fix(wallet): replace text-marker hack with proper raw event API (room.sendRaw + MsgLikeKind.Other)

- Add Timeline.sendRaw() to send custom Matrix events
- Add CustomEventContent type for receiving custom events
- Update TimelineEventContentMapper to handle MsgLikeKind.Other
- Update TimelineItemContentFactory to intercept payment events
- Rewrite DefaultPaymentEventSender to use sendRaw instead of text markers
- Update TimelineItemContentPaymentFactory to parse raw JSON
- Remove text-marker detection from TimelineItemContentMessageFactory
- Update tests to use raw event API
- Mark raw event SDK blocker as RESOLVED in BLOCKERS.md

Event type: co.sulkta.payment.request (reverse-domain format)
Status updates: co.sulkta.payment.status

Benefits:
- Proper Matrix protocol compliance
- No JSON embedded in text messages
- Events won't be indexed by search
- Clean separation from regular messages
This commit is contained in:
Kayos 2026-03-27 11:45:12 -07:00
parent adee67cf0d
commit f2b95d6b8a
10 changed files with 264 additions and 189 deletions

View file

@ -156,55 +156,51 @@
--- ---
## Task 8: Raw Event Handling ✅ COMPLETE ## Task 8: Raw Event Handling ✅ COMPLETE (UPGRADED)
### ✅ RESOLVED: SDK Raw Event API
**Previous blocker:** Matrix Rust SDK did not expose raw event sending or raw JSON access.
**Resolution:** The SDK (version 26.03.24) now provides:
- `Timeline.sendRaw(eventType: String, content: String)` — Sends custom event types
- `MsgLikeKind.Other` with `eventType` field — Receives custom events
- `TimelineItemDebugInfo.originalJson` — Access to raw event JSON via debug info provider
**Implementation updated to use proper raw events instead of text markers.**
### Completed ### Completed
- ✅ **PaymentEventSender.kt** — Interface for sending payment events - ✅ **PaymentEventSender.kt** — Interface for sending payment events
- ✅ **DefaultPaymentEventSender.kt** — Implementation - ✅ **DefaultPaymentEventSender.kt** — Implementation using raw events
- Sends payment as formatted text message with JSON payload - Uses `timeline.sendRaw(eventType, content)` to send custom events
- Format: `[cardano-payment:v1]{...json...}\n💰 Sent X ADA` - Event type: `co.sulkta.payment.request` (reverse-domain format)
- HTML body includes data-payment attribute for future parsing - Status updates: `co.sulkta.payment.status`
- Status updates use separate marker: `[cardano-payment-status:v1]` - No text marker hack — proper Matrix custom events
- ✅ **TimelineItemContentPaymentFactory.kt** — Parser for payment messages - ✅ **TimelineItemContentPaymentFactory.kt** — Parser for payment events
- `isPaymentEvent(body)` — Detects payment marker - `isPaymentEventType(eventType)` — Checks for payment event type
- `isPaymentStatusUpdate(body)` — Detects status update marker - `isStatusUpdateEventType(eventType)` — Checks for status update type
- `createFromBody(body, isSentByMe)` — Parses text message body - `createFromRaw(json, isSentByMe)` — Parses raw JSON from custom events
- `createFromRaw(json, isSentByMe)` — Parses raw JSON (for future SDK extension) - Supports both camelCase and snake_case field names
- Graceful error handling — returns null on malformed JSON - Graceful error handling — returns null on malformed JSON
- ✅ **TimelineItemContentMessageFactory.kt** — Modified to intercept payments - ✅ **TimelineEventContentMapper.kt** — Maps `MsgLikeKind.Other` to `CustomEventContent`
- Added paymentFactory dependency - ✅ **TimelineItemContentFactory.kt** — Handles `CustomEventContent` for payments
- Added isSentByMe parameter to create() - Gets raw JSON via `timelineItemDebugInfoProvider().originalJson`
- TextMessageType checks for payment marker before creating text content - Delegates to paymentFactory for payment event types
- ✅ **TimelineItemContentFactory.kt** — Passes isSentByMe to message factory - ✅ **CustomEventContent.kt** — New EventContent type for custom events
- ✅ **Timeline.sendRaw()** — Added to Timeline interface and RustTimeline implementation
- ✅ **FakePaymentEventSender.kt** — Test fake - ✅ **FakePaymentEventSender.kt** — Test fake
- ✅ **TimelineItemContentPaymentFactoryTest.kt** — Unit tests - ✅ **TimelineItemContentPaymentFactoryTest.kt** — Updated unit tests
### SDK Limitations & Approach
The Matrix Rust SDK does NOT expose:
- Raw event sending (`room.sendRawEvent()`)
- Raw JSON access for UnknownContent
**Workaround implemented:**
Instead of custom event types, we encode payment data in standard text messages:
```
[cardano-payment:v1]{"amount_lovelace":10000000,"to_address":"...","from_address":"...","tx_hash":"...","status":"pending","network":"testnet"}
💰 Sent 10 ADA
```
This approach:
- Works with existing SDK (no fork needed)
- Falls back gracefully (non-wallet clients see "💰 Sent 10 ADA")
- Can be upgraded to proper custom events when SDK exposes raw event APIs
### m.replace Status Updates ### m.replace Status Updates
**Decision:** Due to SDK limitations (no direct access to m.replace relations), status updates are sent as new messages rather than event replacements. **Decision:** Status updates are sent as separate events of type `co.sulkta.payment.status`.
**Future improvement:** When SDK exposes event relations, refactor to use m.replace for cleaner status update thread. **Future improvement:** When SDK exposes event relations, refactor to use m.replace for cleaner status update thread.
### Potential Issues ### Benefits of Raw Event Approach
- ⚠️ Status updates create new timeline events (not ideal, but works) - ✅ Proper Matrix protocol compliance (custom event types, not hacked text)
- ⚠️ Payment messages may be indexed by search (contains JSON) - ✅ Non-wallet clients see "Unknown event" instead of JSON-in-text
- ⚠️ Very long addresses in JSON may hit message length limits (unlikely in practice) - ✅ Clean separation of payment events from regular messages
- ✅ Events won't be indexed by message search
- ✅ No message length limits concern
--- ---

