element-x-ada/ELEMENT-X-ANDROID-REVIEW.md

21 KiB

Element X Android — Cardano Wallet Integration Technical Review

Date: 2026-03-26
Author: Kayos (Technical Review Subagent)
Target: Integrating a built-in Cardano wallet with /pay slash command


Executive Summary

Element X Android is well-architected for extension. The codebase uses a clean feature module pattern with clear separation between API, implementation, and test modules. Key findings:

  1. Slash commands don't exist yet/ is recognized but returns empty suggestions. This is a clean integration point.
  2. Android Keystore infrastructure existsSecretKeyRepository is already used for crypto operations; we can extend it.
  3. Timeline rendering is pluggable — Adding a payment card bubble is straightforward via the factory pattern.
  4. Account data APIs are NOT exposed — The Rust SDK has these but they're not surfaced to Kotlin yet. This is the main blocker.
  5. Widget infrastructure is solid — Element Call uses WebView + postMessage; could inform a payment confirmation UI.

Honest Effort Estimate: 3-4 weeks for a competent Android developer familiar with Matrix.


1. Slash Command System

Current State

The slash command infrastructure exists but is intentionally empty. Element X recognizes / as a command trigger but returns no suggestions.

Key Files:

libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Suggestion.kt
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt

Suggestion.kt defines the types:

sealed interface SuggestionType {
    data object Mention : SuggestionType     // @
    data object Command : SuggestionType     // /
    data object Room : SuggestionType        // #
    data object Emoji : SuggestionType       // :
    data class Custom(val pattern: String) : SuggestionType
}

SuggestionsProcessor.kt handles suggestions but explicitly returns empty for commands:

SuggestionType.Command,
SuggestionType.Emoji,
is SuggestionType.Custom -> {
    // Clear suggestions
    emptyList()
}

How to Add /pay

  1. Create a new slash command data model:

    // features/wallet/api/src/main/kotlin/io/.../SlashCommand.kt
    sealed interface SlashCommand {
        data class Pay(val recipient: UserId?, val amount: String?) : SlashCommand
        // Future: /balance, /receive, etc.
    }
    
  2. Extend SuggestionsProcessor to handle commands:

    SuggestionType.Command -> {
        val commands = listOf(
            ResolvedSuggestion.Command("/pay", "Send ADA to someone"),
            // ... other commands
        )
        commands.filter { it.command.contains(suggestion.text, ignoreCase = true) }
    }
    
  3. Add ResolvedSuggestion.Command type:

    // In libraries/textcomposer/impl/.../mentions/ResolvedSuggestion.kt
    data class Command(val command: String, val description: String) : ResolvedSuggestion
    
  4. Intercept message sending in MessageComposerPresenter:

    // In sendMessage(), before sending to timeline:
    val slashCommand = parseSlashCommand(message.markdown)
    if (slashCommand != null) {
        when (slashCommand) {
            is SlashCommand.Pay -> {
                // Navigate to payment flow instead of sending message
                navigator.navigateToPayment(slashCommand.recipient, slashCommand.amount)
                return@launch
            }
        }
    }
    

Files to Modify

File Change
SuggestionsProcessor.kt Add command filtering logic
ResolvedSuggestion.kt Add Command type
MessageComposerPresenter.kt Intercept /pay before send
New: SlashCommandParser.kt Parse /pay @user 100 syntax

2. Key Storage (Android Keystore)

Current Infrastructure

Element X already uses Android Keystore for AES encryption. The infrastructure is solid and reusable.

Key Files:

libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/SecretKeyRepository.kt
libraries/cryptography/impl/src/main/kotlin/io/element/android/libraries/cryptography/impl/KeyStoreSecretKeyRepository.kt
libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/crypto/EncryptedFile.kt

SecretKeyRepository interface:

interface SecretKeyRepository {
    fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey
    fun deleteKey(alias: String)
}

KeyStoreSecretKeyRepository implementation:

@ContributesBinding(AppScope::class)
class KeyStoreSecretKeyRepository(
    private val keyStore: KeyStore,
) : SecretKeyRepository {
    override fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey {
        // Uses Android Keystore with AES-GCM-128
        val generator = KeyGenerator.getInstance(AESEncryptionSpecs.ALGORITHM, ANDROID_KEYSTORE)
        val keyGenSpec = KeyGenParameterSpec.Builder(alias, ...)
            .setUserAuthenticationRequired(requiresUserAuthentication)
            .build()
        // ...
    }
}

