feat(wallet): payment card timeline item and raw event handling (Tasks 7+8)

Task 7: Timeline Payment Card
- TimelineItemPaymentView integration with TimelineItemEventContentView
- Payment card rendering for both sender and recipient perspectives
- Unit tests for TimelineItemPaymentContent

Task 8: Raw Event Handling
- Modified TimelineItemContentMessageFactory to intercept payment events
- Added isSentByMe parameter propagation through content factories
- FakePaymentEventSender for testing
- Unit tests for TimelineItemContentPaymentFactory

SDK Limitation Workaround:
Since matrix-rust-sdk doesn't expose raw event sending or UnknownContent
raw JSON, payment events are encoded as text messages with a marker:
[cardano-payment:v1]{...json...}

This falls back gracefully for non-wallet clients while enabling
rich payment card rendering for wallet-enabled clients.
This commit is contained in:
Kayos 2026-03-27 11:08:03 -07:00
parent 39561e1aeb
commit adee67cf0d
16 changed files with 1410 additions and 12 deletions

View file

@ -0,0 +1,103 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.wallet.api.CardanoClient
import io.element.android.features.wallet.impl.cardano.CardanoNetwork
import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig
import io.element.android.features.wallet.impl.cardano.CardanoWalletManager
import io.element.android.features.wallet.impl.slash.Lovelace
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
/**
* Presenter for the payment confirmation screen.
*/
class PaymentConfirmationPresenter @AssistedInject constructor(
@Assisted private val recipientAddress: String,
@Assisted private val amountLovelace: Lovelace,
private val matrixClient: MatrixClient,
private val walletManager: CardanoWalletManager,
private val cardanoClient: CardanoClient,
) : Presenter<PaymentConfirmationState> {
@AssistedFactory
interface Factory {
fun create(recipientAddress: String, amountLovelace: Lovelace): PaymentConfirmationPresenter
}
companion object {
private const val ESTIMATED_TX_SIZE_BYTES = 350
}
@Composable
override fun present(): PaymentConfirmationState {
val sessionId = matrixClient.sessionId
var senderAddress by remember { mutableStateOf("") }
var senderBalanceLovelace by remember { mutableStateOf<Lovelace?>(null) }
var estimatedFeeLovelace by remember { mutableStateOf<Lovelace?>(null) }
var isFeeLoading by remember { mutableStateOf(true) }
var feeError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
walletManager.getAddress(sessionId).onSuccess { address ->
senderAddress = address
}
val address = walletManager.getAddress(sessionId).getOrNull()
if (address != null) {
cardanoClient.getBalance(address).onSuccess { balance ->
senderBalanceLovelace = balance
}
}
cardanoClient.getProtocolParameters().onSuccess { params ->
val fee = params.minFeeA * ESTIMATED_TX_SIZE_BYTES + params.minFeeB
estimatedFeeLovelace = fee
isFeeLoading = false
}.onFailure {
estimatedFeeLovelace = 200_000L
feeError = "Could not estimate exact fee"
isFeeLoading = false
}
}
val totalLovelace = estimatedFeeLovelace?.let { amountLovelace + it }
val insufficientFunds = senderBalanceLovelace != null &&
totalLovelace != null &&
senderBalanceLovelace!! < totalLovelace
return PaymentConfirmationState(
recipientAddress = recipientAddress,
recipientAddressDisplay = PaymentConfirmationState.truncateAddress(recipientAddress),
amountLovelace = amountLovelace,
amountAda = PaymentConfirmationState.formatAda(amountLovelace),
estimatedFeeLovelace = estimatedFeeLovelace,
estimatedFeeAda = estimatedFeeLovelace?.let { PaymentConfirmationState.formatAda(it) },
totalLovelace = totalLovelace,
totalAda = totalLovelace?.let { PaymentConfirmationState.formatAda(it) },
senderAddress = senderAddress,
senderBalanceLovelace = senderBalanceLovelace,
insufficientFunds = insufficientFunds,
isTestnet = CardanoNetworkConfig.NETWORK == CardanoNetwork.TESTNET,
isFeeLoading = isFeeLoading,
feeError = feeError,
eventSink = {},
)
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
import io.element.android.features.wallet.impl.slash.Lovelace
/**
* State for the payment confirmation screen.
*/
data class PaymentConfirmationState(
val recipientAddress: String,
val recipientAddressDisplay: String,
val amountLovelace: Lovelace,
val amountAda: String,
val estimatedFeeLovelace: Lovelace?,
val estimatedFeeAda: String?,
val totalLovelace: Lovelace?,
val totalAda: String?,
val senderAddress: String,
val senderBalanceLovelace: Lovelace?,
val insufficientFunds: Boolean,
val isTestnet: Boolean,
val isFeeLoading: Boolean,
val feeError: String?,
val eventSink: (PaymentFlowEvents) -> Unit,
) {
companion object {
fun truncateAddress(address: String): String {
if (address.length <= 20) return address
return "${address.take(8)}...${address.takeLast(6)}"
}
fun formatAda(lovelace: Lovelace): String {
val ada = lovelace / 1_000_000.0
return String.format("%.6f", ada).trimEnd('0').trimEnd('.')
}
}
}

View file

@ -0,0 +1,178 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
import android.view.WindowManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
/**
* Payment confirmation screen.
*
* FLAG_SECURE is applied to prevent screenshots of transaction details.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaymentConfirmationView(
state: PaymentConfirmationState,
onConfirm: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
// FLAG_SECURE to prevent screenshots of payment details
val view = LocalView.current
DisposableEffect(Unit) {
val window = (view.context as? android.app.Activity)?.window
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }
}
Scaffold(
modifier = modifier.fillMaxSize().systemBarsPadding().imePadding(),
topBar = {
TopAppBar(
title = { Text("Confirm Payment") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (state.isTestnet) { TestnetWarningCard() }
Spacer(modifier = Modifier.height(8.dp))
AmountCard(amountAda = state.amountAda)
TransactionDetailsCard(state)
if (state.insufficientFunds) {
InsufficientFundsCard(balanceLovelace = state.senderBalanceLovelace, requiredLovelace = state.totalLovelace)
}
Spacer(modifier = Modifier.weight(1f))
Button(
text = "Send",
onClick = { state.eventSink(PaymentFlowEvents.ConfirmPayment); onConfirm() },
enabled = !state.isFeeLoading && !state.insufficientFunds,
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
leadingIcon = { Icon(Icons.Default.Send, contentDescription = null) },
)
}
}
}
@Composable
private fun TestnetWarningCard(modifier: Modifier = Modifier) {
Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("⚠️", style = MaterialTheme.typography.titleMedium)
Text("Testnet transaction — no real ADA", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onTertiaryContainer)
}
}
}
@Composable
private fun AmountCard(amountAda: String, modifier: Modifier = Modifier) {
Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) {
Column(modifier = Modifier.fillMaxWidth().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Amount", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f))
Text("$amountAda ADA", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer)
}
}
}
@Composable
private fun TransactionDetailsCard(state: PaymentConfirmationState, modifier: Modifier = Modifier) {
Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
DetailRow(label = "To", value = state.recipientAddressDisplay)
HorizontalDivider()
DetailRow(label = "Network fee", value = if (state.isFeeLoading) null else state.estimatedFeeAda?.let { "~$it ADA" } ?: "Unknown", isLoading = state.isFeeLoading)
state.feeError?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) }
HorizontalDivider()
DetailRow(label = "Total", value = state.totalAda?.let { "$it ADA" } ?: "", isBold = true)
}
}
}
@Composable
private fun DetailRow(label: String, value: String?, isBold: Boolean = false, isLoading: Boolean = false, modifier: Modifier = Modifier) {
Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(label, style = if (isBold) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (isLoading) CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
else Text(value ?: "", style = if (isBold) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium, fontWeight = if (isBold) FontWeight.Bold else FontWeight.Normal, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun InsufficientFundsCard(balanceLovelace: Long?, requiredLovelace: Long?, modifier: Modifier = Modifier) {
Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Insufficient funds", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onErrorContainer)
val balanceAda = balanceLovelace?.let { PaymentConfirmationState.formatAda(it) } ?: "?"
val requiredAda = requiredLovelace?.let { PaymentConfirmationState.formatAda(it) } ?: "?"
Text("You have $balanceAda ADA but need $requiredAda ADA (including fee)", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f))
}
}
}
@PreviewsDayNight
@Composable
internal fun PaymentConfirmationViewPreview(@PreviewParameter(PaymentConfirmationStateProvider::class) state: PaymentConfirmationState) {
ElementPreview { PaymentConfirmationView(state = state, onConfirm = {}, onBack = {}) }
}
internal class PaymentConfirmationStateProvider : PreviewParameterProvider<PaymentConfirmationState> {
override val values = sequenceOf(
PaymentConfirmationState(
recipientAddress = "addr_test1qp2fg770ddmqxxduasjsas8rgimrhknmqjn43mj74g7ta2tjt0n5nh4t5xqf6lp5mwfpksj9csjg9s4kgfhvwj7m7dcq9qf7zj",
recipientAddressDisplay = "addr_tes...q9qf7zj", amountLovelace = 10_000_000L, amountAda = "10",
estimatedFeeLovelace = 180_000L, estimatedFeeAda = "0.18", totalLovelace = 10_180_000L, totalAda = "10.18",
senderAddress = "addr_test1q...", senderBalanceLovelace = 100_000_000L, insufficientFunds = false,
isTestnet = true, isFeeLoading = false, feeError = null, eventSink = {},
),
)
}

View file

@ -0,0 +1,177 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.wallet.api.CardanoClient
import io.element.android.features.wallet.impl.cardano.CardanoNetwork
import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig
import io.element.android.features.wallet.impl.cardano.CardanoWalletManager
import io.element.android.features.wallet.impl.slash.Lovelace
import io.element.android.features.wallet.impl.slash.ParsedPayCommand
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import java.math.BigDecimal
/**
* Presenter for the payment entry screen.
*/
class PaymentEntryPresenter @AssistedInject constructor(
@Assisted private val roomId: RoomId,
@Assisted private val parsedCommand: ParsedPayCommand?,
private val matrixClient: MatrixClient,
private val walletManager: CardanoWalletManager,
private val cardanoClient: CardanoClient,
) : Presenter<PaymentEntryState> {
@AssistedFactory
interface Factory {
fun create(roomId: RoomId, parsedCommand: ParsedPayCommand?): PaymentEntryPresenter
}
companion object {
private const val LOVELACE_PER_ADA = 1_000_000L
private const val MIN_AMOUNT_LOVELACE = 1_000_000L
private const val MAX_ADA_SUPPLY = 45_000_000_000L
private val CARDANO_ADDRESS_REGEX = "^addr(_test)?1[a-zA-Z0-9]+$".toRegex()
private val MATRIX_USER_REGEX = "^@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+$".toRegex()
}
@Composable
override fun present(): PaymentEntryState {
val (prefillAmount, prefillRecipient) = remember(parsedCommand) {
extractPrefills(parsedCommand)
}
var amountInput by remember { mutableStateOf(prefillAmount?.let { formatLovelaceInput(it) } ?: "") }
var recipientInput by remember { mutableStateOf(prefillRecipient ?: "") }
var senderAddress by remember { mutableStateOf<String?>(null) }
var senderBalanceLovelace by remember { mutableStateOf<Lovelace?>(null) }
var recipientResolutionState by remember { mutableStateOf<RecipientResolutionState>(RecipientResolutionState.NotNeeded) }
LaunchedEffect(Unit) {
val sessionId = matrixClient.sessionId
walletManager.initialize(sessionId)
senderAddress = walletManager.getAddress(sessionId).getOrNull()
senderAddress?.let { address ->
cardanoClient.getBalance(address).onSuccess { balance ->
senderBalanceLovelace = balance
}
}
}
val parsedAmountLovelace = parseAmountInput(amountInput)
val amountError = validateAmount(parsedAmountLovelace, amountInput)
val isCardanoAddress = CARDANO_ADDRESS_REGEX.matches(recipientInput)
val isMatrixUser = MATRIX_USER_REGEX.matches(recipientInput)
val recipientError = validateRecipient(recipientInput, isCardanoAddress, isMatrixUser)
LaunchedEffect(recipientInput, isMatrixUser, isCardanoAddress) {
recipientResolutionState = when {
recipientInput.isBlank() -> RecipientResolutionState.NotNeeded
isCardanoAddress -> RecipientResolutionState.NotNeeded
isMatrixUser -> RecipientResolutionState.NeedsManualEntry(
matrixUserId = recipientInput,
displayName = null
)
else -> RecipientResolutionState.NotNeeded
}
}
val isValidRecipient = isCardanoAddress
val canContinue = parsedAmountLovelace != null &&
parsedAmountLovelace >= MIN_AMOUNT_LOVELACE &&
amountError == null &&
isValidRecipient &&
recipientError == null
fun handleEvent(event: PaymentFlowEvents) {
when (event) {
is PaymentFlowEvents.AmountChanged -> amountInput = event.amount
is PaymentFlowEvents.RecipientChanged -> recipientInput = event.recipient
else -> Unit
}
}
val senderBalanceAda = senderBalanceLovelace?.let { balance ->
String.format("%.6f", balance / 1_000_000.0).trimEnd('0').trimEnd('.')
}
return PaymentEntryState(
amountInput = amountInput,
recipientInput = recipientInput,
prefillAmount = prefillAmount,
prefillRecipient = prefillRecipient,
parsedAmountLovelace = parsedAmountLovelace,
isValidRecipient = isValidRecipient,
recipientResolutionState = recipientResolutionState,
senderAddress = senderAddress,
senderBalanceAda = senderBalanceAda,
isTestnet = CardanoNetworkConfig.NETWORK == CardanoNetwork.TESTNET,
amountError = amountError,
recipientError = recipientError,
canContinue = canContinue,
eventSink = ::handleEvent,
)
}
private fun extractPrefills(command: ParsedPayCommand?): Pair<Lovelace?, String?> {
return when (command) {
is ParsedPayCommand.WithAddressRecipient -> command.amount to command.address
is ParsedPayCommand.WithMatrixRecipient -> command.amount to command.matrixUserId.value
is ParsedPayCommand.AmountOnly -> command.amount to null
else -> null to null
}
}
private fun formatLovelaceInput(lovelace: Lovelace): String {
val ada = lovelace / 1_000_000.0
return String.format("%.6f", ada).trimEnd('0').trimEnd('.')
}
private fun parseAmountInput(input: String): Lovelace? {
if (input.isBlank()) return null
return try {
val decimal = BigDecimal(input.trim())
if (decimal <= BigDecimal.ZERO) return null
val lovelace = decimal.multiply(BigDecimal(LOVELACE_PER_ADA))
lovelace.toLong()
} catch (e: Exception) {
null
}
}
private fun validateAmount(lovelace: Lovelace?, input: String): String? {
if (input.isBlank()) return null
if (lovelace == null) return "Invalid amount"
if (lovelace < MIN_AMOUNT_LOVELACE) return "Minimum amount is 1 ADA"
if (lovelace > MAX_ADA_SUPPLY * LOVELACE_PER_ADA) return "Amount too large"
return null
}
private fun validateRecipient(input: String, isCardanoAddress: Boolean, isMatrixUser: Boolean): String? {
if (input.isBlank()) return null
if (!isCardanoAddress && !isMatrixUser) {
return "Enter a Cardano address (addr1...) or Matrix user (@user:server)"
}
if (isCardanoAddress && input.length < 50) {
return "Address too short"
}
return null
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
import io.element.android.features.wallet.impl.slash.Lovelace
/**
* State for the payment entry screen.
*/
data class PaymentEntryState(
val amountInput: String,
val recipientInput: String,
val prefillAmount: Lovelace?,
val prefillRecipient: String?,
val parsedAmountLovelace: Lovelace?,
val isValidRecipient: Boolean,
val recipientResolutionState: RecipientResolutionState,
val senderAddress: String?,
val senderBalanceAda: String?,
val isTestnet: Boolean,
val amountError: String?,
val recipientError: String?,
val canContinue: Boolean,
val eventSink: (PaymentFlowEvents) -> Unit,
) {
val parsedAmountAda: String?
get() = parsedAmountLovelace?.let { lovelace ->
val ada = lovelace / 1_000_000.0
String.format("%.6f", ada).trimEnd('0').trimEnd('.')
}
}
/**
* State of resolving a Matrix user ID to a Cardano address.
*/
sealed interface RecipientResolutionState {
data object NotNeeded : RecipientResolutionState
data class NeedsManualEntry(val matrixUserId: String, val displayName: String?) : RecipientResolutionState
data class Resolved(val address: String) : RecipientResolutionState
data class Error(val message: String) : RecipientResolutionState
}

View file

@ -0,0 +1,201 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaymentEntryView(
state: PaymentEntryState,
onContinue: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding(),
topBar = {
TopAppBar(
title = { Text("Send Payment") },
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(Icons.Default.Close, contentDescription = "Cancel")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (state.isTestnet) {
TestnetWarningCard()
}
state.senderBalanceAda?.let { balance ->
BalanceInfoCard(balanceAda = balance)
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = state.amountInput,
onValueChange = { state.eventSink(PaymentFlowEvents.AmountChanged(it)) },
label = { Text("Amount (ADA)") },
placeholder = { Text("0.00") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
isError = state.amountError != null,
supportingText = state.amountError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.recipientInput,
onValueChange = { state.eventSink(PaymentFlowEvents.RecipientChanged(it)) },
label = { Text("Recipient") },
placeholder = { Text("addr1... or @user:server") },
isError = state.recipientError != null,
supportingText = state.recipientError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
when (val resolution = state.recipientResolutionState) {
is RecipientResolutionState.NeedsManualEntry -> {
MatrixUserNeedsAddressCard(
matrixUserId = resolution.matrixUserId,
displayName = resolution.displayName,
)
}
is RecipientResolutionState.Error -> {
Text(resolution.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
else -> Unit
}
Spacer(modifier = Modifier.weight(1f))
Button(
text = "Continue",
onClick = {
state.eventSink(PaymentFlowEvents.Continue)
onContinue()
},
enabled = state.canContinue,
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
}
}
}
@Composable
private fun TestnetWarningCard(modifier: Modifier = Modifier) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("⚠️", style = MaterialTheme.typography.titleMedium)
Text("Testnet transaction — no real ADA", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onTertiaryContainer)
}
}
}
@Composable
private fun BalanceInfoCard(balanceAda: String, modifier: Modifier = Modifier) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Available balance", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("$balanceAda ADA", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun MatrixUserNeedsAddressCard(matrixUserId: String, displayName: String?, modifier: Modifier = Modifier) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
val name = displayName ?: matrixUserId.substringBefore(":").removePrefix("@")
Text("$name hasn't linked a wallet yet", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer)
Text("Enter their Cardano address manually above", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f))
}
}
}
@PreviewsDayNight
@Composable
internal fun PaymentEntryViewPreview(@PreviewParameter(PaymentEntryStateProvider::class) state: PaymentEntryState) {
ElementPreview { PaymentEntryView(state = state, onContinue = {}, onCancel = {}) }
}
internal class PaymentEntryStateProvider : PreviewParameterProvider<PaymentEntryState> {
override val values = sequenceOf(
PaymentEntryState(
amountInput = "", recipientInput = "", prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = null, isValidRecipient = false, recipientResolutionState = RecipientResolutionState.NotNeeded,
senderAddress = "addr_test1qp2fg...", senderBalanceAda = "100.5", isTestnet = true,
amountError = null, recipientError = null, canContinue = false, eventSink = {},
),
)
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
/**
* Events for the payment flow state machine.
*/
sealed interface PaymentFlowEvents {
// Entry screen events
data class AmountChanged(val amount: String) : PaymentFlowEvents
data class RecipientChanged(val recipient: String) : PaymentFlowEvents
data object Continue : PaymentFlowEvents
data object Cancel : PaymentFlowEvents
// Confirmation screen events
data object ConfirmPayment : PaymentFlowEvents
data object GoBack : PaymentFlowEvents
// Authentication events
data class AuthenticationResult(val success: Boolean, val errorMessage: String? = null) : PaymentFlowEvents
// Progress screen events
data object Done : PaymentFlowEvents
data object RetryPayment : PaymentFlowEvents
data object ViewOnExplorer : PaymentFlowEvents
}

View file

@ -0,0 +1,151 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.wallet.api.CardanoClient
import io.element.android.features.wallet.api.PaymentRequest
import io.element.android.features.wallet.api.PaymentStatusPoller
import io.element.android.features.wallet.api.TransactionBuilder
import io.element.android.features.wallet.api.TxStatus
import io.element.android.features.wallet.impl.cardano.CardanoNetwork
import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig
import io.element.android.features.wallet.impl.cardano.CardanoWalletManager
import io.element.android.features.wallet.impl.slash.Lovelace
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import timber.log.Timber
/**
* Presenter for the payment progress screen.
*/
class PaymentProgressPresenter @AssistedInject constructor(
@Assisted private val recipientAddress: String,
@Assisted private val amountLovelace: Lovelace,
private val matrixClient: MatrixClient,
private val walletManager: CardanoWalletManager,
private val transactionBuilder: TransactionBuilder,
private val cardanoClient: CardanoClient,
private val paymentStatusPoller: PaymentStatusPoller,
) : Presenter<PaymentProgressState> {
@AssistedFactory
interface Factory {
fun create(recipientAddress: String, amountLovelace: Lovelace): PaymentProgressPresenter
}
companion object {
private const val TAG = "PaymentProgressPresenter"
private const val TIMEOUT_THRESHOLD_MS = 10 * 60 * 1000L
}
@Composable
override fun present(): PaymentProgressState {
val sessionId = matrixClient.sessionId
var txHash by remember { mutableStateOf<String?>(null) }
var txStatus by remember { mutableStateOf(TxStatus.PENDING) }
var submissionState by remember { mutableStateOf<SubmissionState>(SubmissionState.Submitting) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var submissionStartTime by remember { mutableStateOf(0L) }
LaunchedEffect(Unit) {
submissionStartTime = System.currentTimeMillis()
submissionState = SubmissionState.Submitting
val senderAddress = walletManager.getAddress(sessionId).getOrNull()
if (senderAddress == null) {
submissionState = SubmissionState.Failed("Could not get wallet address")
errorMessage = "Failed to load wallet address"
return@LaunchedEffect
}
val request = PaymentRequest(
fromAddress = senderAddress,
toAddress = recipientAddress,
amountLovelace = amountLovelace,
sessionId = sessionId,
)
Timber.tag(TAG).d("Building and signing transaction...")
transactionBuilder.buildAndSign(request).onSuccess { signedTx ->
Timber.tag(TAG).d("Transaction built successfully, hash: ${signedTx.txHash}")
txHash = signedTx.txHash
cardanoClient.submitTx(signedTx.txCbor).onSuccess { submittedHash ->
Timber.tag(TAG).i("Transaction submitted: $submittedHash")
submissionState = SubmissionState.Pending
}.onFailure { error ->
Timber.tag(TAG).e(error, "Failed to submit transaction")
submissionState = SubmissionState.Failed("Failed to submit transaction")
errorMessage = error.message ?: "Transaction submission failed"
}
}.onFailure { error ->
Timber.tag(TAG).e(error, "Failed to build transaction")
submissionState = SubmissionState.Failed("Failed to build transaction")
errorMessage = error.message ?: "Transaction build failed"
}
}
val currentTxHash = txHash
LaunchedEffect(currentTxHash) {
if (currentTxHash == null) return@LaunchedEffect
if (submissionState !is SubmissionState.Pending) return@LaunchedEffect
Timber.tag(TAG).d("Starting to poll for confirmation: $currentTxHash")
paymentStatusPoller.pollUntilConfirmed(currentTxHash).collect { status ->
txStatus = status
when (status) {
TxStatus.CONFIRMED -> {
Timber.tag(TAG).i("Transaction confirmed: $currentTxHash")
submissionState = SubmissionState.Confirmed
}
TxStatus.FAILED -> {
Timber.tag(TAG).w("Transaction failed: $currentTxHash")
submissionState = SubmissionState.Failed("Transaction failed on chain")
errorMessage = "Transaction was rejected by the network"
}
TxStatus.PENDING -> {
val elapsed = System.currentTimeMillis() - submissionStartTime
if (elapsed > TIMEOUT_THRESHOLD_MS) {
Timber.tag(TAG).w("Transaction taking too long: $currentTxHash")
submissionState = SubmissionState.TakingTooLong
}
}
}
}
}
val explorerUrl = txHash?.let {
"${CardanoNetworkConfig.EXPLORER_BASE_URL}/transaction/$it"
}
return PaymentProgressState(
txHash = txHash,
txHashDisplay = txHash?.let { PaymentProgressState.truncateTxHash(it) },
explorerUrl = explorerUrl,
amountLovelace = amountLovelace,
amountAda = PaymentConfirmationState.formatAda(amountLovelace),
recipientAddress = recipientAddress,
txStatus = txStatus,
submissionState = submissionState,
errorMessage = errorMessage,
isTestnet = CardanoNetworkConfig.NETWORK == CardanoNetwork.TESTNET,
eventSink = {},
)
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
import io.element.android.features.wallet.api.TxStatus
import io.element.android.features.wallet.impl.slash.Lovelace
/**
* State for the payment progress screen.
*/
data class PaymentProgressState(
val txHash: String?,
val txHashDisplay: String?,
val explorerUrl: String?,
val amountLovelace: Lovelace,
val amountAda: String,
val recipientAddress: String,
val txStatus: TxStatus,
val submissionState: SubmissionState,
val errorMessage: String?,
val isTestnet: Boolean,
val eventSink: (PaymentFlowEvents) -> Unit,
) {
companion object {
fun truncateTxHash(txHash: String): String {
if (txHash.length <= 20) return txHash
return "${txHash.take(8)}...${txHash.takeLast(6)}"
}
}
}
/**
* State of the transaction submission and confirmation process.
*/
sealed interface SubmissionState {
data object Submitting : SubmissionState
data object Pending : SubmissionState
data object Confirmed : SubmissionState
data class Failed(val reason: String) : SubmissionState
data object TakingTooLong : SubmissionState
}

View file

@ -0,0 +1,123 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.timeline
import com.google.common.truth.Truth.assertThat
import io.element.android.features.wallet.api.PaymentCardStatus
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()
}
@Test
fun `isPaymentEvent returns false for regular message`() {
val body = "Hello, this is a regular message"
assertThat(factory.isPaymentEvent(body)).isFalse()
}
@Test
fun `isPaymentEvent returns false for empty string`() {
assertThat(factory.isPaymentEvent("")).isFalse()
}
@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"""
val result = factory.createFromBody(body, isSentByMe = true)
assertThat(result).isNotNull()
assertThat(result!!.amountLovelace).isEqualTo(10_000_000)
assertThat(result.toAddress).isEqualTo("addr_test1abc")
assertThat(result.fromAddress).isEqualTo("addr_test1xyz")
assertThat(result.txHash).isEqualTo("hash123")
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"}"""
val result = factory.createFromBody(body, 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"}"""
val result = factory.createFromBody(body, isSentByMe = true)
assertThat(result).isNotNull()
assertThat(result!!.status).isEqualTo(PaymentCardStatus.FAILED)
assertThat(result.txHash).isNull()
}
@Test
fun `createFromBody returns null for malformed JSON`() {
val body = "[cardano-payment:v1]{not valid json}\n💰 Sent 10 ADA"
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)
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()
}
@Test
fun `createFromRaw returns null for invalid JSON`() {
val json = "not valid json"
val result = factory.createFromRaw(json, isSentByMe = true)
assertThat(result).isNull()
}
@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()
}
@Test
fun `isPaymentStatusUpdate returns false for regular message`() {
assertThat(factory.isPaymentStatusUpdate("Hello")).isFalse()
}
}

View file

@ -0,0 +1,132 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
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.api.timeline.TimelineItemPaymentContent
import org.junit.Test
class TimelineItemPaymentContentTest {
@Test
fun `amountAda formats whole number correctly`() {
val content = createContent(amountLovelace = 10_000_000)
assertThat(content.amountAda).isEqualTo("10 ADA")
}
@Test
fun `amountAda formats decimal correctly`() {
val content = createContent(amountLovelace = 5_500_000)
assertThat(content.amountAda).isEqualTo("5.5 ADA")
}
@Test
fun `amountAda formats small amounts correctly`() {
val content = createContent(amountLovelace = 1_000)
assertThat(content.amountAda).isEqualTo("0.001 ADA")
}
@Test
fun `amountAda formats zero correctly`() {
val content = createContent(amountLovelace = 0)
assertThat(content.amountAda).isEqualTo("0 ADA")
}
@Test
fun `isTestnet returns true for testnet`() {
val content = createContent(network = "testnet")
assertThat(content.isTestnet).isTrue()
}
@Test
fun `isTestnet returns true for preprod`() {
val content = createContent(network = "preprod")
assertThat(content.isTestnet).isTrue()
}
@Test
fun `isTestnet returns true for preview`() {
val content = createContent(network = "preview")
assertThat(content.isTestnet).isTrue()
}
@Test
fun `isTestnet returns false for mainnet`() {
val content = createContent(network = "mainnet")
assertThat(content.isTestnet).isFalse()
}
@Test
fun `truncatedTxHash returns null when txHash is null`() {
val content = createContent(txHash = null)
assertThat(content.truncatedTxHash).isNull()
}
@Test
fun `truncatedTxHash truncates long hash`() {
val content = createContent(txHash = "abc123def456789012345678901234567890xyz")
assertThat(content.truncatedTxHash).isEqualTo("abc123de...01234xyz")
}
@Test
fun `truncatedTxHash keeps short hash intact`() {
val content = createContent(txHash = "shorthash")
assertThat(content.truncatedTxHash).isEqualTo("shorthash")
}
@Test
fun `explorerUrl returns testnet URL for testnet`() {
val content = createContent(txHash = "abc123", network = "testnet")
assertThat(content.explorerUrl).isEqualTo("https://preprod.cardanoscan.io/transaction/abc123")
}
@Test
fun `explorerUrl returns mainnet URL for mainnet`() {
val content = createContent(txHash = "abc123", network = "mainnet")
assertThat(content.explorerUrl).isEqualTo("https://cardanoscan.io/transaction/abc123")
}
@Test
fun `explorerUrl returns null when txHash is null`() {
val content = createContent(txHash = null)
assertThat(content.explorerUrl).isNull()
}
@Test
fun `type returns m_payment_cardano`() {
val content = createContent()
assertThat(content.type).isEqualTo("m.payment.cardano")
}
@Test
fun `formatAda companion function works correctly`() {
assertThat(TimelineItemPaymentContent.formatAda(1_000_000)).isEqualTo("1 ADA")
assertThat(TimelineItemPaymentContent.formatAda(1_500_000)).isEqualTo("1.5 ADA")
assertThat(TimelineItemPaymentContent.formatAda(100_000_000)).isEqualTo("100 ADA")
}
private fun createContent(
amountLovelace: Long = 10_000_000,
toAddress: String = "addr_test1abc",
fromAddress: String = "addr_test1xyz",
txHash: String? = "hash123",
status: PaymentCardStatus = PaymentCardStatus.PENDING,
network: String = "testnet",
isSentByMe: Boolean = true,
fallbackText: String = "💰 Sent 10 ADA",
) = TimelineItemPaymentContent(
amountLovelace = amountLovelace,
toAddress = toAddress,
fromAddress = fromAddress,
txHash = txHash,
status = status,
network = network,
isSentByMe = isSentByMe,
fallbackText = fallbackText,
)
}