taler-android

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

commit ad65cd02bfbd7141d38d313f967c43cad3f655f3
parent dadaacaadbf16960170d3f31d4c91c646d687091
Author: Iván Ávalos <avalos@disroot.org>
Date:   Wed,  3 Dec 2025 09:53:04 +0100

[wallet] withdrawal improvements, fixes and code cleanup

Diffstat:
Mwallet/src/main/java/net/taler/wallet/compose/AmountScopeField.kt | 3+++
Mwallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt | 40+++++++++++++++++++++-------------------
Mwallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt | 184++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mwallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt | 21++++++++++++++-------
4 files changed, 132 insertions(+), 116 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/compose/AmountScopeField.kt b/wallet/src/main/java/net/taler/wallet/compose/AmountScopeField.kt @@ -61,7 +61,10 @@ import net.taler.wallet.cleanExchange data class AmountScope( val amount: Amount = Amount.zero(scope.currency), val scope: ScopeInfo, + // whether fee calculation should be debounced val debounce: Boolean = false, + // whether it originated from user input + val userInput: Boolean = false, ) @Composable diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt @@ -53,12 +53,14 @@ import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.compose.AmountScope +import net.taler.wallet.compose.EmptyComposable import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.exchanges.ExchangeItem import net.taler.wallet.exchanges.SelectExchangeDialogFragment import net.taler.wallet.withdraw.WithdrawStatus.Status.AlreadyConfirmed +import net.taler.wallet.withdraw.WithdrawStatus.Status.Confirming import net.taler.wallet.withdraw.WithdrawStatus.Status.Error import net.taler.wallet.withdraw.WithdrawStatus.Status.InfoReceived import net.taler.wallet.withdraw.WithdrawStatus.Status.Loading @@ -77,7 +79,6 @@ class PromptWithdrawFragment: Fragment() { private val selectExchangeDialog = SelectExchangeDialogFragment() - private var startup: Boolean = true private var editableCurrency: Boolean = true private var navigating: Boolean = false @@ -100,6 +101,10 @@ class PromptWithdrawFragment: Fragment() { val status by withdrawManager.withdrawStatus.collectAsStateLifecycleAware() val devMode by model.devMode.observeAsState() + if (scopes.isEmpty()) EmptyComposable() + val initialScope = status.defaultInputScope ?: scope ?: scopes.first() + val initialAmount = status.defaultInputAmount ?: Amount.zero(initialScope.currency) + LaunchedEffect(status.status) { if (status.status == None) { if (withdrawUri != null) { @@ -108,6 +113,13 @@ class PromptWithdrawFragment: Fragment() { } else if (withdrawExchangeUri != null) { // get withdrawal details for taler://withdraw-exchange URI withdrawManager.prepareManualWithdrawal(withdrawExchangeUri) + } else if (exchangeBaseUrl != null) { + withdrawManager.getWithdrawalDetailsForExchange(exchangeBaseUrl, loading = true) + } else { + withdrawManager.getWithdrawalDetailsForAmount( + amount = initialAmount, + scopeInfo = initialScope, + ) } } } @@ -124,18 +136,8 @@ class PromptWithdrawFragment: Fragment() { TalerSurface { status.let { s -> - if (scopes.isEmpty()) { - LoadingScreen() - return@let - } - - if (withdrawUri != null && status.uriInfo == null) { - LoadingScreen() - return@let - } - when (s.status) { - AlreadyConfirmed -> LoadingScreen() + Confirming, AlreadyConfirmed -> LoadingScreen() None, Loading, Error, InfoReceived, TosReviewRequired, Updating -> { val initialScope = s.defaultInputScope ?: scope ?: scopes.first() @@ -151,19 +153,19 @@ class PromptWithdrawFragment: Fragment() { scopes = scopes, onSelectExchange = { selectExchange() }, onSelectAmount = { amount, scope -> - withdrawManager.getWithdrawalDetails( + withdrawManager.getWithdrawalDetailsForAmount( amount = amount, scopeInfo = scope, - exchangeBaseUrl = exchangeBaseUrl, // only show loading screen when switching currencies - loading = startup || (exchangeBaseUrl == null && scope != status.selectedScope), + loading = scope != status.selectedScope, ) - startup = false }, onTosReview = { // TODO: rewrite ToS review screen in compose - val args = bundleOf("exchangeBaseUrl" to s.exchangeBaseUrl) - findNavController().navigate(R.id.action_global_reviewExchangeTos, args) + if (s.exchangeBaseUrl != null) { + val args = bundleOf("exchangeBaseUrl" to s.exchangeBaseUrl) + findNavController().navigate(R.id.action_global_reviewExchangeTos, args) + } }, onConfirm = { age -> status.selectedScope?.let { model.selectScope(it) } @@ -240,7 +242,7 @@ class PromptWithdrawFragment: Fragment() { } private fun onExchangeSelected(exchange: ExchangeItem) { - withdrawManager.getWithdrawalDetails( + withdrawManager.getWithdrawalDetailsForExchange( exchangeBaseUrl = exchange.exchangeBaseUrl, ) } diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt @@ -85,6 +85,7 @@ data class WithdrawStatus( None, Loading, Updating, + Confirming, InfoReceived, AlreadyConfirmed, TosReviewRequired, @@ -179,14 +180,6 @@ data class WithdrawalDetailsForUri( @Serializable data class WithdrawalDetailsForAmount( /** - * Did the user accept the current version of the exchange's - * terms of service? - * - * @deprecated the client should query the exchange entry instead - */ - val tosAccepted: Boolean, - - /** * Amount that the user will transfer to the exchange. */ val amountRaw: Amount, @@ -265,8 +258,6 @@ class WithdrawManager( private val _withdrawTestStatus = MutableStateFlow<TestWithdrawStatus>(TestWithdrawStatus.None) val withdrawTestStatus: StateFlow<TestWithdrawStatus> = _withdrawTestStatus.asStateFlow() - val qrCodes = MutableLiveData<List<QrCodeSpec>>() - var exchangeFees: ExchangeFees? = null private set @@ -320,22 +311,25 @@ class WithdrawManager( ?: error("transaction ${details.transactionId} not found") val status = _withdrawStatus.updateAndGet { value -> - updateInputDefaults(value.copy( - status = if (tx.txState.major == TransactionMajorState.Dialog) { - InfoReceived - } else { - AlreadyConfirmed - }, - uriInfo = details.info, - exchangeBaseUrl = details.info.defaultExchangeBaseUrl, - )) + updateInputDefaults( + value.copy( + status = if (tx.txState.major == TransactionMajorState.Dialog) { + InfoReceived + } else { + AlreadyConfirmed + }, + uriInfo = details.info, + exchangeBaseUrl = details.info.defaultExchangeBaseUrl, + ) + ) } // then extend with amount details (not for cash acceptor) if (!status.isCashAcceptor) { - getWithdrawalDetails( - amount = details.info.amount, - exchangeBaseUrl = details.info.defaultExchangeBaseUrl, + getWithdrawalDetailsForAmount( + amount = details.info.amount + ?: Amount.zero(details.info.currency), + defaultExchangeBaseUrl = details.info.defaultExchangeBaseUrl, loading = loading, ) } @@ -343,95 +337,87 @@ class WithdrawManager( } } - fun getWithdrawalDetails( - amount: Amount? = null, + fun getWithdrawalDetailsForAmount( + amount: Amount, scopeInfo: ScopeInfo? = null, - exchangeBaseUrl: String? = null, + defaultExchangeBaseUrl: String? = null, loading: Boolean = true, ) = scope.launch { - val status = _withdrawStatus.getAndUpdate { value -> - value.copy(status = if (loading) Loading else Updating) + // complete exchangeBaseUrl if missing + val exchange = scopeInfo?.let { exchangeManager.findExchange(it) } + ?: defaultExchangeBaseUrl?.let { exchangeManager.findExchange(it) } + ?: exchangeManager.findExchange(amount.currency) + if (exchange != null) { + getWithdrawalDetails( + amount = amount, + exchange = exchange, + loading = loading, + ) } + } - val ex: ExchangeItem - val am: Amount? - - if (amount != null && (scopeInfo != null || exchangeBaseUrl != null)) { - // 1. caller sets both parameters - ex = exchangeBaseUrl?.let { exchangeManager.findExchangeByUrl(it) } - ?: scopeInfo?.let { exchangeManager.findExchange(it) } - ?: error("could not resolve exchange") - am = ex.currency?.let { amount.copy(currency = it) } - ?: error("could not resolve currency") - } else if (amount != null) { - // 2. caller only provides amount - // => amount is updated - // => exchange URL is kept - ex = status.exchangeBaseUrl?.let { exchangeManager.findExchangeByUrl(it) } - ?: status.selectedScope?.let { exchangeManager.findExchange(it) } - ?: exchangeManager.findExchange(amount.currency) - ?: error("could not resolve exchange") - am = amount - } else if (exchangeBaseUrl != null) { - // 3. caller only provides exchange URL - ex = exchangeManager.findExchangeByUrl(exchangeBaseUrl) - ?: error("could not resolve exchange") - am = status.selectedAmount - ?: ex.currency?.let { Amount.zero(ex.currency) } - ?: error("could not resolve currency") - } else if (scopeInfo != null) { - // 3. caller only provides scope - ex = exchangeManager.findExchange(scopeInfo) - ?: error("could not resolve exchange") - am = status.selectedAmount - ?: ex.currency?.let { Amount.zero(ex.currency) } - ?: error("could not resolve currency") - } else { - error("no parameters specified") + fun getWithdrawalDetailsForExchange( + exchangeBaseUrl: String, + amount: Amount? = null, + loading: Boolean = true, + ) = scope.launch { + // complete amount if missing + val exchange = exchangeManager + .findExchangeByUrl(exchangeBaseUrl) + if (exchange != null) { + val amount = amount + ?: exchange.currency?.let { Amount.zero(it)} + if (amount != null) { + _withdrawStatus.update { value -> + updateInputDefaults(value.copy( + exchangeBaseUrl = exchangeBaseUrl)) + } + + getWithdrawalDetails( + amount = amount, + exchange = exchange, + loading = loading, + ) + } } + } - if (ex.tosStatus != ExchangeTosStatus.Accepted) { - _withdrawStatus.update { it.copy(status = TosReviewRequired) } + fun getWithdrawalDetails( + amount: Amount, + exchange: ExchangeItem, + loading: Boolean = true, + ) = scope.launch { + // do not interrupt confirmation + if (_withdrawStatus.value.status == Confirming) { return@launch } + _withdrawStatus.update { status -> + status.copy(status = if (loading) Loading else Updating) + } + api.request("getWithdrawalDetailsForAmount", WithdrawalDetailsForAmount.serializer()) { - put("exchangeBaseUrl", ex.exchangeBaseUrl) - put("amount", am.toJSONString()) + put("exchangeBaseUrl", exchange.exchangeBaseUrl) + put("amount", amount.toJSONString()) }.onError { error -> handleError("getWithdrawalDetailsForAmount", error) }.onSuccess { details -> scope.launch { _withdrawStatus.update { value -> updateSelections(value.copy( - status = InfoReceived, + status = if (exchange.tosStatus != ExchangeTosStatus.Accepted) { + TosReviewRequired + } else { + InfoReceived + }, amountInfo = details, - exchangeBaseUrl = ex.exchangeBaseUrl, + exchangeBaseUrl = exchange.exchangeBaseUrl, )) } } } } - - private fun updateSelections( - status: WithdrawStatus, - ): WithdrawStatus { - val selectedAmount = status.amountInfo?.amountRaw - val selectedScope = status.amountInfo?.scopeInfo - val selectedSpec = selectedScope?.let { scope -> - exchangeManager.getSpecForScopeInfo(scope) - } ?: selectedAmount?.currency?.let { currency -> - exchangeManager.getSpecForCurrency(currency) - } - - return status.copy( - selectedAmount = selectedAmount, - selectedScope = selectedScope, - selectedSpec = selectedSpec, - ) - } - private suspend fun updateInputDefaults( status: WithdrawStatus, ): WithdrawStatus { @@ -456,6 +442,24 @@ class WithdrawManager( ) } + private fun updateSelections( + status: WithdrawStatus, + ): WithdrawStatus { + val selectedAmount = status.amountInfo?.amountRaw + val selectedScope = status.amountInfo?.scopeInfo + val selectedSpec = selectedScope?.let { scope -> + exchangeManager.getSpecForScopeInfo(scope) + } ?: selectedAmount?.currency?.let { currency -> + exchangeManager.getSpecForCurrency(currency) + } + + return status.copy( + selectedAmount = selectedAmount, + selectedScope = selectedScope, + selectedSpec = selectedSpec, + ) + } + @UiThread fun prepareManualWithdrawal(uri: String) = scope.launch { _withdrawStatus.value = WithdrawStatus(status = Loading) @@ -464,9 +468,9 @@ class WithdrawManager( }.onError { handleError("prepareWithdrawExchange", it) }.onSuccess { - getWithdrawalDetails( - amount = it.amount, + getWithdrawalDetailsForExchange( exchangeBaseUrl = it.exchangeBaseUrl, + amount = it.amount, ) } } @@ -491,7 +495,7 @@ class WithdrawManager( @UiThread fun acceptWithdrawal(restrictAge: Int? = null) = scope.launch { val status = _withdrawStatus.updateAndGet { value -> - value.copy(status = Loading) + value.copy(status = Confirming) } if (status.talerWithdrawUri == null) { diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt @@ -90,7 +90,7 @@ fun WithdrawalShowInfo( val ageRestrictionOptions = status.amountInfo?.ageRestrictionOptions ?: emptyList() val keyboardController = LocalSoftwareKeyboardController.current - var selectedAmount by remember { mutableStateOf(initialAmountScope) } + var selectedAmount by remember(initialAmountScope) { mutableStateOf(initialAmountScope) } var selectedAge by remember { mutableStateOf<Int?>(null) } val scrollState = rememberScrollState() val insufficientBalance = remember(selectedAmount, maxAmount) { @@ -106,7 +106,7 @@ fun WithdrawalShowInfo( LaunchedEffect(selectedAmount) { val selected = selectedAmount - if (!selected.debounce) { + if (!selected.debounce && selected.userInput) { onSelectAmount( selected.amount, selected.scope, @@ -116,7 +116,7 @@ fun WithdrawalShowInfo( selectedAmount.useDebounce { val selected = selectedAmount - if (selected.debounce) { + if (selected.debounce && selected.userInput) { onSelectAmount( selected.amount, selected.scope, @@ -152,11 +152,13 @@ fun WithdrawalShowInfo( selectedAmount = amount.copy( amount = Amount.zero(amount.amount.currency), debounce = false, + userInput = true, ) }, ) - if (status.status == WithdrawStatus.Status.Loading) { + if (status.status == WithdrawStatus.Status.Loading + || status.status == WithdrawStatus.Status.None) { LoadingScreen(Modifier.weight(1f)) return } else if (status.status == Error && status.error != null) { @@ -183,7 +185,10 @@ fun WithdrawalShowInfo( showAmount = status.status != TosReviewRequired, readOnly = status.status == Updating, onAmountChanged = { amount -> - selectedAmount = amount.copy(debounce = true) + selectedAmount = amount.copy( + debounce = true, + userInput = true, + ) }, label = { Text(stringResource(R.string.amount_withdraw)) }, isError = selectedAmount.amount.isZero() || insufficientBalance, @@ -194,7 +199,10 @@ fun WithdrawalShowInfo( }, showShortcuts = true, onShortcutSelected = { amount -> - selectedAmount = amount.copy(debounce = false) + selectedAmount = amount.copy( + debounce = false, + userInput = true, + ) } ) @@ -361,7 +369,6 @@ private fun buildPreviewWithdrawStatus( ), ), amountInfo = WithdrawalDetailsForAmount( - tosAccepted = true, amountRaw = Amount.fromJSONString("KUDOS:10.1"), amountEffective = Amount.fromJSONString("KUDOS:10.2"), withdrawalAccountsList = emptyList(),