What We Need to Add

  1. Cardano-specific key storage module:

    // libraries/wallet/impl/src/.../CardanoKeyStorage.kt
    @Inject
    class CardanoKeyStorage(
        private val secretKeyRepository: SecretKeyRepository,
        private val encryptionService: EncryptionDecryptionService,
        private val sessionStore: SessionStore,
    ) {
        private val KEY_ALIAS_PREFIX = "cardano_wallet_"
    
        fun storeSpendingKey(sessionId: SessionId, encryptedKey: ByteArray): Result<Unit>
        fun getSpendingKey(sessionId: SessionId): Result<ByteArray>
        fun hasWallet(sessionId: SessionId): Boolean
        fun deleteWallet(sessionId: SessionId): Result<Unit>
    }
    
  2. Biometric/PIN protection option:

    secretKeyRepository.getOrCreateKey(
        alias = "${KEY_ALIAS_PREFIX}${sessionId.value}",
        requiresUserAuthentication = true  // Requires biometric/PIN
    )
    
  3. Leverage EncryptedFile pattern for key backup:

    val encryptedFile = EncryptedFile(context, walletKeyFile)
    encryptedFile.openFileOutput().use { it.write(encryptedSpendingKey) }
    

Security Considerations

  • Keys should be per-session (each Matrix account has its own wallet)
  • Consider setUserAuthenticationRequired(true) for spending keys
  • Use existing RandomSecretPassphraseProvider pattern for derived keys
  • DO NOT store raw spending keys — always encrypt with Keystore-backed key

3. Widget Infrastructure (Element Call Pattern)

How Element Call Works

Element Call is embedded as a WebView-based widget with bidirectional message passing.

Key Files:

features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt
features/call/impl/src/main/kotlin/io/element/android/features/call/impl/data/WidgetMessage.kt
features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt

Architecture:

┌─────────────────────────────────────────┐
│           CallScreenView                │
│  ┌───────────────────────────────────┐  │
│  │           WebView                 │  │
│  │  (Element Call HTML/JS)           │  │
│  └───────────────────────────────────┘  │
│              ▲          │               │
│              │ postMessage              │
│              ▼          ▼               │
│  ┌───────────────────────────────────┐  │
│  │   WebViewWidgetMessageInterceptor │  │
│  └───────────────────────────────────┘  │
│              │                          │
│              ▼                          │
│  ┌───────────────────────────────────┐  │
│  │       MatrixWidgetDriver          │  │
│  │  (SDK ↔ Widget communication)     │  │
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

WidgetMessage structure:

@Serializable
data class WidgetMessage(
    @SerialName("api") val direction: Direction,
    @SerialName("widgetId") val widgetId: String,
    @SerialName("requestId") val requestId: String,
    @SerialName("action") val action: Action,
    @SerialName("data") val data: JsonElement? = null,
)

Relevance for Wallet

We could use this pattern for:

  1. Payment confirmation UI — A WebView showing tx details before signing
  2. dApp browser — Future widget-based dApp integration
  3. Cross-device signing — If we need to coordinate with desktop wallets

For MVP /pay, we probably don't need widgets — a native Compose UI is simpler.


4. Message Rendering (Payment Card Bubble)

Current Architecture

Timeline items are rendered through a factory + sealed class pattern.

Key Files:

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt

TimelineItemEventContent is a sealed interface with multiple implementations:

sealed interface TimelineItemEventContent {
    val type: String
}

// Existing types:
data class TimelineItemTextBasedContent(...) : TimelineItemEventContent
data class TimelineItemImageContent(...) : TimelineItemEventContent
data class TimelineItemPollContent(...) : TimelineItemEventContent
data class TimelineItemLocationContent(...) : TimelineItemEventContent
// ... etc

TimelineItemContentFactory maps SDK EventContent → UI content:

suspend fun create(itemContent: EventContent, ...): TimelineItemEventContent {
    return when (itemContent) {
        is MessageContent -> messageFactory.create(...)
        is PollContent -> pollFactory.create(...)
        is UnknownContent -> TimelineItemUnknownContent
        // ... etc
    }
}

TimelineItemEventContentView dispatches rendering:

@Composable
fun TimelineItemEventContentView(content: TimelineItemEventContent, ...) {
    when (content) {
        is TimelineItemTextBasedContent -> TimelineItemTextView(...)
        is TimelineItemPollContent -> TimelineItemPollView(...)
        // ... etc
    }
}

