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
- ✅ **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
---

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.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)

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.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()

View file

@ -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<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(
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 """<span data-mx-payment="$PAYMENT_MARKER" data-payment-json='$jsonPayload'>$fallbackText</span>"""
}
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"
}
}

View file

@ -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<PaymentEventData>(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<PaymentEventData>(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
}
}

View file

@ -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")
}
}

View file

@ -71,6 +71,18 @@ interface Timeline : AutoCloseable {
intentionalMentions: List<IntentionalMention>,
): 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(
eventOrTransactionId: EventOrTransactionId,
body: String,

View file

@ -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

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) {
runCatchingExceptions {
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.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 -> {