taler-android

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

commit 3687d7c40810bc142623d1a38190bce78c0d20f8
parent b237ae21531b797dba52569ed1a4b77f0a515a36
Author: Iván Ávalos <avalos@disroot.org>
Date:   Mon,  8 Jun 2026 23:05:09 +0200

[wallet] replace preparePayForUri with preparePayForUriV2

Diffstat:
Mwallet/src/main/java/net/taler/wallet/HandleUriScreen.kt | 12+++++++++---
Mwallet/src/main/java/net/taler/wallet/WalletNavHost.kt | 9---------
Mwallet/src/main/java/net/taler/wallet/WalletNavigation.kt | 3---
Mwallet/src/main/java/net/taler/wallet/main/MainScreen.kt | 8++++----
Mwallet/src/main/java/net/taler/wallet/payment/PayTemplateScreen.kt | 5+++--
Mwallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt | 26+++++++-------------------
Mwallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt | 5+++++
Dwallet/src/main/java/net/taler/wallet/payment/PromptPaymentScreen.kt | 177-------------------------------------------------------------------------------
Mwallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt | 107++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionDetailScreen.kt | 25++++++++++++++++++++++++-
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt | 6++++++
Mwallet/src/main/java/net/taler/wallet/transactions/Transactions.kt | 6+++---
12 files changed, 165 insertions(+), 224 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt b/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.core.net.toUri @@ -34,7 +35,6 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import net.taler.wallet.backend.TalerErrorInfo -import net.taler.wallet.compose.ErrorComposable import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.RetryScreen import net.taler.wallet.main.MainViewModel @@ -57,6 +57,7 @@ fun HandleUriScreen( var errorInfo by remember { mutableStateOf<TalerErrorInfo?>(null) } val networkStatus by model.networkManager.networkStatus.observeAsState() val devMode by model.devMode.observeAsState(false) + val scope = rememberCoroutineScope() fun processTalerUri() { if (processing) return @@ -96,8 +97,13 @@ fun HandleUriScreen( when { action.startsWith("pay/", ignoreCase = true) -> { - model.paymentManager.preparePay(u2) - onNavigate(WalletDestination.PromptPayment, true) + scope.launch { + model.paymentManager.preparePay(u2)?.let { transactionId -> + if (model.transactionManager.selectTransaction(transactionId)) { + onNavigate(WalletDestination.TransactionPayment, true) + } + } + } } action.startsWith("withdraw/", ignoreCase = true) -> { model.withdrawManager.resetWithdrawal() diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt b/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt @@ -47,7 +47,6 @@ import net.taler.wallet.exchanges.ReviewExchangeTosScreen import net.taler.wallet.main.MainScreen import net.taler.wallet.main.MainViewModel import net.taler.wallet.payment.PayTemplateScreen -import net.taler.wallet.payment.PromptPaymentScreen import net.taler.wallet.peer.IncomingPullPaymentScreen import net.taler.wallet.peer.IncomingPushPaymentScreen import net.taler.wallet.peer.OutgoingPullScreen @@ -162,14 +161,6 @@ fun WalletNavHost( onNavigateBack = onNavigateBack, ) } - composable<WalletDestination.PromptPayment> { - PromptPaymentScreen( - model = model, - onNavigate = onNavigate, - onNavigateBack = onNavigateBack, - onShowError = onShowError, - ) - } composable<WalletDestination.ExchangeList> { ExchangeListScreen( model = model, diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt b/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt @@ -30,9 +30,6 @@ sealed interface WalletDestination { data class PaytoUri(val uri: String) : WalletDestination @Serializable - data object PromptPayment : WalletDestination - - @Serializable data class PromptWithdraw( val withdrawUri: String? = null, val withdrawExchangeUri: String? = null, diff --git a/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt b/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt @@ -510,10 +510,10 @@ private fun onTransactionClicked( when (tx.txState) { // unfinished transactions (dialog) TransactionState(TransactionMajorState.Dialog) -> when (tx) { - is TransactionPayment -> { - model.paymentManager.preparePay(tx.transactionId) {} - onNavigate(WalletDestination.PromptPayment, true) - } +// is TransactionPayment -> { +// model.paymentManager.preparePay(tx.transactionId) {} +// onNavigate(WalletDestination.PromptPayment, true) +// } is TransactionPeerPushCredit -> { model.peerManager.preparePeerPushCredit(transactionId = tx.transactionId) diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateScreen.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateScreen.kt @@ -50,6 +50,7 @@ fun PayTemplateScreen( val paymentManager = model.paymentManager val balanceManager = model.balanceManager val exchangeManager = model.exchangeManager + val transactionManager = model.transactionManager val payStatus by paymentManager.payStatus.asFlow().collectAsStateLifecycleAware(PayStatus.None) val balanceState by balanceManager.state.observeAsState(BalanceState.None) @@ -63,8 +64,8 @@ fun PayTemplateScreen( LaunchedEffect(payStatus) { when (val s = payStatus) { is PayStatus.Prepared -> { - paymentManager.preparePay(s.transactionId) { - onNavigate(WalletDestination.PromptPayment, false) + if (transactionManager.selectTransaction(s.transactionId)) { + onNavigate(WalletDestination.TransactionPayment, true) } } diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt @@ -112,36 +112,24 @@ class PaymentManager( private val scope: CoroutineScope, private val exchangeManager: ExchangeManager, ) { - private val mPayStatus = MutableLiveData<PayStatus>(PayStatus.None) internal val payStatus: LiveData<PayStatus> = mPayStatus - @UiThread - fun preparePay(url: String) = scope.launch { + suspend fun preparePay(url: String): String? { + var transactionId: String? = null mPayStatus.value = PayStatus.Loading - api.request("preparePayForUri", PreparePayResponse.serializer()) { + api.request("preparePayForUriV2", PreparePayV2Response.serializer()) { put("talerPayUri", url) }.onError { - handleError("preparePayForUri", it) + handleError("preparePayForUriV2", it) }.onSuccess { response -> - if (response is AlreadyConfirmedResponse) { - mPayStatus.value = AlreadyPaid(response.transactionId) - return@onSuccess - } - - val transactionId = when (response) { - is PaymentPossibleResponse -> response.transactionId - is InsufficientBalanceResponse -> response.transactionId - is PreparePayResponse.ChoiceSelection -> response.transactionId - else -> return@onSuccess - } - - preparePay(transactionId) {} + transactionId = response.transactionId } + return transactionId } @UiThread - fun preparePay( + fun getPaymentChoices( transactionId: String, onSuccess: () -> Unit, ) = scope.launch { diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt @@ -88,6 +88,11 @@ sealed class PreparePayResponse { } @Serializable +data class PreparePayV2Response ( + val transactionId: String, +) + +@Serializable data class GetChoicesForPaymentResponse( val choices: List<ChoiceSelectionDetail>, val contractTerms: ContractTerms, diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentScreen.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentScreen.kt @@ -1,177 +0,0 @@ -/* - * 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.payment - -import android.graphics.Bitmap -import android.util.Log -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import net.taler.wallet.NavigateCallback -import net.taler.wallet.R -import net.taler.wallet.WalletDestination -import net.taler.wallet.backend.TalerErrorInfo -import net.taler.wallet.compose.GlobalScaffold -import net.taler.wallet.compose.LoadingScreen -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.main.TAG -import net.taler.wallet.transactions.TransactionManager - -@Composable -fun PromptPaymentScreen( - model: MainViewModel, - onNavigate: NavigateCallback, - onNavigateBack: () -> Unit, - onShowError: (TalerErrorInfo) -> Unit, -) { - val paymentManager = model.paymentManager - val transactionManager = model.transactionManager - val payStatus by paymentManager.payStatus.observeAsState(PayStatus.None) - var showImage by remember { mutableStateOf<Bitmap?>(null) } - - LaunchedEffect(payStatus) { - val status = payStatus - when (status) { - is PayStatus.Success -> { - navigateToTransaction(status.transactionId, transactionManager, onNavigate, onNavigateBack) - paymentManager.resetPayStatus() - } - - is PayStatus.AlreadyPaid -> { - navigateToTransaction(status.transactionId, transactionManager, onNavigate, onNavigateBack) - paymentManager.resetPayStatus() - } - - is PayStatus.Pending -> { - navigateToTransaction(status.transactionId, transactionManager, onNavigate, onNavigateBack) - paymentManager.resetPayStatus() - status.error?.let { error -> - onShowError(error) - } - } - - else -> {} - } - } - - GlobalScaffold( - model = model, - modifier = Modifier.fillMaxSize(), - title = { Text(stringResource(R.string.payment_title)) }, - onNavigateBack = onNavigateBack, - ) { paddingValues -> - when (val status = payStatus) { - is PayStatus.None, - is PayStatus.Loading, - is PayStatus.Prepared -> LoadingScreen(Modifier.padding(paddingValues)) - is PayStatus.Choices -> { - PromptPaymentComposable( - modifier = Modifier.padding(paddingValues), - status = status, - onConfirm = { index, useDonau -> - paymentManager.confirmPay( - transactionId = status.transactionId, - choiceIndex = index, - useDonau = useDonau, - ) - }, - onCancel = { - transactionManager.abortTransaction( - status.transactionId, - onSuccess = { - onNavigateBack() - }, - onError = { error -> - Log.e(TAG, "Error abortTransaction $error") - onShowError(error) - } - ) - }, - onClickImage = { bitmap -> - showImage = bitmap - }, - checkDonauStatus = { index -> - status.choices.find { it.choiceIndex == index }?.let { choice -> - paymentManager.checkDonauForChoice(choice) - } ?: DonauStatus.Unavailable - }, - onSetupDonau = { donauBaseUrl -> - onNavigate(WalletDestination.SetDonau(donauBaseUrl), false) - }, - ) - } - else -> {} - } - } - - if (showImage != null) { - // ProductImageFragment was a DialogFragment. - // For now, we can just use a simple Modal or similar if needed, - // but let's just keep it simple or implement a basic compose dialog. - ProductImageDialog(showImage!!) { showImage = null } - } -} - -@Composable -fun ProductImageDialog(bitmap: Bitmap, onDismiss: () -> Unit) { - Dialog(onDismissRequest = onDismiss) { - Card( - shape = MaterialTheme.shapes.medium, - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - ) { - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .clickable { onDismiss() } - ) - } - } -} - -private suspend fun navigateToTransaction( - id: String?, - transactionManager: TransactionManager, - onNavigate: NavigateCallback, - onNavigateBack: () -> Unit, -) { - if (id != null && transactionManager.selectTransaction(id)) { - onNavigate(WalletDestination.TransactionPayment, true) - } else { - onNavigateBack() - } -} diff --git a/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt @@ -16,20 +16,31 @@ package net.taler.wallet.payment +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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 androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import net.taler.common.Amount import net.taler.common.CurrencySpecification import net.taler.common.Merchant @@ -40,6 +51,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.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.transactions.AmountType import net.taler.wallet.transactions.ErrorTransactionButton @@ -51,7 +63,9 @@ import net.taler.wallet.transactions.TransactionAmountComposable import net.taler.wallet.transactions.TransactionInfo import net.taler.wallet.transactions.TransactionInfoComposable import net.taler.wallet.transactions.TransactionLinkComposable +import net.taler.wallet.transactions.TransactionMajorState import net.taler.wallet.transactions.TransactionMajorState.Pending +import net.taler.wallet.transactions.TransactionMinorState import net.taler.wallet.transactions.TransactionPayment import net.taler.wallet.transactions.TransactionState import net.taler.wallet.transactions.TransactionStateComposable @@ -60,12 +74,28 @@ import net.taler.wallet.transactions.TransitionsComposable @Composable fun TransactionPaymentComposable( t: TransactionPayment, + payStatus: PayStatus, devMode: Boolean, spec: CurrencySpecification?, + modifier: Modifier = Modifier, onFulfill: (url: String) -> Unit, onTransition: (t: TransactionAction) -> Unit, - modifier: Modifier = Modifier, + onConfirmPay: (Int?, useDonau: Boolean) -> Unit, + onSetupDonau: (donauBaseUrl: String) -> Unit, + checkDonauForChoice: suspend (PayChoiceDetails) -> DonauStatus?, ) { + if (t.txState.minor == TransactionMinorState.ClaimProposal && t.error == null) { + return LoadingScreen() + } else if (t.txState.major == TransactionMajorState.Dialog) { + return TransactionPaymentPrompt( + payStatus = payStatus, + onConfirmPay = onConfirmPay, + onAbortPay = { onTransition(Abort) }, + onSetupDonau = onSetupDonau, + checkDonauForChoice = checkDonauForChoice, + ) + } + val scrollState = rememberScrollState() Column( modifier = modifier @@ -109,7 +139,7 @@ fun TransactionPaymentComposable( ) } - PurchaseDetails(info = t.info) { + if (t.info != null) PurchaseDetails(info = t.info) { onFulfill(t.info.fulfillmentUrl ?: "") } @@ -123,6 +153,47 @@ fun TransactionPaymentComposable( } @Composable +fun TransactionPaymentPrompt( + payStatus: PayStatus, + onConfirmPay: (Int?, useDonau: Boolean) -> Unit, + onAbortPay: () -> Unit, + onSetupDonau: (donauBaseUrl: String) -> Unit, + checkDonauForChoice: suspend (PayChoiceDetails) -> DonauStatus?, +) { + var showImage by remember { mutableStateOf<Bitmap?>(null) } + when (val status = payStatus) { + is PayStatus.None, + is PayStatus.Loading, + is PayStatus.Prepared -> LoadingScreen() + is PayStatus.Choices -> PromptPaymentComposable( + status = status, + onConfirm = { index, useDonau -> + onConfirmPay(index, useDonau) + }, + onCancel = { + onAbortPay() + }, + onClickImage = { bitmap -> + showImage = bitmap + }, + checkDonauStatus = { index -> + status.choices.find { it.choiceIndex == index }?.let { choice -> + checkDonauForChoice(choice) + } ?: DonauStatus.Unavailable + }, + onSetupDonau = { donauBaseUrl -> + onSetupDonau(donauBaseUrl) + }, + ) + else -> {} + } + + if (showImage != null) { + ProductImageDialog(showImage!!) { showImage = null } + } +} + +@Composable fun PurchaseDetails( info: TransactionInfo, onClick: () -> Unit, @@ -155,6 +226,26 @@ fun PurchaseDetails( } } +@Composable +fun ProductImageDialog(bitmap: Bitmap, onDismiss: () -> Unit) { + Dialog(onDismissRequest = onDismiss) { + Card( + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .clickable { onDismiss() } + ) + } + } +} + @Preview @Composable fun TransactionPaymentComposablePreview() { @@ -180,6 +271,16 @@ fun TransactionPaymentComposablePreview() { )) ) TalerSurface { - TransactionPaymentComposable(t = t, devMode = true, spec = null, onFulfill = {}, onTransition = {}) + TransactionPaymentComposable( + t = t, + payStatus = PayStatus.None, + devMode = true, + spec = null, + onFulfill = {}, + onTransition = {}, + onConfirmPay = { _, _ ->}, + onSetupDonau = {}, + checkDonauForChoice = { _ -> DonauStatus.Available }, + ) } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailScreen.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -71,6 +72,7 @@ import net.taler.wallet.deposit.TransactionDepositComposable import net.taler.wallet.launchInAppBrowser import net.taler.wallet.main.MainViewModel import net.taler.wallet.main.TAG +import net.taler.wallet.payment.PayStatus import net.taler.wallet.payment.TransactionPaymentComposable import net.taler.wallet.peer.TransactionPeerPullCreditComposable import net.taler.wallet.peer.TransactionPeerPullDebitComposable @@ -127,16 +129,37 @@ fun TransactionDetailScreen( when (destination) { is WalletDestination.TransactionPayment -> { (t as? TransactionPayment)?.let { tx -> + LaunchedEffect(tx.txState) { + if (tx.txState.major == TransactionMajorState.Dialog) { + model.paymentManager.getPaymentChoices(tx.transactionId) {} + } + } + TransactionPaymentComposable( - modifier = modifier, t = tx, + payStatus = model.paymentManager.payStatus + .observeAsState(PayStatus.None).value, devMode = devMode, spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + modifier = modifier, onFulfill = { url -> launchInAppBrowser(context, url) }, onTransition = { action -> handleTransactionAction(tx, action, model, onNavigateBack) + }, + onConfirmPay = { choiceIndex, useDonau -> + model.paymentManager.confirmPay( + transactionId = tx.transactionId, + choiceIndex = choiceIndex, + useDonau = useDonau, + ) + }, + onSetupDonau = { donauBaseUrl -> + onNavigate(WalletDestination.SetDonau(donauBaseUrl), false) + }, + checkDonauForChoice = { choice -> + model.paymentManager.checkDonauForChoice(choice) } ) } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt @@ -186,6 +186,12 @@ class TransactionManager( } } + fun selectTransactionSync(transactionId: String, onSuccess: () -> Unit) = scope.launch { + if (selectTransaction(transactionId)) { + onSuccess() + } + } + @UiThread fun updateTransactionIfSelected(id: String) = scope.launch { val selectedTransaction = selectedTransaction.value diff --git a/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt b/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt @@ -388,8 +388,7 @@ class TransactionPayment( override val timestamp: Timestamp, override val txState: TransactionState, override val txActions: List<TransactionAction>, - val info: TransactionInfo, - val contractTerms: ContractTerms? = null, + val info: TransactionInfo? = null, override val error: TalerErrorInfo? = null, override val amountRaw: Amount, override val amountEffective: Amount, @@ -402,7 +401,8 @@ class TransactionPayment( @Transient override val amountType = AmountType.Negative @Composable - override fun getTitle() = info.merchant.name + override fun getTitle() = info?.merchant?.name + ?: stringResource(R.string.payment_title) override val generalTitleRes = R.string.payment_title }