Adding Payment Card

  1. Create content model:

    // features/wallet/impl/src/.../timeline/TimelineItemPaymentContent.kt
    data class TimelineItemPaymentContent(
        val transactionId: String?,
        val senderAddress: String,
        val recipientAddress: String,
        val amount: String,          // e.g., "100 ADA"
        val status: PaymentStatus,   // Pending, Confirmed, Failed
        val timestamp: Long,
        val txHash: String?,         // Once confirmed
    ) : TimelineItemEventContent {
        override val type: String = "m.payment"  // Custom event type
    }
    
    enum class PaymentStatus { PENDING, CONFIRMED, FAILED }
    
  2. Create factory:

    // features/wallet/impl/src/.../timeline/TimelineItemContentPaymentFactory.kt
    @Inject
    class TimelineItemContentPaymentFactory {
        fun create(content: PaymentEventContent): TimelineItemPaymentContent {
            return TimelineItemPaymentContent(
                transactionId = content.transactionId,
                // ... map fields
            )
        }
    }
    
  3. Create view:

    // features/wallet/impl/src/.../timeline/TimelineItemPaymentView.kt
    @Composable
    fun TimelineItemPaymentView(
        content: TimelineItemPaymentContent,
        modifier: Modifier = Modifier,
    ) {
        Card(modifier = modifier) {
            Column {
                // Icon + "Payment" header
                Row {
                    Icon(Icons.Default.Payment)
                    Text("Payment")
                }
                // Amount
                Text(
                    text = content.amount,
                    style = MaterialTheme.typography.headlineMedium
                )
                // Status indicator
                StatusChip(content.status)
                // View on explorer link (if confirmed)
                if (content.txHash != null) {
                    TextButton(onClick = { openExplorer(content.txHash) }) {
                        Text("View on CardanoScan")
                    }
                }
            }
        }
    }
    
  4. Register in TimelineItemContentFactory:

    // Add to existing factory
    is PaymentContent -> paymentFactory.create(content)
    
  5. Register in TimelineItemEventContentView:

    // Add to existing when block
    is TimelineItemPaymentContent -> TimelineItemPaymentView(content = content, modifier = modifier)
    

Custom Event Type

We need a custom Matrix event type for payments. Options:

  1. m.payment.cardano — Custom event type (requires SDK extension)
  2. m.room.message with msgtype: "m.payment" — Easier but hacky
  3. Room state event — For persistent payment records

The SDK currently handles UnknownContent for unrecognized events, so we'd need to:

  • Either extend the Rust SDK to recognize m.payment.*
  • Or use the raw event JSON and parse it ourselves

5. Account Data APIs

Current State: BLOCKER

Account data (m.account_data) is the natural place to store:

  • Encrypted wallet seed (per-account)
  • Wallet addresses (for backup/sync)
  • Payment preferences

The Matrix Rust SDK has account data APIs, but Element X Android does NOT expose them.

The MatrixClient interface has no:

  • getAccountData(type: String)
  • setAccountData(type: String, content: JsonObject)
  • Room-level account data access

Workarounds

  1. Local-only storage (MVP approach):

    • Store wallet keys in Android Keystore
    • Store addresses in DataStore/preferences
    • Con: No cross-device sync
  2. Use a custom room as wallet state:

    • Create a self-DM or special room
    • Store state as room events
    • Con: Awkward, discoverable
  3. Extend the SDK (proper solution):

    • Add account data bindings to RustMatrixClient
    • Expose setAccountData / getAccountData
    • Time: 2-3 days for SDK extension

Recommendation

For MVP: Local-only storage. The user's wallet exists only on that device.

For V2: Extend SDK to use account data for encrypted seed backup.


