diff --git a/BLOCKERS.md b/BLOCKERS.md index c429442f35..9f0d1688ef 100644 --- a/BLOCKERS.md +++ b/BLOCKERS.md @@ -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 - ✅ **PaymentEventSender.kt** — Interface for sending payment events -- ✅ **DefaultPaymentEventSender.kt** — Implementation - - Sends payment as formatted text message with JSON payload - - Format: `[cardano-payment:v1]{...json...}\n💰 Sent X ADA` - - HTML body includes data-payment attribute for future parsing - - Status updates use separate marker: `[cardano-payment-status:v1]` -- ✅ **TimelineItemContentPaymentFactory.kt** — Parser for payment messages - - `isPaymentEvent(body)` — Detects payment marker - - `isPaymentStatusUpdate(body)` — Detects status update marker - - `createFromBody(body, isSentByMe)` — Parses text message body - - `createFromRaw(json, isSentByMe)` — Parses raw JSON (for future SDK extension) +- ✅ **DefaultPaymentEventSender.kt** — Implementation using raw events + - Uses `timeline.sendRaw(eventType, content)` to send custom events + - Event type: `co.sulkta.payment.request` (reverse-domain format) + - Status updates: `co.sulkta.payment.status` + - No text marker hack — proper Matrix custom events +- ✅ **TimelineItemContentPaymentFactory.kt** — Parser for payment events + - `isPaymentEventType(eventType)` — Checks for payment event type + - `isStatusUpdateEventType(eventType)` — Checks for status update type + - `createFromRaw(json, isSentByMe)` — Parses raw JSON from custom events + - Supports both camelCase and snake_case field names - Graceful error handling — returns null on malformed JSON -- ✅ **TimelineItemContentMessageFactory.kt** — Modified to intercept payments - - Added paymentFactory dependency - - Added isSentByMe parameter to create() - - TextMessageType checks for payment marker before creating text content -- ✅ **TimelineItemContentFactory.kt** — Passes isSentByMe to message factory +- ✅ **TimelineEventContentMapper.kt** — Maps `MsgLikeKind.Other` to `CustomEventContent` +- ✅ **TimelineItemContentFactory.kt** — Handles `CustomEventContent` for payments + - Gets raw JSON via `timelineItemDebugInfoProvider().originalJson` + - Delegates to paymentFactory for payment event types +- ✅ **CustomEventContent.kt** — New EventContent type for custom events +- ✅ **Timeline.sendRaw()** — Added to Timeline interface and RustTimeline implementation - ✅ **FakePaymentEventSender.kt** — Test fake -- ✅ **TimelineItemContentPaymentFactoryTest.kt** — 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 +- ✅ **TimelineItemContentPaymentFactoryTest.kt** — Updated unit tests ### 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. -### Potential Issues -- ⚠️ Status updates create new timeline events (not ideal, but works) -- ⚠️ Payment messages may be indexed by search (contains JSON) -- ⚠️ Very long addresses in JSON may hit message length limits (unlikely in practice) +### Benefits of Raw Event Approach +- ✅ Proper Matrix protocol compliance (custom event types, not hacked text) +- ✅ Non-wallet clients see "Unknown event" instead of JSON-in-text +- ✅ Clean separation of payment events from regular messages +- ✅ Events won't be indexed by message search +- ✅ No message length limits concern --- diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index c2bc4debe8..f6c3a958ad 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -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.UnableToDecryptContent 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.features.wallet.impl.timeline.TimelineItemContentPaymentFactory @Inject class TimelineItemContentFactory( @@ -49,9 +51,25 @@ class TimelineItemContentFactory( private val stateFactory: TimelineItemContentStateFactory, private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory, private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory, + private val paymentFactory: TimelineItemContentPaymentFactory, private val sessionId: SessionId, ) { 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( itemContent = eventTimelineItem.content, eventId = eventTimelineItem.eventId, @@ -78,7 +96,6 @@ class TimelineItemContentFactory( senderProfile = senderProfile, content = itemContent, eventId = eventId, - isSentByMe = isOutgoing, ) } is ProfileChangeContent -> { @@ -100,6 +117,10 @@ class TimelineItemContentFactory( is UnableToDecryptContent -> utdFactory.create(itemContent) is CallNotifyContent -> TimelineItemRtcNotificationContent() is UnknownContent -> TimelineItemUnknownContent + is CustomEventContent -> { + // Custom events that weren't handled above (e.g., unknown custom event types) + TimelineItemUnknownContent + } is LiveLocationContent -> { val lastKnownLocation = itemContent.locations.mapNotNull { beacon -> Location.fromGeoUri(beacon.geoUri) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index ff4447f8f4..385956dcd1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -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.TimelineItemVoiceContent 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.text.safeLinkify import io.element.android.libraries.core.mimetype.MimeTypes @@ -66,14 +66,12 @@ class TimelineItemContentMessageFactory( private val htmlConverterProvider: HtmlConverterProvider, private val permalinkParser: PermalinkParser, private val textPillificationHelper: TextPillificationHelper, - private val paymentFactory: TimelineItemContentPaymentFactory, ) { fun create( content: MessageContent, senderId: UserId, senderProfile: ProfileDetails, eventId: EventId?, - isSentByMe: Boolean = false, ): TimelineItemEventContent { return when (val messageType = content.type) { is EmoteMessageType -> { @@ -257,13 +255,7 @@ class TimelineItemContentMessageFactory( } is TextMessageType -> { val body = messageType.body.trimEnd() - // Check for Cardano payment events embedded in text messages - if (paymentFactory.isPaymentEvent(body)) { - paymentFactory.createFromBody(body, isSentByMe) - ?: createTextContent(body, messageType, content.isEdited) - } else { - createTextContent(body, messageType, content.isEdited) - } + createTextContent(body, messageType, content.isEdited) } is OtherMessageType -> { val body = messageType.body.trimEnd() diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/DefaultPaymentEventSender.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/DefaultPaymentEventSender.kt index e301664f5b..faf4a71cff 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/DefaultPaymentEventSender.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/DefaultPaymentEventSender.kt @@ -21,14 +21,10 @@ import kotlinx.serialization.json.Json /** * Default implementation of [PaymentEventSender]. * - * Sends payment events as specially formatted text messages that can be - * parsed by wallet-enabled clients while remaining readable for others. + * Sends payment events as custom Matrix events using the raw event API. * - * Message format: - * ``` - * [cardano-payment:v1]{"amount_lovelace":...,"to_address":"...","from_address":"...","tx_hash":"...","status":"...","network":"..."} - * 💰 Sent X ADA - * ``` + * Event type: co.sulkta.payment.request + * Event content: JSON-serialized [PaymentEventData] */ @Inject @ContributesBinding(SessionScope::class) @@ -53,13 +49,11 @@ class DefaultPaymentEventSender : PaymentEventSender { network = network, ) - val fallbackText = "💰 Sent ${TimelineItemPaymentContent.formatAda(signedTx.actualAmount)}" - val messageBody = formatPaymentMessage(paymentData, fallbackText) + val content = json.encodeToString(paymentData) - return timeline.sendMessage( - body = messageBody, - htmlBody = formatPaymentHtml(paymentData, fallbackText), - intentionalMentions = emptyList(), + return timeline.sendRaw( + eventType = PAYMENT_EVENT_TYPE, + content = content, ) } @@ -69,44 +63,25 @@ class DefaultPaymentEventSender : PaymentEventSender { newStatus: String, network: String, ): Result { - // 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( txHash = txHash, status = newStatus, network = network, ) - val statusText = when (newStatus.lowercase()) { - "confirmed" -> "✅ Payment confirmed" - "failed" -> "❌ Payment failed" - else -> "⏳ Payment $newStatus" - } + val content = json.encodeToString(statusData) - val messageBody = "[cardano-payment-status:v1]${json.encodeToString(statusData)}\n$statusText (tx: ${txHash.take(8)}...)" - - return timeline.sendMessage( - body = messageBody, - htmlBody = null, - intentionalMentions = emptyList(), + return timeline.sendRaw( + eventType = STATUS_UPDATE_EVENT_TYPE, + content = content, ) } - 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 """$fallbackText""" - } - companion object { - const val PAYMENT_MARKER = "[cardano-payment:v1]" - const val STATUS_MARKER = "[cardano-payment-status:v1]" + /** Custom event type for Cardano payment requests (reverse-domain format) */ + 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" } } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemContentPaymentFactory.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemContentPaymentFactory.kt index 60bcc82488..0547d36c35 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemContentPaymentFactory.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemContentPaymentFactory.kt @@ -9,14 +9,20 @@ package io.element.android.features.wallet.impl.timeline import dev.zacsweers.metro.Inject import io.element.android.features.wallet.api.PaymentCardStatus 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 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 /** - * 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 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 { - return body.startsWith(PAYMENT_MARKER) + fun isPaymentEventType(eventType: String): Boolean { + 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 { - return body.startsWith(STATUS_MARKER) + fun isStatusUpdateEventType(eventType: String): Boolean { + 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 isSentByMe Whether the current user sent this message + * @param rawJson The raw JSON content from the Matrix event + * @param isSentByMe Whether the current user sent this event * @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 { - val jsonStart = body.indexOf(PAYMENT_MARKER) + PAYMENT_MARKER.length - val jsonEnd = body.indexOf('\n', jsonStart).takeIf { it != -1 } ?: body.length - val jsonPayload = body.substring(jsonStart, jsonEnd) - val fallbackText = if (jsonEnd < body.length) { - body.substring(jsonEnd + 1).trim() + // Try to parse the content field from the raw event JSON + val eventJson = json.parseToJsonElement(rawJson).jsonObject + val content = eventJson["content"]?.jsonObject ?: eventJson + + 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 { - "Payment" + null } - - val data = json.decodeFromString(jsonPayload) - createFromData(data, isSentByMe, fallbackText) } 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 } } @@ -71,7 +85,6 @@ class TimelineItemContentPaymentFactory { fun createFromData( data: PaymentEventData, isSentByMe: Boolean, - fallbackText: String, ): TimelineItemPaymentContent { return TimelineItemPaymentContent( amountLovelace = data.amountLovelace, @@ -81,35 +94,40 @@ class TimelineItemContentPaymentFactory { status = parseStatus(data.status), network = data.network, isSentByMe = isSentByMe, - fallbackText = fallbackText, + fallbackText = "💰 ${TimelineItemPaymentContent.formatAda(data.amountLovelace)}", ) } - /** - * 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? { + private fun parsePaymentData(content: JsonObject): PaymentEventData? { return try { - val data = json.decodeFromString(rawJson) - 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)}", + val amountLovelace = content["amount_lovelace"]?.jsonPrimitive?.longOrNull + ?: content["amountLovelace"]?.jsonPrimitive?.longOrNull + ?: return null + + val toAddress = content["to_address"]?.jsonPrimitive?.content + ?: content["toAddress"]?.jsonPrimitive?.content + ?: return null + + val fromAddress = content["from_address"]?.jsonPrimitive?.content + ?: 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) { - Timber.w(e, "Failed to parse payment event from raw JSON") + Timber.w(e, "Failed to parse payment data from JSON object") null } } @@ -124,7 +142,9 @@ class TimelineItemContentPaymentFactory { } companion object { - const val PAYMENT_MARKER = "[cardano-payment:v1]" - const val STATUS_MARKER = "[cardano-payment-status:v1]" + /** Custom event type for Cardano payment requests */ + 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 } } diff --git a/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemContentPaymentFactoryTest.kt b/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemContentPaymentFactoryTest.kt index c7247b7dab..46ed98d69a 100644 --- a/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemContentPaymentFactoryTest.kt +++ b/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemContentPaymentFactoryTest.kt @@ -8,34 +8,60 @@ package io.element.android.features.wallet.impl.timeline import com.google.common.truth.Truth.assertThat import io.element.android.features.wallet.api.PaymentCardStatus +import io.element.android.features.wallet.impl.payment.DefaultPaymentEventSender import org.junit.Test class TimelineItemContentPaymentFactoryTest { private val factory = TimelineItemContentPaymentFactory() @Test - fun `isPaymentEvent returns true for valid payment marker`() { - val body = "[cardano-payment:v1]{\"amount_lovelace\":10000000}\n💰 Sent 10 ADA" - assertThat(factory.isPaymentEvent(body)).isTrue() + fun `isPaymentEventType returns true for payment event type`() { + assertThat(factory.isPaymentEventType(DefaultPaymentEventSender.PAYMENT_EVENT_TYPE)).isTrue() + assertThat(factory.isPaymentEventType("co.sulkta.payment.request")).isTrue() } @Test - fun `isPaymentEvent returns false for regular message`() { - val body = "Hello, this is a regular message" - assertThat(factory.isPaymentEvent(body)).isFalse() + fun `isPaymentEventType returns false for other event types`() { + assertThat(factory.isPaymentEventType("m.room.message")).isFalse() + assertThat(factory.isPaymentEventType("m.room.member")).isFalse() + assertThat(factory.isPaymentEventType("co.other.event")).isFalse() } @Test - fun `isPaymentEvent returns false for empty string`() { - assertThat(factory.isPaymentEvent("")).isFalse() + fun `isStatusUpdateEventType returns true for status update event type`() { + assertThat(factory.isStatusUpdateEventType(DefaultPaymentEventSender.STATUS_UPDATE_EVENT_TYPE)).isTrue() + assertThat(factory.isStatusUpdateEventType("co.sulkta.payment.status")).isTrue() } @Test - fun `createFromBody parses valid payment event`() { - val body = """[cardano-payment:v1]{"amount_lovelace":10000000,"to_address":"addr_test1abc","from_address":"addr_test1xyz","tx_hash":"hash123","status":"pending","network":"testnet"} -💰 Sent 10 ADA""" + fun `isStatusUpdateEventType returns false for other event types`() { + assertThat(factory.isStatusUpdateEventType("m.room.message")).isFalse() + 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!!.amountLovelace).isEqualTo(10_000_000) @@ -45,25 +71,34 @@ class TimelineItemContentPaymentFactoryTest { assertThat(result.status).isEqualTo(PaymentCardStatus.PENDING) assertThat(result.network).isEqualTo("testnet") assertThat(result.isSentByMe).isTrue() - assertThat(result.fallbackText).isEqualTo("💰 Sent 10 ADA") } @Test - fun `createFromBody parses confirmed status`() { - val body = """[cardano-payment:v1]{"amount_lovelace":5000000,"to_address":"addr","from_address":"addr2","tx_hash":"hash","status":"confirmed","network":"mainnet"}""" + fun `createFromRaw parses wrapped event JSON with content field`() { + 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!!.status).isEqualTo(PaymentCardStatus.CONFIRMED) - assertThat(result.isSentByMe).isFalse() } @Test - fun `createFromBody parses failed status`() { - val body = """[cardano-payment:v1]{"amount_lovelace":1000000,"to_address":"a","from_address":"b","tx_hash":null,"status":"failed","network":"testnet"}""" + fun `createFromRaw parses failed status`() { + 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!!.status).isEqualTo(PaymentCardStatus.FAILED) @@ -71,34 +106,13 @@ class TimelineItemContentPaymentFactoryTest { } @Test - fun `createFromBody returns null for malformed JSON`() { - val body = "[cardano-payment:v1]{not valid json}\n💰 Sent 10 ADA" + fun `createFromRaw defaults to pending for unknown status`() { + val json = """{"amountLovelace":1000000,"toAddress":"a","fromAddress":"b","status":"unknown_status","network":"mainnet"}""" - val result = factory.createFromBody(body, 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) + val result = factory.createFromRaw(json, isSentByMe = true) assertThat(result).isNotNull() - assertThat(result!!.amountLovelace).isEqualTo(25_000_000) - assertThat(result.amountAda).isEqualTo("25 ADA") - assertThat(result.status).isEqualTo(PaymentCardStatus.CONFIRMED) - assertThat(result.isTestnet).isFalse() + assertThat(result!!.status).isEqualTo(PaymentCardStatus.PENDING) } @Test @@ -111,13 +125,30 @@ class TimelineItemContentPaymentFactoryTest { } @Test - fun `isPaymentStatusUpdate returns true for valid status marker`() { - val body = "[cardano-payment-status:v1]{\"tx_hash\":\"abc\"}\n✅ Payment confirmed" - assertThat(factory.isPaymentStatusUpdate(body)).isTrue() + fun `createFromRaw returns null for missing required fields`() { + val json = """{"amountLovelace":1000000}""" + + val result = factory.createFromRaw(json, isSentByMe = true) + + assertThat(result).isNull() } @Test - fun `isPaymentStatusUpdate returns false for regular message`() { - assertThat(factory.isPaymentStatusUpdate("Hello")).isFalse() + fun `createFromRaw returns null for empty JSON`() { + 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") } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index 500d9f3191..8e04e452b9 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -71,6 +71,18 @@ interface Timeline : AutoCloseable { intentionalMentions: List, ): Result + /** + * 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 + suspend fun editMessage( eventOrTransactionId: EventOrTransactionId, body: String, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index 95d4327c07..993a8b759f 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -118,3 +118,14 @@ data object LegacyCallInviteContent : EventContent data object CallNotifyContent : 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 diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index 3996155871..7a5cb75f9d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -279,6 +279,15 @@ class RustTimeline( } } + override suspend fun sendRaw( + eventType: String, + content: String, + ): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.sendRaw(eventType, content) + } + } + override suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result = withContext(dispatcher) { runCatchingExceptions { inner.redactEvent( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt index 2145bd2a7d..5fd085e671 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -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.UnableToDecryptContent 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.impl.media.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 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 -> {