View file

@ -35,7 +35,9 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.CustomEventContent
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.features.wallet.impl.timeline.TimelineItemContentPaymentFactory
@Inject @Inject
class TimelineItemContentFactory( class TimelineItemContentFactory(
@ -49,9 +51,25 @@ class TimelineItemContentFactory(
private val stateFactory: TimelineItemContentStateFactory, private val stateFactory: TimelineItemContentStateFactory,
private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory, private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory,
private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory, private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory,
private val paymentFactory: TimelineItemContentPaymentFactory,
private val sessionId: SessionId, private val sessionId: SessionId,
) { ) {
suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
val isOutgoing = sessionId == eventTimelineItem.sender
// Check for custom event types that we handle specially
val content = eventTimelineItem.content
if (content is CustomEventContent && paymentFactory.isPaymentEventType(content.eventType)) {
// Try to get raw JSON from debug info for payment events
val rawJson = eventTimelineItem.timelineItemDebugInfoProvider().originalJson
if (rawJson != null) {
val paymentContent = paymentFactory.createFromRaw(rawJson, isOutgoing)
if (paymentContent != null) {
return paymentContent
}
}
}
return create( return create(
itemContent = eventTimelineItem.content, itemContent = eventTimelineItem.content,
eventId = eventTimelineItem.eventId, eventId = eventTimelineItem.eventId,
@ -78,7 +96,6 @@ class TimelineItemContentFactory(
senderProfile = senderProfile, senderProfile = senderProfile,
content = itemContent, content = itemContent,
eventId = eventId, eventId = eventId,
isSentByMe = isOutgoing,
) )
} }
is ProfileChangeContent -> { is ProfileChangeContent -> {
@ -100,6 +117,10 @@ class TimelineItemContentFactory(
is UnableToDecryptContent -> utdFactory.create(itemContent) is UnableToDecryptContent -> utdFactory.create(itemContent)
is CallNotifyContent -> TimelineItemRtcNotificationContent() is CallNotifyContent -> TimelineItemRtcNotificationContent()
is UnknownContent -> TimelineItemUnknownContent is UnknownContent -> TimelineItemUnknownContent
is CustomEventContent -> {
// Custom events that weren't handled above (e.g., unknown custom event types)
TimelineItemUnknownContent
}
is LiveLocationContent -> { is LiveLocationContent -> {
val lastKnownLocation = itemContent.locations.mapNotNull { beacon -> val lastKnownLocation = itemContent.locations.mapNotNull { beacon ->
Location.fromGeoUri(beacon.geoUri) Location.fromGeoUri(beacon.geoUri)

View file

@ -26,7 +26,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.features.wallet.impl.timeline.TimelineItemContentPaymentFactory
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.androidutils.text.safeLinkify import io.element.android.libraries.androidutils.text.safeLinkify
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
@ -66,14 +66,12 @@ class TimelineItemContentMessageFactory(
private val htmlConverterProvider: HtmlConverterProvider, private val htmlConverterProvider: HtmlConverterProvider,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val textPillificationHelper: TextPillificationHelper, private val textPillificationHelper: TextPillificationHelper,
private val paymentFactory: TimelineItemContentPaymentFactory,
) { ) {
fun create( fun create(
content: MessageContent, content: MessageContent,
senderId: UserId, senderId: UserId,
senderProfile: ProfileDetails, senderProfile: ProfileDetails,
eventId: EventId?, eventId: EventId?,
isSentByMe: Boolean = false,
): TimelineItemEventContent { ): TimelineItemEventContent {
return when (val messageType = content.type) { return when (val messageType = content.type) {
is EmoteMessageType -> { is EmoteMessageType -> {
@ -257,13 +255,7 @@ class TimelineItemContentMessageFactory(
} }
is TextMessageType -> { is TextMessageType -> {
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()
// Check for Cardano payment events embedded in text messages createTextContent(body, messageType, content.isEdited)
if (paymentFactory.isPaymentEvent(body)) {
paymentFactory.createFromBody(body, isSentByMe)
?: createTextContent(body, messageType, content.isEdited)
} else {
createTextContent(body, messageType, content.isEdited)
}
} }
is OtherMessageType -> { is OtherMessageType -> {
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()

View file

@ -21,14 +21,10 @@ import kotlinx.serialization.json.Json
/** /**
* Default implementation of [PaymentEventSender]. * Default implementation of [PaymentEventSender].
* *
* Sends payment events as specially formatted text messages that can be * Sends payment events as custom Matrix events using the raw event API.
* parsed by wallet-enabled clients while remaining readable for others.
* *
* Message format: * Event type: co.sulkta.payment.request
* ``` * Event content: JSON-serialized [PaymentEventData]
* [cardano-payment:v1]{"amount_lovelace":...,"to_address":"...","from_address":"...","tx_hash":"...","status":"...","network":"..."}
* 💰 Sent X ADA
* ```
*/ */
@Inject @Inject
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
@ -53,13 +49,11 @@ class DefaultPaymentEventSender : PaymentEventSender {
network = network, network = network,
) )
val fallbackText = "💰 Sent ${TimelineItemPaymentContent.formatAda(signedTx.actualAmount)}" val content = json.encodeToString(paymentData)
val messageBody = formatPaymentMessage(paymentData, fallbackText)
return timeline.sendMessage( return timeline.sendRaw(
body = messageBody, eventType = PAYMENT_EVENT_TYPE,
htmlBody = formatPaymentHtml(paymentData, fallbackText), content = content,
intentionalMentions = emptyList(),
) )
} }
@ -69,44 +63,25 @@ class DefaultPaymentEventSender : PaymentEventSender {
newStatus: String, newStatus: String,
network: String, network: String,
): Result<Unit> { ): Result<Unit> {
// Status updates use m.relates_to with m.replace relation
// Since the SDK doesn't expose raw event editing, we send a new event
// with a reference to the original transaction
val statusData = PaymentStatusUpdateData( val statusData = PaymentStatusUpdateData(
txHash = txHash, txHash = txHash,
status = newStatus, status = newStatus,
network = network, network = network,
) )
val statusText = when (newStatus.lowercase()) { val content = json.encodeToString(statusData)
"confirmed" -> "✅ Payment confirmed"
"failed" -> "❌ Payment failed"
else -> "⏳ Payment $newStatus"
}
val messageBody = "[cardano-payment-status:v1]${json.encodeToString(statusData)}\n$statusText (tx: ${txHash.take(8)}...)" return timeline.sendRaw(
eventType = STATUS_UPDATE_EVENT_TYPE,
return timeline.sendMessage( content = content,
body = messageBody,
htmlBody = null,
intentionalMentions = emptyList(),
) )
} }
private fun formatPaymentMessage(data: PaymentEventData, fallbackText: String): String {
val jsonPayload = json.encodeToString(data)
return "$PAYMENT_MARKER$jsonPayload\n$fallbackText"
}
private fun formatPaymentHtml(data: PaymentEventData, fallbackText: String): String {
val jsonPayload = json.encodeToString(data)
// Embed payment data in a data attribute for potential future parsing
return """<span data-mx-payment="$PAYMENT_MARKER" data-payment-json='$jsonPayload'>$fallbackText</span>"""
}
companion object { companion object {
const val PAYMENT_MARKER = "[cardano-payment:v1]" /** Custom event type for Cardano payment requests (reverse-domain format) */
const val STATUS_MARKER = "[cardano-payment-status:v1]" const val PAYMENT_EVENT_TYPE = "co.sulkta.payment.request"
/** Custom event type for payment status updates */
const val STATUS_UPDATE_EVENT_TYPE = "co.sulkta.payment.status"
} }
} }

View file

@ -9,14 +9,20 @@ package io.element.android.features.wallet.impl.timeline
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.PaymentCardStatus import io.element.android.features.wallet.api.PaymentCardStatus
import io.element.android.features.wallet.api.timeline.TimelineItemPaymentContent import io.element.android.features.wallet.api.timeline.TimelineItemPaymentContent
import io.element.android.features.wallet.impl.payment.DefaultPaymentEventSender
import io.element.android.features.wallet.impl.payment.PaymentEventData import io.element.android.features.wallet.impl.payment.PaymentEventData
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.json.longOrNull
import timber.log.Timber import timber.log.Timber
/** /**
* Factory for creating [TimelineItemPaymentContent] from payment event messages. * Factory for creating [TimelineItemPaymentContent] from raw payment events.
* *
* Parses messages that start with the payment marker and extracts the JSON payload. * Parses custom events with type "co.sulkta.payment.request" and extracts the payment data.
*/ */
@Inject @Inject
class TimelineItemContentPaymentFactory { class TimelineItemContentPaymentFactory {
@ -26,41 +32,49 @@ class TimelineItemContentPaymentFactory {
} }
/** /**
* Check if a message body contains a payment event. * Check if an event type is a payment event.
*/ */
fun isPaymentEvent(body: String): Boolean { fun isPaymentEventType(eventType: String): Boolean {
return body.startsWith(PAYMENT_MARKER) return eventType == DefaultPaymentEventSender.PAYMENT_EVENT_TYPE
} }
/** /**
* Check if a message body contains a payment status update. * Check if an event type is a payment status update.
*/ */
fun isPaymentStatusUpdate(body: String): Boolean { fun isStatusUpdateEventType(eventType: String): Boolean {
return body.startsWith(STATUS_MARKER) return eventType == DefaultPaymentEventSender.STATUS_UPDATE_EVENT_TYPE
} }
/** /**
* Create a [TimelineItemPaymentContent] from a payment message body. * Create a [TimelineItemPaymentContent] from raw JSON event content.
* *
* @param body The message body starting with [PAYMENT_MARKER] * @param rawJson The raw JSON content from the Matrix event
* @param isSentByMe Whether the current user sent this message * @param isSentByMe Whether the current user sent this event
* @return The parsed payment content, or null if parsing failed * @return The parsed payment content, or null if parsing failed
*/ */
fun createFromBody(body: String, isSentByMe: Boolean): TimelineItemPaymentContent? { fun createFromRaw(rawJson: String, isSentByMe: Boolean): TimelineItemPaymentContent? {
return try { return try {
val jsonStart = body.indexOf(PAYMENT_MARKER) + PAYMENT_MARKER.length // Try to parse the content field from the raw event JSON
val jsonEnd = body.indexOf('\n', jsonStart).takeIf { it != -1 } ?: body.length val eventJson = json.parseToJsonElement(rawJson).jsonObject
val jsonPayload = body.substring(jsonStart, jsonEnd) val content = eventJson["content"]?.jsonObject ?: eventJson
val fallbackText = if (jsonEnd < body.length) {
body.substring(jsonEnd + 1).trim() val data = parsePaymentData(content)
if (data != null) {
TimelineItemPaymentContent(
amountLovelace = data.amountLovelace,
toAddress = data.toAddress,
fromAddress = data.fromAddress,
txHash = data.txHash,
status = parseStatus(data.status),
network = data.network,
isSentByMe = isSentByMe,
fallbackText = "💰 ${TimelineItemPaymentContent.formatAda(data.amountLovelace)}",
)
} else { } else {
"Payment" null
} }
val data = json.decodeFromString<PaymentEventData>(jsonPayload)
createFromData(data, isSentByMe, fallbackText)
} catch (e: Exception) { } catch (e: Exception) {
Timber.w(e, "Failed to parse payment event from body") Timber.w(e, "Failed to parse payment event from raw JSON")
null null
} }
} }
@ -71,7 +85,6 @@ class TimelineItemContentPaymentFactory {
fun createFromData( fun createFromData(
data: PaymentEventData, data: PaymentEventData,
isSentByMe: Boolean, isSentByMe: Boolean,
fallbackText: String,
): TimelineItemPaymentContent { ): TimelineItemPaymentContent {
return TimelineItemPaymentContent( return TimelineItemPaymentContent(
amountLovelace = data.amountLovelace, amountLovelace = data.amountLovelace,
@ -81,35 +94,40 @@ class TimelineItemContentPaymentFactory {
status = parseStatus(data.status), status = parseStatus(data.status),
network = data.network, network = data.network,
isSentByMe = isSentByMe, isSentByMe = isSentByMe,
fallbackText = fallbackText, fallbackText = "💰 ${TimelineItemPaymentContent.formatAda(data.amountLovelace)}",
) )
} }
/** private fun parsePaymentData(content: JsonObject): PaymentEventData? {
* Create a [TimelineItemPaymentContent] from raw JSON.
*
* This is the method called from TimelineItemContentFactory when
* handling UnknownContent (if we had access to raw JSON).
*
* @param rawJson The raw JSON content
* @param isSentByMe Whether the current user sent this event
* @return The parsed payment content, or null if parsing failed
*/
fun createFromRaw(rawJson: String, isSentByMe: Boolean): TimelineItemPaymentContent? {
return try { return try {
val data = json.decodeFromString<PaymentEventData>(rawJson) val amountLovelace = content["amount_lovelace"]?.jsonPrimitive?.longOrNull
TimelineItemPaymentContent( ?: content["amountLovelace"]?.jsonPrimitive?.longOrNull
amountLovelace = data.amountLovelace, ?: return null
toAddress = data.toAddress,
fromAddress = data.fromAddress, val toAddress = content["to_address"]?.jsonPrimitive?.content
txHash = data.txHash, ?: content["toAddress"]?.jsonPrimitive?.content
status = parseStatus(data.status), ?: return null
network = data.network,
isSentByMe = isSentByMe, val fromAddress = content["from_address"]?.jsonPrimitive?.content
fallbackText = "💰 ${TimelineItemPaymentContent.formatAda(data.amountLovelace)}", ?: content["fromAddress"]?.jsonPrimitive?.content
?: return null
val txHash = content["tx_hash"]?.jsonPrimitive?.content
?: content["txHash"]?.jsonPrimitive?.content
val status = content["status"]?.jsonPrimitive?.content ?: "pending"
val network = content["network"]?.jsonPrimitive?.content ?: "mainnet"
PaymentEventData(
amountLovelace = amountLovelace,
toAddress = toAddress,
fromAddress = fromAddress,
txHash = txHash,
status = status,
network = network,
) )
} catch (e: Exception) { } catch (e: Exception) {
Timber.w(e, "Failed to parse payment event from raw JSON") Timber.w(e, "Failed to parse payment data from JSON object")
null null
} }
} }
@ -124,7 +142,9 @@ class TimelineItemContentPaymentFactory {
} }
companion object { companion object {
const val PAYMENT_MARKER = "[cardano-payment:v1]" /** Custom event type for Cardano payment requests */
const val STATUS_MARKER = "[cardano-payment-status:v1]" const val PAYMENT_EVENT_TYPE = DefaultPaymentEventSender.PAYMENT_EVENT_TYPE
/** Custom event type for payment status updates */
const val STATUS_UPDATE_EVENT_TYPE = DefaultPaymentEventSender.STATUS_UPDATE_EVENT_TYPE
} }
} }

View file

@ -8,34 +8,60 @@ package io.element.android.features.wallet.impl.timeline
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.wallet.api.PaymentCardStatus import io.element.android.features.wallet.api.PaymentCardStatus
import io.element.android.features.wallet.impl.payment.DefaultPaymentEventSender
import org.junit.Test import org.junit.Test
class TimelineItemContentPaymentFactoryTest { class TimelineItemContentPaymentFactoryTest {
private val factory = TimelineItemContentPaymentFactory() private val factory = TimelineItemContentPaymentFactory()
@Test @Test
fun `isPaymentEvent returns true for valid payment marker`() { fun `isPaymentEventType returns true for payment event type`() {
val body = "[cardano-payment:v1]{\"amount_lovelace\":10000000}\n💰 Sent 10 ADA" assertThat(factory.isPaymentEventType(DefaultPaymentEventSender.PAYMENT_EVENT_TYPE)).isTrue()
assertThat(factory.isPaymentEvent(body)).isTrue() assertThat(factory.isPaymentEventType("co.sulkta.payment.request")).isTrue()
} }
@Test @Test
fun `isPaymentEvent returns false for regular message`() { fun `isPaymentEventType returns false for other event types`() {
val body = "Hello, this is a regular message" assertThat(factory.isPaymentEventType("m.room.message")).isFalse()
assertThat(factory.isPaymentEvent(body)).isFalse() assertThat(factory.isPaymentEventType("m.room.member")).isFalse()
assertThat(factory.isPaymentEventType("co.other.event")).isFalse()
} }
@Test @Test
fun `isPaymentEvent returns false for empty string`() { fun `isStatusUpdateEventType returns true for status update event type`() {
assertThat(factory.isPaymentEvent("")).isFalse() assertThat(factory.isStatusUpdateEventType(DefaultPaymentEventSender.STATUS_UPDATE_EVENT_TYPE)).isTrue()
assertThat(factory.isStatusUpdateEventType("co.sulkta.payment.status")).isTrue()
} }
@Test @Test
fun `createFromBody parses valid payment event`() { fun `isStatusUpdateEventType returns false for other event types`() {
val body = """[cardano-payment:v1]{"amount_lovelace":10000000,"to_address":"addr_test1abc","from_address":"addr_test1xyz","tx_hash":"hash123","status":"pending","network":"testnet"} assertThat(factory.isStatusUpdateEventType("m.room.message")).isFalse()
💰 Sent 10 ADA""" assertThat(factory.isStatusUpdateEventType("co.sulkta.payment.request")).isFalse()
}
val result = factory.createFromBody(body, isSentByMe = true) @Test
fun `createFromRaw parses valid payment JSON`() {
val json = """{"amountLovelace":25000000,"toAddress":"addr1","fromAddress":"addr2","txHash":"abc123","status":"confirmed","network":"mainnet"}"""
val result = factory.createFromRaw(json, isSentByMe = false)
assertThat(result).isNotNull()
assertThat(result!!.amountLovelace).isEqualTo(25_000_000)
assertThat(result.amountAda).isEqualTo("25 ADA")
assertThat(result.toAddress).isEqualTo("addr1")
assertThat(result.fromAddress).isEqualTo("addr2")
assertThat(result.txHash).isEqualTo("abc123")
assertThat(result.status).isEqualTo(PaymentCardStatus.CONFIRMED)
assertThat(result.network).isEqualTo("mainnet")
assertThat(result.isTestnet).isFalse()
assertThat(result.isSentByMe).isFalse()
}
@Test
fun `createFromRaw parses snake_case field names`() {
val json = """{"amount_lovelace":10000000,"to_address":"addr_test1abc","from_address":"addr_test1xyz","tx_hash":"hash123","status":"pending","network":"testnet"}"""
val result = factory.createFromRaw(json, isSentByMe = true)
assertThat(result).isNotNull() assertThat(result).isNotNull()
assertThat(result!!.amountLovelace).isEqualTo(10_000_000) assertThat(result!!.amountLovelace).isEqualTo(10_000_000)
@ -45,25 +71,34 @@ class TimelineItemContentPaymentFactoryTest {
assertThat(result.status).isEqualTo(PaymentCardStatus.PENDING) assertThat(result.status).isEqualTo(PaymentCardStatus.PENDING)
assertThat(result.network).isEqualTo("testnet") assertThat(result.network).isEqualTo("testnet")
assertThat(result.isSentByMe).isTrue() assertThat(result.isSentByMe).isTrue()
assertThat(result.fallbackText).isEqualTo("💰 Sent 10 ADA")
} }
@Test @Test
fun `createFromBody parses confirmed status`() { fun `createFromRaw parses wrapped event JSON with content field`() {
val body = """[cardano-payment:v1]{"amount_lovelace":5000000,"to_address":"addr","from_address":"addr2","tx_hash":"hash","status":"confirmed","network":"mainnet"}""" val json = """{"type":"co.sulkta.payment.request","content":{"amountLovelace":5000000,"toAddress":"addr","fromAddress":"addr2","txHash":"hash","status":"confirmed","network":"mainnet"}}"""
val result = factory.createFromBody(body, isSentByMe = false) val result = factory.createFromRaw(json, isSentByMe = false)
assertThat(result).isNotNull()
assertThat(result!!.amountLovelace).isEqualTo(5_000_000)
assertThat(result.status).isEqualTo(PaymentCardStatus.CONFIRMED)
}
@Test
fun `createFromRaw parses confirmed status`() {
val json = """{"amountLovelace":5000000,"toAddress":"addr","fromAddress":"addr2","txHash":"hash","status":"confirmed","network":"mainnet"}"""
val result = factory.createFromRaw(json, isSentByMe = false)
assertThat(result).isNotNull() assertThat(result).isNotNull()
assertThat(result!!.status).isEqualTo(PaymentCardStatus.CONFIRMED) assertThat(result!!.status).isEqualTo(PaymentCardStatus.CONFIRMED)
assertThat(result.isSentByMe).isFalse()
} }
@Test @Test
fun `createFromBody parses failed status`() { fun `createFromRaw parses failed status`() {
val body = """[cardano-payment:v1]{"amount_lovelace":1000000,"to_address":"a","from_address":"b","tx_hash":null,"status":"failed","network":"testnet"}""" val json = """{"amountLovelace":1000000,"toAddress":"a","fromAddress":"b","txHash":null,"status":"failed","network":"testnet"}"""
val result = factory.createFromBody(body, isSentByMe = true) val result = factory.createFromRaw(json, isSentByMe = true)
assertThat(result).isNotNull() assertThat(result).isNotNull()
assertThat(result!!.status).isEqualTo(PaymentCardStatus.FAILED) assertThat(result!!.status).isEqualTo(PaymentCardStatus.FAILED)
@ -71,34 +106,13 @@ class TimelineItemContentPaymentFactoryTest {
} }
@Test @Test
fun `createFromBody returns null for malformed JSON`() { fun `createFromRaw defaults to pending for unknown status`() {
val body = "[cardano-payment:v1]{not valid json}\n💰 Sent 10 ADA" val json = """{"amountLovelace":1000000,"toAddress":"a","fromAddress":"b","status":"unknown_status","network":"mainnet"}"""
val result = factory.createFromBody(body, isSentByMe = true) val result = factory.createFromRaw(json, isSentByMe = true)
assertThat(result).isNull()
}
@Test
fun `createFromBody returns null for missing marker`() {
val body = """{"amount_lovelace":10000000,"to_address":"addr","from_address":"addr2","status":"pending","network":"testnet"}"""
val result = factory.createFromBody(body, isSentByMe = true)
assertThat(result).isNull()
}
@Test
fun `createFromRaw parses valid JSON`() {
val json = """{"amount_lovelace":25000000,"to_address":"addr1","from_address":"addr2","tx_hash":"abc123","status":"confirmed","network":"mainnet"}"""
val result = factory.createFromRaw(json, isSentByMe = false)
assertThat(result).isNotNull() assertThat(result).isNotNull()
assertThat(result!!.amountLovelace).isEqualTo(25_000_000) assertThat(result!!.status).isEqualTo(PaymentCardStatus.PENDING)
assertThat(result.amountAda).isEqualTo("25 ADA")
assertThat(result.status).isEqualTo(PaymentCardStatus.CONFIRMED)
assertThat(result.isTestnet).isFalse()
} }
@Test @Test
@ -111,13 +125,30 @@ class TimelineItemContentPaymentFactoryTest {
} }
@Test @Test
fun `isPaymentStatusUpdate returns true for valid status marker`() { fun `createFromRaw returns null for missing required fields`() {
val body = "[cardano-payment-status:v1]{\"tx_hash\":\"abc\"}\n✅ Payment confirmed" val json = """{"amountLovelace":1000000}"""
assertThat(factory.isPaymentStatusUpdate(body)).isTrue()
val result = factory.createFromRaw(json, isSentByMe = true)
assertThat(result).isNull()
} }
@Test @Test
fun `isPaymentStatusUpdate returns false for regular message`() { fun `createFromRaw returns null for empty JSON`() {
assertThat(factory.isPaymentStatusUpdate("Hello")).isFalse() val json = "{}"
val result = factory.createFromRaw(json, isSentByMe = true)
assertThat(result).isNull()
}
@Test
fun `createFromRaw formats fallback text correctly`() {
val json = """{"amountLovelace":1500000,"toAddress":"a","fromAddress":"b","status":"pending","network":"mainnet"}"""
val result = factory.createFromRaw(json, isSentByMe = true)
assertThat(result).isNotNull()
assertThat(result!!.fallbackText).isEqualTo("💰 1.5 ADA")
} }
} }

View file

@ -71,6 +71,18 @@ interface Timeline : AutoCloseable {
intentionalMentions: List<IntentionalMention>, intentionalMentions: List<IntentionalMention>,
): Result<Unit> ): Result<Unit>
/**
* Send a raw/custom event to the room.
*
* @param eventType The event type (e.g., "co.sulkta.payment.request")
* @param content The JSON content of the event
* @return Result indicating success or failure
*/
suspend fun sendRaw(
eventType: String,
content: String,
): Result<Unit>
suspend fun editMessage( suspend fun editMessage(
eventOrTransactionId: EventOrTransactionId, eventOrTransactionId: EventOrTransactionId,
body: String, body: String,

View file

@ -118,3 +118,14 @@ data object LegacyCallInviteContent : EventContent
data object CallNotifyContent : EventContent data object CallNotifyContent : EventContent
data object UnknownContent : EventContent data object UnknownContent : EventContent
/**
* Content for custom/unknown message-like events that we want to handle specially.
*
* @param eventType The Matrix event type (e.g., "co.sulkta.payment.request")
* @param rawJson The raw JSON content of the event, if available
*/
data class CustomEventContent(
val eventType: String,
val rawJson: String?,
) : EventContent

View file

@ -279,6 +279,15 @@ class RustTimeline(
} }
} }
override suspend fun sendRaw(
eventType: String,
content: String,
): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions {
inner.sendRaw(eventType, content)
}
}
override suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result<Unit> = withContext(dispatcher) { override suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions { runCatchingExceptions {
inner.redactEvent( inner.redactEvent(

View file

@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.CustomEventContent
import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause
import io.element.android.libraries.matrix.impl.media.map import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.map import io.element.android.libraries.matrix.impl.poll.map
@ -111,7 +112,14 @@ class TimelineEventContentMapper(
// Live location messages are a special kind of message that we want to treat as unknown content for now // Live location messages are a special kind of message that we want to treat as unknown content for now
UnknownContent UnknownContent
} }
is MsgLikeKind.Other -> UnknownContent is MsgLikeKind.Other -> {
// MsgLikeKind.Other contains custom event types
// Pass through the event type so downstream handlers can process it
CustomEventContent(
eventType = kind.eventType,
rawJson = null, // Raw JSON accessed via TimelineItemDebugInfoProvider
)
}
} }
} }
is TimelineItemContent.ProfileChange -> { is TimelineItemContent.ProfileChange -> {