features/
└── wallet/
    ├── api/
    │   └── src/main/kotlin/io/element/android/features/wallet/api/
    │       ├── WalletEntryPoint.kt           # Feature entry point
    │       ├── WalletState.kt                # Wallet balance/state
    │       ├── PaymentRequest.kt             # /pay parsing
    │       └── slash/
    │           └── SlashCommand.kt           # Slash command models
    │
    ├── impl/
    │   └── src/main/kotlin/io/element/android/features/wallet/impl/
    │       ├── di/
    │       │   └── WalletModule.kt           # DI bindings
    │       ├── storage/
    │       │   ├── CardanoKeyStorage.kt      # Keystore wrapper
    │       │   └── WalletPreferences.kt      # DataStore prefs
    │       ├── cardano/
    │       │   ├── CardanoWalletManager.kt   # Wallet operations
    │       │   ├── TransactionBuilder.kt     # Tx construction
    │       │   └── BlockfrostClient.kt       # Chain queries
    │       ├── payment/
    │       │   ├── PaymentFlowNode.kt        # Navigation node
    │       │   ├── PaymentPresenter.kt       # MVP logic
    │       │   ├── PaymentState.kt           # UI state
    │       │   └── PaymentView.kt            # Compose UI
    │       ├── timeline/
    │       │   ├── TimelineItemPaymentContent.kt
    │       │   ├── TimelineItemContentPaymentFactory.kt
    │       │   └── TimelineItemPaymentView.kt
    │       └── slash/
    │           ├── SlashCommandParser.kt
    │           └── SlashCommandHandler.kt
    │
    └── test/
        └── src/main/kotlin/io/element/android/features/wallet/test/
            ├── FakeCardanoWalletManager.kt
            └── FakeBlockfrostClient.kt

7. Blockers & Gotchas

Hard Blockers

  1. Account Data API not exposed

    • Impact: No cross-device wallet sync
    • Mitigation: Local storage for MVP; SDK extension for V2
    • Effort: 2-3 days to extend SDK
  2. Custom event types not recognized

    • Impact: Payment events show as "Unknown event"
    • Mitigation: Extend SDK's event parsing or use raw JSON
    • Effort: 1-2 days

Soft Blockers

  1. No existing Cardano library for Android/Kotlin

    • We need: Key derivation (BIP39/CIP1852), transaction building
    • Options: cardano-serialization-lib (Rust via JNI), or port to Kotlin
    • Effort: 3-5 days for JNI wrapper OR 1-2 weeks for native Kotlin
  2. Blockfrost dependency

    • Need API key management
    • Rate limiting concerns
    • Effort: 1 day

Things That Are Easier Than Expected

  • Keystore integration — Existing infrastructure is excellent
  • Timeline rendering — Factory pattern makes it trivial
  • Feature module structure — Well-documented pattern
  • Slash command hook — Clean insertion point exists

8. Effort Estimate

MVP: /pay Command End-to-End

Task Days Notes
Slash command parser + suggestions 2 Extend existing infrastructure
Payment flow UI (recipient, amount, confirm) 3 Compose screens
Cardano key storage (Keystore) 2 Extend SecretKeyRepository
Cardano transaction builder 5 Depends on Rust lib or Kotlin port
Blockfrost integration 1 Submit tx, query balance
Payment card timeline item 2 New content type + view
Testing & polish 3 Unit tests, UI tests, edge cases
Total MVP 18 days ~3.5 weeks

V2: Cross-Device Sync

Task Days Notes
Extend SDK for account data 3 Rust bindings + Kotlin wrapper
Encrypted seed backup 2 Store in m.account_data
Receive flow + QR 2 Address display, sharing
Total V2 7 days ~1.5 weeks

Full Feature Set

Task Days Notes
MVP 18 As above
V2 7 Cross-device
Balance widget 2 Room-level balance display
Transaction history 3 Query + display
Multi-asset support 3 Native tokens
Total Full 33 days ~6.5 weeks

9. Recommendations

  1. Start with local-only MVP — Don't block on SDK extensions
  2. Use cardano-serialization-lib via JNI — Don't reinvent wheel
  3. Store wallet per-session — Each Matrix account has its own wallet
  4. Use Blockfrost for MVP — Move to own node later if needed
  5. Custom m.payment.cardano event — Proper event type, extend SDK
  6. Fork Element X for initial dev — Easier than patches

10. Files Quick Reference

Purpose Location
Slash command suggestions features/messages/impl/.../suggestions/SuggestionsProcessor.kt
Message composer features/messages/impl/.../messagecomposer/MessageComposerPresenter.kt
Keystore libraries/cryptography/impl/.../KeyStoreSecretKeyRepository.kt
Timeline factory features/messages/impl/.../factories/event/TimelineItemContentFactory.kt
Timeline content view features/messages/impl/.../components/event/TimelineItemEventContentView.kt
Content models features/messages/impl/.../model/event/TimelineItem*.kt
Widget system features/call/impl/.../utils/DefaultCallWidgetProvider.kt
Encrypted DB libraries/encrypted-db/src/.../crypto/EncryptedFile.kt
Session storage libraries/session-storage/impl/.../DatabaseSessionStore.kt
Matrix client libraries/matrix/impl/.../RustMatrixClient.kt

End of Technical Review