diff --git a/ELEMENT-X-ANDROID-REVIEW.md b/ELEMENT-X-ANDROID-REVIEW.md new file mode 100644 index 0000000000..024c44848a --- /dev/null +++ b/ELEMENT-X-ANDROID-REVIEW.md @@ -0,0 +1,581 @@ +# 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 exists** — `SecretKeyRepository` 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: +```kotlin +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: +```kotlin +SuggestionType.Command, +SuggestionType.Emoji, +is SuggestionType.Custom -> { + // Clear suggestions + emptyList() +} +``` + +### How to Add `/pay` + +1. **Create a new slash command data model:** + ```kotlin + // 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:** + ```kotlin + 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:** + ```kotlin + // In libraries/textcomposer/impl/.../mentions/ResolvedSuggestion.kt + data class Command(val command: String, val description: String) : ResolvedSuggestion + ``` + +4. **Intercept message sending in MessageComposerPresenter:** + ```kotlin + // 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:** +```kotlin +interface SecretKeyRepository { + fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey + fun deleteKey(alias: String) +} +``` + +**KeyStoreSecretKeyRepository implementation:** +```kotlin +@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:** + ```kotlin + // 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 + fun getSpendingKey(sessionId: SessionId): Result + fun hasWallet(sessionId: SessionId): Boolean + fun deleteWallet(sessionId: SessionId): Result + } + ``` + +2. **Biometric/PIN protection option:** + ```kotlin + secretKeyRepository.getOrCreateKey( + alias = "${KEY_ALIAS_PREFIX}${sessionId.value}", + requiresUserAuthentication = true // Requires biometric/PIN + ) + ``` + +3. **Leverage EncryptedFile pattern for key backup:** + ```kotlin + 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:** +```kotlin +@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: +```kotlin +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: +```kotlin +suspend fun create(itemContent: EventContent, ...): TimelineItemEventContent { + return when (itemContent) { + is MessageContent -> messageFactory.create(...) + is PollContent -> pollFactory.create(...) + is UnknownContent -> TimelineItemUnknownContent + // ... etc + } +} +``` + +**TimelineItemEventContentView** dispatches rendering: +```kotlin +@Composable +fun TimelineItemEventContentView(content: TimelineItemEventContent, ...) { + when (content) { + is TimelineItemTextBasedContent -> TimelineItemTextView(...) + is TimelineItemPollContent -> TimelineItemPollView(...) + // ... etc + } +} +``` + +### Adding Payment Card + +1. **Create content model:** + ```kotlin + // 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:** + ```kotlin + // 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:** + ```kotlin + // 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:** + ```kotlin + // Add to existing factory + is PaymentContent -> paymentFactory.create(content) + ``` + +5. **Register in TimelineItemEventContentView:** + ```kotlin + // 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. + +--- + +## 6. Recommended File Structure + +``` +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 + +3. **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 + +4. **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*