taler-android

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

commit b5b77ef3b0aec393071e94843433eff49bd46495
parent 3276c8fb44f1203938f44c054c0332bb865e3744
Author: Iván Ávalos <avalos@disroot.org>
Date:   Sat, 29 Aug 2026 00:07:37 +0200

[wallet] handle payment claim races and stale UI state

Diffstat:
Mwallet/src/main/java/net/taler/wallet/HandleUriScreen.kt | 5+++++
Mwallet/src/main/java/net/taler/wallet/backend/TalerErrorCode.kt | 3+++
Mwallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt | 11+++++++++--
Mwallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt | 321++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mwallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt | 4+++-
Mwallet/src/test/java/net/taler/wallet/backend/WalletResponseTest.kt | 31+++++++++++++++++++++++++++++++
6 files changed, 248 insertions(+), 127 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt b/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt @@ -150,6 +150,11 @@ fun HandleUriScreen( } } + LaunchedEffect(payStatus) { + val error = (payStatus as? PayStatus.Error)?.error ?: return@LaunchedEffect + errorInfo = error + } + LaunchedEffect(errorInfo) { val currentError = errorInfo if (currentError != null) { diff --git a/wallet/src/main/java/net/taler/wallet/backend/TalerErrorCode.kt b/wallet/src/main/java/net/taler/wallet/backend/TalerErrorCode.kt @@ -1718,6 +1718,9 @@ enum class TalerErrorCode(val code: Int) { /** A parameter in the request is malformed or missing. */ WALLET_CORE_API_BAD_REQUEST(7048), + /** The order could not be found. Maybe the merchant deleted it. */ + WALLET_MERCHANT_ORDER_NOT_FOUND(7049), + /** The HTTP server failed to allocate memory. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate. */ GENERIC_ALLOCATION_FAILURE(71), diff --git a/wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt b/wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt @@ -29,6 +29,7 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -72,6 +73,10 @@ data class TalerErrorInfo( // Error extra details val extra: Map<String, JsonElement> = mapOf(), + + // Preserve codes that are newer than this app's error-code registry, + // so they are not re-encoded as -1. + val rawCode: Int = code.code, ) { val userFacingMsg: String get() { @@ -119,6 +124,8 @@ class TalerErrorInfoSerializer : KSerializer<TalerErrorInfo> { val json = decoder.json val filtersMap = decoder.decodeSerializableValue(stringToJsonElementSerializer) + val rawCode = filtersMap["code"]?.jsonPrimitive?.intOrNull + ?: TalerErrorCode.UNKNOWN.code val code = filtersMap["code"]?.let { json.decodeFromJsonElement(TalerErrorCode.serializer(), it) } ?: TalerErrorCode.UNKNOWN @@ -132,12 +139,12 @@ class TalerErrorInfoSerializer : KSerializer<TalerErrorInfo> { val knownKeys = setOf("code", "hint", "message") val unknownFilters = filtersMap.filter { (key, _) -> !knownKeys.contains(key) } - return TalerErrorInfo(code, hint, message, unknownFilters) + return TalerErrorInfo(code, hint, message, unknownFilters, rawCode) } override fun serialize(encoder: Encoder, value: TalerErrorInfo) { encoder.encodeSerializableValue(JsonObject.serializer(), buildJsonObject { - put("code", JsonPrimitive(value.code.code)) + put("code", JsonPrimitive(value.rawCode)) put("hint", JsonPrimitive(value.hint)) put("message", JsonPrimitive(value.message)) value.extra.forEach { (key, value) -> put(key, value) } diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt @@ -21,6 +21,7 @@ import androidx.annotation.UiThread import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import net.taler.common.Amount @@ -31,6 +32,7 @@ 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.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi import net.taler.wallet.backend.WalletResponse @@ -42,20 +44,23 @@ 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 +import net.taler.wallet.transactions.TransactionMinorState -sealed class PayStatus { +sealed class PayStatus( + open val transactionId: String? = null, +) { data object None : PayStatus() data object Loading : PayStatus() data class Prepared( - val transactionId: String, - ) : PayStatus() + override val transactionId: String, + ) : PayStatus(transactionId) data class Choices( - val transactionId: String, + override val transactionId: String, val contractTerms: ContractTerms, val choices: List<PayChoiceDetails>, val defaultChoiceIndex: Int? = null, - ) : PayStatus() + ) : PayStatus(transactionId) data class Checked( val details: WalletTemplateDetails, @@ -63,31 +68,40 @@ sealed class PayStatus { ) : PayStatus() data class InsufficientBalance( - val transactionId: String, + override val transactionId: String, val contractTerms: ContractTerms, val amountRaw: Amount, val balanceDetails: PaymentInsufficientBalanceDetails, - ) : PayStatus() + ) : PayStatus(transactionId) data class AlreadyPaid( - val transactionId: String, - ) : PayStatus() + override val transactionId: String, + ) : PayStatus(transactionId) data class Pending( - val transactionId: String? = null, + override val transactionId: String? = null, val error: TalerErrorInfo? = null, - ) : PayStatus() + ) : PayStatus(transactionId) data class Error( val error: TalerErrorInfo, - ) : PayStatus() + override val transactionId: String? = null, + ) : PayStatus(transactionId) data class Success( - val transactionId: String, + override val transactionId: String, val automaticExecution: Boolean, - ) : PayStatus() + ) : PayStatus(transactionId) } +/** + * Payment status is shared between all payment attempts, so screens must + * scope the observed status to their own transaction: status produced by + * another transaction is never rendered, instead the screen keeps loading. + */ +internal fun PayStatus.forTransaction(transactionId: String): PayStatus = + if (this.transactionId == transactionId) this else PayStatus.Loading + data class PayChoiceDetails( val choiceIndex: Int, val amountRaw: Amount, @@ -118,15 +132,40 @@ class PaymentManager( internal val payStatus: LiveData<PayStatus> = mPayStatus /** - * Transaction id of the currently prepared payment. Watched for + * Transaction id of the currently active 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. + * Cleared whenever a new payment attempt starts or the currently active + * payment completes, so failures of an older payment are never shown + * during a newer one. */ @Volatile private var currentTransactionId: String? = null + /** + * In-flight request of the currently active payment attempt. A new + * payment attempt cancels it, so an error or late response from an + * older attempt can never appear during a newer payment. + */ + private var attemptJob: Job? = null + + private fun cancelPreviousAttempt() { + attemptJob?.cancel() + attemptJob = null + } + + private fun startNewAttempt(block: suspend CoroutineScope.() -> Unit) { + cancelPreviousAttempt() + attemptJob = scope.launch { block() } + } + suspend fun preparePay(url: String): String? { + // Runs in the caller's scope, which is cancelled when the caller + // leaves the screen, so a response can never outlive this payment. + cancelPreviousAttempt() + currentTransactionId = null + mPayStatus.value = PayStatus.Loading var transactionId: String? = null api.request("preparePayForUriV2", PreparePayV2Response.serializer()) { put("talerPayUri", url) @@ -143,74 +182,77 @@ class PaymentManager( fun getPaymentChoices( transactionId: String, onSuccess: () -> Unit, - ) = scope.launch { - when (val response = api.request("getChoicesForPayment", GetChoicesForPaymentResponse.serializer()) { - put("transactionId", transactionId) - }) { - is WalletResponse.Error -> handleError("getChoicesForPayment", response.error) - - is WalletResponse.Success -> { - val res = response.result - if (res.automaticExecution == true && res.automaticExecutableIndex != null) { - confirmPay(transactionId, res.automaticExecutableIndex, automaticExecution = true) - return@launch - } + ) { + currentTransactionId = transactionId + startNewAttempt { + when (val response = api.request("getChoicesForPayment", GetChoicesForPaymentResponse.serializer()) { + put("transactionId", transactionId) + }) { + is WalletResponse.Error -> handleError("getChoicesForPayment", response.error, transactionId) - mPayStatus.value = PayStatus.Choices( - transactionId = transactionId, - contractTerms = res.contractTerms, - defaultChoiceIndex = res.defaultChoiceIndex, - choices = res.choices.map { choice -> - val spec = exchangeManager.getSpecForCurrency( - choice.amountRaw.currency, - res.contractTerms.exchanges.map { - ScopeInfo.Exchange(choice.amountRaw.currency, it.url) - }, - ) ?: res.contractTerms.exchanges.firstOrNull()?.let { - exchangeManager.getSpecForScopeInfo( - ScopeInfo.Exchange(choice.amountRaw.currency, it.url) - ) - } + is WalletResponse.Success -> { + val res = response.result + if (res.automaticExecution == true && res.automaticExecutableIndex != null) { + confirmPay(transactionId, res.automaticExecutableIndex, automaticExecution = true) + return@startNewAttempt + } - when (choice) { - is PaymentPossible -> { - choice.copy( - amountRaw = choice.amountRaw.withSpec(spec), - amountEffective = choice.amountEffective.withSpec(spec), + mPayStatus.value = PayStatus.Choices( + transactionId = transactionId, + contractTerms = res.contractTerms, + defaultChoiceIndex = res.defaultChoiceIndex, + choices = res.choices.map { choice -> + val spec = exchangeManager.getSpecForCurrency( + choice.amountRaw.currency, + res.contractTerms.exchanges.map { + ScopeInfo.Exchange(choice.amountRaw.currency, it.url) + }, + ) ?: res.contractTerms.exchanges.firstOrNull()?.let { + exchangeManager.getSpecForScopeInfo( + ScopeInfo.Exchange(choice.amountRaw.currency, it.url) ) } - is ChoiceSelectionDetail.InsufficientBalance -> { - choice.copy(amountRaw = choice.amountRaw.withSpec(spec)) + when (choice) { + is PaymentPossible -> { + choice.copy( + amountRaw = choice.amountRaw.withSpec(spec), + amountEffective = choice.amountEffective.withSpec(spec), + ) + } + + is ChoiceSelectionDetail.InsufficientBalance -> { + choice.copy(amountRaw = choice.amountRaw.withSpec(spec)) + } } - } - }.mapIndexed { i, choice -> - PayChoiceDetails( - choiceIndex = i, - description = choice.description, - descriptionI18n = choice.descriptionI18n, - amountRaw = choice.amountRaw, - inputs = (res.contractTerms as? ContractTerms.V1) - ?.choices?.get(i)?.inputs ?: listOf(), - outputs = (res.contractTerms as? ContractTerms.V1) - ?.choices?.get(i)?.outputs ?: listOf(), - details = choice, - ) - }.filter { - // Hide auto executable choice - res.automaticExecutableIndex != it.choiceIndex - }.sortedWith( - compareByDescending<PayChoiceDetails> { - it.choiceIndex == res.defaultChoiceIndex - }.thenByDescending { - it.details is PaymentPossible - }.thenByDescending { - it.amountRaw.toString() - }, - ), - ) + }.mapIndexed { i, choice -> + PayChoiceDetails( + choiceIndex = i, + description = choice.description, + descriptionI18n = choice.descriptionI18n, + amountRaw = choice.amountRaw, + inputs = (res.contractTerms as? ContractTerms.V1) + ?.choices?.get(i)?.inputs ?: listOf(), + outputs = (res.contractTerms as? ContractTerms.V1) + ?.choices?.get(i)?.outputs ?: listOf(), + details = choice, + ) + }.filter { + // Hide auto executable choice + res.automaticExecutableIndex != it.choiceIndex + }.sortedWith( + compareByDescending<PayChoiceDetails> { + it.choiceIndex == res.defaultChoiceIndex + }.thenByDescending { + it.details is PaymentPossible + }.thenByDescending { + it.amountRaw.toString() + }, + ), + ) - onSuccess() + onSuccess() + } } } } @@ -220,25 +262,31 @@ class PaymentManager( choiceIndex: Int? = null, automaticExecution: Boolean = false, useDonau: Boolean = false, - ) = scope.launch { - mPayStatus.postValue(PayStatus.Loading) - api.request("confirmPay", ConfirmPayResult.serializer()) { - choiceIndex?.let { put("choiceIndex", it) } - put("transactionId", transactionId) - put("useDonau", useDonau) - }.onError { - handleError("confirmPay", it) - }.onSuccess { response -> - mPayStatus.postValue(when (response) { - is ConfirmPayResult.Done -> PayStatus.Success( - transactionId = response.transactionId, - automaticExecution = automaticExecution, - ) - is ConfirmPayResult.Pending -> PayStatus.Pending( - transactionId = response.transactionId, - error = response.lastError, - ) - }) + ) { + currentTransactionId = transactionId + startNewAttempt { + mPayStatus.postValue(PayStatus.Loading) + api.request("confirmPay", ConfirmPayResult.serializer()) { + choiceIndex?.let { put("choiceIndex", it) } + put("transactionId", transactionId) + put("useDonau", useDonau) + }.onError { + handleError("confirmPay", it, transactionId) + }.onSuccess { response -> + mPayStatus.postValue(when (response) { + is ConfirmPayResult.Done -> { + currentTransactionId = null + PayStatus.Success( + transactionId = response.transactionId, + automaticExecution = automaticExecution, + ) + } + is ConfirmPayResult.Pending -> PayStatus.Pending( + transactionId = response.transactionId, + error = response.lastError, + ) + }) + } } } @@ -269,29 +317,41 @@ class PaymentManager( } } - fun checkPayForTemplate(url: String) = scope.launch { + fun checkPayForTemplate(url: String) { + currentTransactionId = null mPayStatus.value = PayStatus.Loading - api.request("checkPayForTemplate", CheckPayTemplateResponse.serializer()) { - put("talerPayTemplateUri", url) - }.onError { - handleError("checkPayForTemplate", it) - }.onSuccess { response -> - if (response.templateDetails.templateContract.templateType == TemplateType.Paivana) { - scope.launch { - preparePayForTemplate(url, TemplateParams())?.let { transactionId -> - mPayStatus.value = PayStatus.Prepared(transactionId = transactionId) + startNewAttempt { + when (val response = api.request("checkPayForTemplate", CheckPayTemplateResponse.serializer()) { + put("talerPayTemplateUri", url) + }) { + is WalletResponse.Error -> handleError("checkPayForTemplate", response.error) + + is WalletResponse.Success -> { + // The auto-prepare runs in this attempt, so it is + // cancelled together with it when a new attempt starts. + val res = response.result + if (res.templateDetails.templateContract.templateType == TemplateType.Paivana) { + mPayStatus.value = PayStatus.Loading + preparePayForTemplate(url, TemplateParams())?.let { transactionId -> + mPayStatus.value = PayStatus.Prepared(transactionId = transactionId) + } + } else { + mPayStatus.value = PayStatus.Checked( + details = res.templateDetails, + supportedCurrencies = res.supportedCurrencies, + ) } } - } else { - mPayStatus.value = PayStatus.Checked( - details = response.templateDetails, - supportedCurrencies = response.supportedCurrencies, - ) } } } suspend fun preparePayForTemplate(url: String, params: TemplateParams): String? { + // Runs in the caller's (attempt or screen) scope; a newer payment + // attempt cancels the enclosing attempt scope before it can produce + // a stale result. The screen's check already superseded any earlier + // attempt before a form can be submitted. + mPayStatus.value = PayStatus.Loading var transactionId: String? = null api.request("preparePayForTemplateV2", PreparePayV2Response.serializer()) { put("talerPayTemplateUri", url) @@ -307,27 +367,40 @@ class PaymentManager( /** * 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. + * active payment is deleted by wallet-core with cause "claim-failed" + * (e.g. the merchant rejected or deleted the order), the UI is taken + * out of its loading state and shown the error instead. */ + @Synchronized 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 + if (!payload.isClaimFailureDeletion()) return + val transactionId = checkNotNull(payload.transactionId) + if (transactionId != currentTransactionId) return currentTransactionId = null - val errorInfo = payload.errorInfo - if (errorInfo == null) { - Log.e(TAG, "prepared transaction $transactionId deleted by wallet-core without error info") - return - } + val errorInfo = payload.errorInfo ?: TalerErrorInfo.makeCustomError( + message = "The order could not be found. Maybe the merchant deleted it.", + code = TalerErrorCode.WALLET_MERCHANT_ORDER_NOT_FOUND, + ) Log.e(TAG, "prepared transaction $transactionId deleted by wallet-core: $errorInfo") - mPayStatus.postValue(PayStatus.Error(error = errorInfo)) + mPayStatus.postValue(PayStatus.Error(error = errorInfo, transactionId = transactionId)) } - private fun handleError(operation: String, error: TalerErrorInfo) { + private fun handleError(operation: String, error: TalerErrorInfo, transactionId: String? = null) { Log.e(TAG, "got $operation error result $error") - mPayStatus.postValue(PayStatus.Error(error = error)) + mPayStatus.postValue(PayStatus.Error(error = error, transactionId = transactionId)) } } + +/** + * Matches the deletion notification emitted by wallet-core when a payment + * claim failed, e.g. because the merchant rejected or deleted the order. + * Only this specific state transition is of interest to in-progress + * payments; other deletions must not surface as payment errors. + */ +internal fun NotificationPayload.TransactionStateTransition.isClaimFailureDeletion(): Boolean = + transactionId != null && + causeHint == "claim-failed" && + oldTxState?.major == TransactionMajorState.Pending && + oldTxState.minor == TransactionMinorState.ClaimProposal && + newTxState?.major == TransactionMajorState.Deleted diff --git a/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt @@ -92,7 +92,9 @@ fun TransactionPaymentComposable( ) ) { return TransactionPaymentPrompt( - payStatus = payStatus, + // Scope the shared payment status to this transaction, so an + // error or late response from another payment is never shown. + payStatus = payStatus.forTransaction(t.transactionId), devMode = devMode, modifier = modifier, onConfirmPay = onConfirmPay, diff --git a/wallet/src/test/java/net/taler/wallet/backend/WalletResponseTest.kt b/wallet/src/test/java/net/taler/wallet/backend/WalletResponseTest.kt @@ -17,6 +17,9 @@ package net.taler.wallet.backend import kotlinx.serialization.json.Json +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import net.taler.wallet.balances.BalanceResponse import org.junit.Assert.assertEquals import org.junit.Test @@ -76,4 +79,32 @@ class WalletResponseTest { val info = json.decodeFromString(TalerErrorInfo.serializer(), infoJson) println(info.userFacingMsg) } + + @Test + fun knownWalletErrorCodeRoundTrips() { + val info = json.decodeFromString( + TalerErrorInfo.serializer(), + """{"code":7049}""", + ) + + assertEquals(TalerErrorCode.WALLET_MERCHANT_ORDER_NOT_FOUND, info.code) + assertEquals(7049, info.rawCode) + assertEquals(7049, encodedCode(info)) + } + + @Test + fun futureWalletErrorCodeRoundTripsWithoutBecomingMinusOne() { + val info = json.decodeFromString( + TalerErrorInfo.serializer(), + """{"code":7998}""", + ) + + assertEquals(TalerErrorCode.UNKNOWN, info.code) + assertEquals(7998, info.rawCode) + assertEquals(7998, encodedCode(info)) + } + + private fun encodedCode(info: TalerErrorInfo): Int = json + .parseToJsonElement(json.encodeToString(TalerErrorInfo.serializer(), info)) + .jsonObject.getValue("code").jsonPrimitive.int }