taler-android

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

commit d05e1bb523e80112e62a0f49a2520e8b697c074d
parent 1da8cdbd06e2c885647ab8da7a0ea9a4b475935f
Author: Iván Ávalos <avalos@disroot.org>
Date:   Wed, 12 Aug 2026 10:32:08 +0200

[wallet] wait for currency spec non-blockingly

Diffstat:
Awallet/src/main/java/net/taler/wallet/compose/CurrencySpec.kt | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt | 56+++++++++++++++++++++++++++++---------------------------
Mwallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt | 10++++------
Mwallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt | 118+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt | 10++++++++--
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt | 12+++++++++---
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionDetailScreen.kt | 15++++++++-------
Mwallet/src/main/java/net/taler/wallet/transfer/WireTransferDetailsScreen.kt | 9++++++---
8 files changed, 178 insertions(+), 105 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/compose/CurrencySpec.kt b/wallet/src/main/java/net/taler/wallet/compose/CurrencySpec.kt @@ -0,0 +1,53 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import net.taler.common.CurrencySpecification +import net.taler.wallet.balances.ScopeInfo + +@Composable +fun rememberCurrencySpec( + currency: String, + scopes: List<ScopeInfo>, + getSpec: suspend (String, List<ScopeInfo>) -> CurrencySpecification?, +): CurrencySpecification? { + val spec by produceState<CurrencySpecification?>( + initialValue = null, + key1 = currency, + key2 = scopes, + ) { + value = getSpec(currency, scopes) + } + return spec +} + +@Composable +fun rememberCurrencySpec( + scope: ScopeInfo, + getSpec: suspend (ScopeInfo) -> CurrencySpecification?, +): CurrencySpecification? { + val spec by produceState<CurrencySpecification?>( + initialValue = null, + key1 = scope, + ) { + value = getSpec(scope) + } + return spec +} diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt @@ -22,7 +22,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.jsonObject @@ -36,6 +35,7 @@ import net.taler.wallet.accounts.PaytoUriTalerBank import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE import net.taler.wallet.backend.WalletBackendApi +import net.taler.wallet.backend.WalletResponse import net.taler.wallet.balances.BalanceManager import net.taler.wallet.balances.ScopeInfo import org.json.JSONObject @@ -66,11 +66,31 @@ class DepositManager( suspend fun checkDepositFees(paytoUri: String, amount: Amount): CheckDepositResult { var response: CheckDepositResult = CheckDepositResult.None - api.request("checkDeposit", CheckDepositResponse.serializer()) { + when (val res = api.request("checkDeposit", CheckDepositResponse.serializer()) { put("depositPaytoUri", paytoUri) put("amount", amount.toJSONString()) - }.onSuccess { - runBlocking { + }) { + is WalletResponse.Error -> { + Log.e(TAG, "Error checkDeposit ${res.error}") + if (res.error.code == WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE) { + res.error.extra["insufficientBalanceDetails"]?.let { details -> + val maxAmountRaw = details.jsonObject["balanceAvailable"]?.let { amount -> + Amount.fromJSONString(amount.jsonPrimitive.content) + } + + val maxAmountEffective = details.jsonObject["maxEffectiveSpendAmount"]?.let { amount -> + Amount.fromJSONString(amount.jsonPrimitive.content) + } ?: maxAmountRaw + + response = CheckDepositResult.InsufficientBalance( + maxAmountEffective = maxAmountEffective, + maxAmountRaw = maxAmountRaw, + ) + } + } + } + + is WalletResponse.Success -> { val max = getMaxDepositAmount(amount.currency, paytoUri) response = if (max?.effectiveAmount != null && amount > max.effectiveAmount) { CheckDepositResult.ExceedsLimit( @@ -79,29 +99,11 @@ class DepositManager( ) } else { CheckDepositResult.Success( - totalDepositCost = it.totalDepositCost, - effectiveDepositAmount = it.effectiveDepositAmount, - kycSoftLimit = it.kycSoftLimit, - kycHardLimit = it.kycHardLimit, - kycExchanges = it.kycExchanges, - ) - } - } - }.onError { error -> - Log.e(TAG, "Error checkDeposit $error") - if (error.code == WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE) { - error.extra["insufficientBalanceDetails"]?.let { details -> - val maxAmountRaw = details.jsonObject["balanceAvailable"]?.let { amount -> - Amount.fromJSONString(amount.jsonPrimitive.content) - } - - val maxAmountEffective = details.jsonObject["maxEffectiveSpendAmount"]?.let { amount -> - Amount.fromJSONString(amount.jsonPrimitive.content) - } ?: maxAmountRaw - - response = CheckDepositResult.InsufficientBalance( - maxAmountEffective = maxAmountEffective, - maxAmountRaw = maxAmountRaw, + totalDepositCost = res.result.totalDepositCost, + effectiveDepositAmount = res.result.effectiveDepositAmount, + kycSoftLimit = res.result.kycSoftLimit, + kycHardLimit = res.result.kycHardLimit, + kycExchanges = res.result.kycExchanges, ) } } diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable import net.taler.common.CurrencySpecification import net.taler.common.Event @@ -308,12 +307,11 @@ class ExchangeManager( }?.let { currencySpecs[it] } } - fun getSpecForCurrency(currency: String, scopes: List<ScopeInfo>) = + suspend fun getSpecForCurrency(currency: String, scopes: List<ScopeInfo>): CurrencySpecification? = scopes.find { it.currency == currency }?.let { scope -> - runBlocking { getCurrencySpecification(scope) } + getCurrencySpecification(scope) } - fun getSpecForScopeInfo(scopeInfo: ScopeInfo): CurrencySpecification? { - return runBlocking { getCurrencySpecification(scopeInfo) } - } + suspend fun getSpecForScopeInfo(scopeInfo: ScopeInfo): CurrencySpecification? = + getCurrencySpecification(scopeInfo) } diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt @@ -32,6 +32,7 @@ import net.taler.wallet.main.TAG import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi +import net.taler.wallet.backend.WalletResponse import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.donau.DonauInfo import net.taler.wallet.donau.GetDonauResponse @@ -126,71 +127,74 @@ class PaymentManager( transactionId: String, onSuccess: () -> Unit, ) = scope.launch { - api.request("getChoicesForPayment", GetChoicesForPaymentResponse.serializer()) { + when (val response = api.request("getChoicesForPayment", GetChoicesForPaymentResponse.serializer()) { put("transactionId", transactionId) - }.onSuccess { res -> - if (res.automaticExecution == true && res.automaticExecutableIndex != null) { - confirmPay(transactionId, res.automaticExecutableIndex, automaticExecution = true) - return@onSuccess - } + }) { + is WalletResponse.Error -> handleError("getChoicesForPayment", response.error) - 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@launch + } - 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() - }.onError { error -> - handleError("getChoicesForPayment", error) + onSuccess() + } } } diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -72,7 +73,7 @@ fun OutgoingPullComposable( defaultScope: ScopeInfo?, scopes: List<ScopeInfo>, devMode: Boolean, - getCurrencySpec: (scope: ScopeInfo) -> CurrencySpecification?, + getCurrencySpec: suspend (scope: ScopeInfo) -> CurrencySpecification?, checkPeerPullCredit: suspend (amount: AmountScope, loading: Boolean) -> CheckPeerPullCreditResult?, onCreateInvoice: (amount: AmountScope, subject: String, hours: Long, exchangeBaseUrl: String) -> Unit, onTosAccept: (exchangeBaseUrl: String) -> Unit, @@ -84,7 +85,12 @@ fun OutgoingPullComposable( val currency = scope.currency mutableStateOf(AmountScope(Amount.zero(currency), scope)) } - val selectedSpec = remember(amount.scope) { getCurrencySpec(amount.scope) } + val selectedSpec by produceState<CurrencySpecification?>( + initialValue = null, + key1 = amount.scope, + ) { + value = getCurrencySpec(amount.scope) + } var checkResult by remember { mutableStateOf<CheckPeerPullCreditResult?>(null) } val res = checkResult diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -77,7 +78,7 @@ fun OutgoingPushComposable( defaultScope: ScopeInfo?, scopes: List<ScopeInfo>, devMode: Boolean, - getCurrencySpec: (scope: ScopeInfo) -> CurrencySpecification?, + getCurrencySpec: suspend (scope: ScopeInfo) -> CurrencySpecification?, getFees: suspend (amount: AmountScope) -> CheckFeeResult?, onSend: (amount: AmountScope, summary: String, hours: Long) -> Unit, modifier: Modifier = Modifier, @@ -103,7 +104,7 @@ fun OutgoingPushIntroComposable( defaultScope: ScopeInfo?, scopes: List<ScopeInfo>, devMode: Boolean, - getCurrencySpec: (scope: ScopeInfo) -> CurrencySpecification?, + getCurrencySpec: suspend (scope: ScopeInfo) -> CurrencySpecification?, getFees: suspend (amount: AmountScope) -> CheckFeeResult?, onSend: (amount: AmountScope, summary: String, hours: Long) -> Unit, modifier: Modifier = Modifier, @@ -113,7 +114,12 @@ fun OutgoingPushIntroComposable( val currency = scope.currency mutableStateOf(AmountScope(Amount.zero(currency), scope)) } - val selectedSpec = remember(amount.scope) { getCurrencySpec(amount.scope) } + val selectedSpec by produceState<CurrencySpecification?>( + initialValue = null, + key1 = amount.scope, + ) { + value = getCurrencySpec(amount.scope) + } var feeResult by remember { mutableStateOf<CheckFeeResult>(None()) } var subject by rememberSaveable { mutableStateOf("") } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailScreen.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailScreen.kt @@ -73,6 +73,7 @@ import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.compose.GlobalScaffold import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.compose.rememberCurrencySpec import net.taler.wallet.deposit.TransactionDepositComposable import net.taler.wallet.launchInAppBrowser import net.taler.wallet.main.MainViewModel @@ -206,7 +207,7 @@ fun TransactionDetailScreen( .observeAsState(PayStatus.None).value, devMode = devMode, promptMode = destination.promptMode, - spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + spec = rememberCurrencySpec(tx.amountRaw.currency, tx.scopes, exchangeManager::getSpecForCurrency), modifier = modifier, onFulfill = { url -> launchInAppBrowser(context, url) @@ -237,7 +238,7 @@ fun TransactionDetailScreen( modifier = modifier, t = tx, devMode = devMode, - spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + spec = rememberCurrencySpec(tx.amountRaw.currency, tx.scopes, exchangeManager::getSpecForCurrency), onSelectOption = { it?.let { transactionManager.selectTransferOption(it) } }, onConfirmKyc = { url -> launchInAppBrowser(context, url) @@ -270,7 +271,7 @@ fun TransactionDetailScreen( modifier = modifier, t = tx, devMode = devMode, - spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + spec = rememberCurrencySpec(tx.amountRaw.currency, tx.scopes, exchangeManager::getSpecForCurrency), onSelectOption = { it?.let { transactionManager.selectTransferOption(it) } }, onConfirmKyc = { url -> launchInAppBrowser(context, url) @@ -302,7 +303,7 @@ fun TransactionDetailScreen( modifier = modifier, t = tx, devMode = devMode, - spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + spec = rememberCurrencySpec(tx.amountRaw.currency, tx.scopes, exchangeManager::getSpecForCurrency), onTransition = { handleTransactionAction(tx, it, model, onNavigateBack) }, @@ -316,7 +317,7 @@ fun TransactionDetailScreen( modifier = modifier, t = tx, devMode = devMode, - spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + spec = rememberCurrencySpec(tx.amountRaw.currency, tx.scopes, exchangeManager::getSpecForCurrency), ) { handleTransactionAction(tx, it, model, onNavigateBack) } @@ -330,7 +331,7 @@ fun TransactionDetailScreen( modifier = modifier, t = tx, devMode = devMode, - spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + spec = rememberCurrencySpec(tx.amountRaw.currency, tx.scopes, exchangeManager::getSpecForCurrency), onConfirmKyc = { url -> launchInAppBrowser(context, url) } ) { handleTransactionAction(tx, it, model, onNavigateBack) @@ -344,7 +345,7 @@ fun TransactionDetailScreen( modifier = modifier, t = tx, devMode = devMode, - spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes) + spec = rememberCurrencySpec(tx.amountRaw.currency, tx.scopes, exchangeManager::getSpecForCurrency) ) { handleTransactionAction(tx, it, model, onNavigateBack) } diff --git a/wallet/src/main/java/net/taler/wallet/transfer/WireTransferDetailsScreen.kt b/wallet/src/main/java/net/taler/wallet/transfer/WireTransferDetailsScreen.kt @@ -43,6 +43,7 @@ import net.taler.wallet.R import net.taler.wallet.compose.EmptyComposable import net.taler.wallet.compose.GlobalScaffold import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.compose.rememberCurrencySpec import net.taler.wallet.main.MainViewModel import net.taler.wallet.transactions.TransactionDeposit import net.taler.wallet.transactions.TransactionMajorState.Done @@ -118,9 +119,11 @@ fun WireTransferDetailsScreen( ) { paddingValues -> val tx = selectedTx ?: return@GlobalScaffold - val spec = tx.amountRaw.currency.let { currency -> - exchangeManager.getSpecForCurrency(currency, tx.scopes) - } + val spec = rememberCurrencySpec( + tx.amountRaw.currency, + tx.scopes, + exchangeManager::getSpecForCurrency, + ) val bankAppClick: (TransferData) -> Unit = { transfer -> context.openUri(uri = transfer.withdrawalAccount.paytoUri, title = sharePaymentTitle)