taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

commit 79848a0448d8c9551e7b73ca144a569d475d8735
parent e6334cdc426658961be1990331a03080805e9f60
Author: Iván Ávalos <avalos@disroot.org>
Date:   Wed, 26 Aug 2026 17:19:17 +0200

[wallet] show claim order error even when wallet-core deletes the transaction

Diffstat:
Mwallet/src/main/java/net/taler/wallet/HandleUriScreen.kt | 5-----
Mwallet/src/main/java/net/taler/wallet/compose/ErrorComposable.kt | 8++++++++
Mwallet/src/main/java/net/taler/wallet/main/MainViewModel.kt | 1+
Mwallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt | 67++++++++++++++++---------------------------------------------------
Mwallet/src/main/java/net/taler/wallet/payment/PayTemplateScreen.kt | 5++---
Mwallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt | 40+++++++++++++++++++++++++++++++++++++++-
Mwallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt | 14++++++++++++++
7 files changed, 80 insertions(+), 60 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt b/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt @@ -158,11 +158,6 @@ fun HandleUriScreen( } } - LaunchedEffect(payStatus) { - val error = (payStatus as? PayStatus.Pending)?.error ?: return@LaunchedEffect - errorInfo = error - } - Box(Modifier.fillMaxSize()) { if (networkStatus == true) { LoadingScreen() diff --git a/wallet/src/main/java/net/taler/wallet/compose/ErrorComposable.kt b/wallet/src/main/java/net/taler/wallet/compose/ErrorComposable.kt @@ -58,6 +58,7 @@ fun ErrorComposable( devMode: Boolean, message: String? = null, scrollable: Boolean = true, + onRetry: (() -> Unit)? = null, onClose: (() -> Unit)? = null, ) { val scrollState = rememberScrollState() @@ -143,6 +144,13 @@ fun ErrorComposable( } } + if (onRetry != null) Button( + modifier = Modifier.padding(bottom = 16.dp), + onClick = onRetry, + ) { + Text(text = stringResource(R.string.transactions_retry)) + } + if (onClose != null) Button( modifier = Modifier.padding(bottom = 16.dp), onClick = onClose, diff --git a/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt @@ -165,6 +165,7 @@ class MainViewModel( } is NotificationPayload.TransactionStateTransition -> { + paymentManager.onTransactionStateTransition(payload) viewModelScope.launch(Dispatchers.Main) { payload.transactionId?.let { id -> // update currently selected transaction diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt @@ -16,36 +16,30 @@ package net.taler.wallet.payment -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import net.taler.common.CurrencySpecification import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.ErrorComposable import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.systemBarsPaddingBottom @Composable fun PayTemplateComposable( currencies: List<String>, payStatus: PayStatus, + devMode: Boolean = false, getCurrencySpec: (String) -> CurrencySpecification?, onSubmit: (params: TemplateParams) -> Unit, onRetry: (() -> Unit)? = null, ) { if (currencies.isEmpty()) { - PayTemplateError(stringResource(R.string.payment_balance_insufficient)) + ErrorComposable( + error = TalerErrorInfo.makeCustomError(stringResource(R.string.payment_balance_insufficient)), + devMode = devMode, + ) } else when (val p = payStatus) { is PayStatus.Checked -> { val usableCurrencies = currencies @@ -53,7 +47,10 @@ fun PayTemplateComposable( .toList() if (usableCurrencies.isEmpty()) { // If user doesn't have any supported currency, they can't pay either - PayTemplateError(stringResource(R.string.payment_balance_insufficient)) + ErrorComposable( + error = TalerErrorInfo.makeCustomError(stringResource(R.string.payment_balance_insufficient)), + devMode = devMode, + ) } else if (!p.details.isTemplateEditable(usableCurrencies)) { // Non-editable: auto-preparing, show loading instead of flashing the form PayTemplateLoading() @@ -67,17 +64,16 @@ fun PayTemplateComposable( } } is PayStatus.None, is PayStatus.Loading -> PayTemplateLoading() - is PayStatus.Pending -> PayTemplateError( - message = if (p.error != null) { - stringResource(R.string.payment_error, p.error.userFacingMsg) - } else { - stringResource(R.string.payment_template_error) - }, + is PayStatus.Error -> ErrorComposable( + error = p.error, + message = stringResource(R.string.payment_template_error), + devMode = devMode, onRetry = onRetry, ) is PayStatus.Prepared -> PayTemplateLoading() // not emitted by template flow + is PayStatus.Pending, is PayStatus.AlreadyPaid, is PayStatus.InsufficientBalance, is PayStatus.Success, @@ -86,37 +82,6 @@ fun PayTemplateComposable( } @Composable -fun PayTemplateError( - message: String, - onRetry: (() -> Unit)? = null, -) { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxSize() - .systemBarsPaddingBottom(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text( - text = message, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, - ) - - if (onRetry != null) { - Button( - modifier = Modifier.padding(top = 24.dp), - onClick = onRetry, - ) { - Text(stringResource(R.string.transactions_retry)) - } - } - } -} - -@Composable fun PayTemplateLoading() { LoadingScreen() } diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateScreen.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateScreen.kt @@ -84,9 +84,7 @@ fun PayTemplateScreen( } } - is PayStatus.Pending -> if (s.error != null) { - onShowError(s.error) - } + is PayStatus.Error -> onShowError(s.error) is PayStatus.Checked -> { val usableCurrencies = balanceManager.getCurrencies() @@ -115,6 +113,7 @@ fun PayTemplateScreen( is BalanceState.Success -> PayTemplateComposable( currencies = state.balances.map { it.currency }, payStatus = payStatus, + devMode = devMode, onSubmit = { params -> scope.launch { prepareTemplate(uri, params) } }, diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt @@ -30,6 +30,7 @@ import net.taler.common.ContractTerms import net.taler.common.TalerUtils.getLocalizedString import net.taler.wallet.main.TAG import net.taler.wallet.backend.BackendManager +import net.taler.wallet.backend.NotificationPayload import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi import net.taler.wallet.backend.WalletResponse @@ -40,6 +41,7 @@ import net.taler.wallet.exchanges.ExchangeManager import org.json.JSONObject import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDetail import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDetail.PaymentPossible +import net.taler.wallet.transactions.TransactionMajorState sealed class PayStatus { data object None : PayStatus() @@ -75,6 +77,11 @@ sealed class PayStatus { val transactionId: String? = null, val error: TalerErrorInfo? = null, ) : PayStatus() + + data class Error( + val error: TalerErrorInfo, + ) : PayStatus() + data class Success( val transactionId: String, val automaticExecution: Boolean, @@ -110,6 +117,15 @@ class PaymentManager( private val mPayStatus = MutableLiveData<PayStatus>(PayStatus.None) internal val payStatus: LiveData<PayStatus> = mPayStatus + /** + * Transaction id of the currently prepared payment. Watched for + * transaction-state-transition notifications so that a transaction + * deleted by wallet-core (e.g. after a failed claim) surfaces an + * error in the UI instead of leaving the user on a loading screen. + */ + @Volatile + private var currentTransactionId: String? = null + suspend fun preparePay(url: String): String? { var transactionId: String? = null api.request("preparePayForUriV2", PreparePayV2Response.serializer()) { @@ -117,6 +133,7 @@ class PaymentManager( }.onError { handleError("preparePayForUriV2", it) }.onSuccess { response -> + currentTransactionId = response.transactionId transactionId = response.transactionId } return transactionId @@ -282,14 +299,35 @@ class PaymentManager( }.onError { handleError("preparePayForTemplate", it) }.onSuccess { response -> + currentTransactionId = response.transactionId transactionId = response.transactionId } return transactionId } + /** + * Called on transaction-state-transition notifications. If the currently + * prepared transaction is deleted by wallet-core (e.g. claim failed), the + * UI is taken out of its loading state and shown the error instead. + */ + fun onTransactionStateTransition(payload: NotificationPayload.TransactionStateTransition) { + val transactionId = payload.transactionId + if (transactionId == null || transactionId != currentTransactionId) return + val newTxState = payload.newTxState ?: return + if (newTxState.major != TransactionMajorState.Deleted) return + currentTransactionId = null + val errorInfo = payload.errorInfo + if (errorInfo == null) { + Log.e(TAG, "prepared transaction $transactionId deleted by wallet-core without error info") + return + } + Log.e(TAG, "prepared transaction $transactionId deleted by wallet-core: $errorInfo") + mPayStatus.postValue(PayStatus.Error(error = errorInfo)) + } + private fun handleError(operation: String, error: TalerErrorInfo) { Log.e(TAG, "got $operation error result $error") - mPayStatus.postValue(PayStatus.Pending(error = error)) + mPayStatus.postValue(PayStatus.Error(error = error)) } } diff --git a/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt @@ -50,6 +50,7 @@ import net.taler.wallet.R import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.balances.ScopeInfo +import net.taler.wallet.compose.ErrorComposable import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.transactions.AmountType @@ -92,6 +93,8 @@ fun TransactionPaymentComposable( ) { return TransactionPaymentPrompt( payStatus = payStatus, + devMode = devMode, + modifier = modifier, onConfirmPay = onConfirmPay, onAbortPay = { onTransition(Abort) }, onSetupDonau = onSetupDonau, @@ -156,6 +159,8 @@ fun TransactionPaymentComposable( @Composable fun TransactionPaymentPrompt( payStatus: PayStatus, + devMode: Boolean, + modifier: Modifier = Modifier, onConfirmPay: (Int?, useDonau: Boolean) -> Unit, onAbortPay: () -> Unit, onSetupDonau: (donauBaseUrl: String) -> Unit, @@ -187,6 +192,15 @@ fun TransactionPaymentPrompt( onSetupDonau(donauBaseUrl) }, ) + + is PayStatus.Error -> ErrorComposable( + error = status.error, + modifier = modifier, + devMode = devMode, + message = stringResource(R.string.payment_template_error), + scrollable = false, + ) + else -> {} }