taler-android

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

commit 6d3efa869b6889d3b5d825eb998a7c8beb3c397c
parent 47723af929feec09aec983d5f4ae0f6691fb4be6
Author: Iván Ávalos <avalos@disroot.org>
Date:   Thu, 29 May 2025 17:21:18 +0200

[wallet] important ToS review fixes

Diffstat:
Mwallet/src/main/java/net/taler/wallet/compose/AmountScopeField.kt | 65+++++++++++++++++++++++++++++++++--------------------------------
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt | 211+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt | 15++++++++-------
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt | 4+++-
Mwallet/src/main/java/net/taler/wallet/peer/PeerManager.kt | 18+++++++++++++-----
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt | 13+++++++------
Mwallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawSuccessFragment.kt | 1-
Mwallet/src/main/res/values/strings.xml | 1+
9 files changed, 176 insertions(+), 154 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 @@ -60,7 +60,8 @@ fun AmountScopeField( supportingText: @Composable (() -> Unit)? = null, isError: Boolean = false, readOnly: Boolean = false, - enabled: Boolean = true, + enabledAmount: Boolean = true, + enabledScope: Boolean = true, showShortcuts: Boolean = false, onShortcutSelected: ((amount: AmountScope) -> Unit)? = null, ) { @@ -78,41 +79,42 @@ fun AmountScopeField( )) }, initialScope = amount.scope, - readOnly = readOnly || !enabled, + readOnly = readOnly || !enabledScope, ) } - AmountInputFieldBase( - modifier = Modifier - .fillMaxWidth(), - amount = amount.amount, - onAmountChanged = { - onAmountChanged(amount.copy(amount = it)) - }, - label = label, - isError = isError, - supportingText = supportingText, - readOnly = readOnly, - enabled = enabled, - showSymbol = true, - ) - - if (showShortcuts) { - val currency = amount.amount.currency - AmountInputShortcuts( - // TODO: currency-appropriate presets - amounts = listOf( - Amount.fromString(currency, "50").withSpec(amount.amount.spec), - Amount.fromString(currency, "25").withSpec(amount.amount.spec), - Amount.fromString(currency, "10").withSpec(amount.amount.spec), - Amount.fromString(currency, "5").withSpec(amount.amount.spec), - ), - onSelected = { shortcut -> - onShortcutSelected?.let { - it(amount.copy(amount = shortcut)) - } + if (enabledAmount) { + AmountInputFieldBase( + modifier = Modifier + .fillMaxWidth(), + amount = amount.amount, + onAmountChanged = { + onAmountChanged(amount.copy(amount = it)) }, + label = label, + isError = isError, + supportingText = supportingText, + readOnly = readOnly, + showSymbol = true, ) + + if (showShortcuts) { + val currency = amount.amount.currency + AmountInputShortcuts( + // TODO: currency-appropriate presets + amounts = listOf( + Amount.fromString(currency, "50").withSpec(amount.amount.spec), + Amount.fromString(currency, "25").withSpec(amount.amount.spec), + Amount.fromString(currency, "10").withSpec(amount.amount.spec), + Amount.fromString(currency, "5").withSpec(amount.amount.spec), + ), + onSelected = { shortcut -> + onShortcutSelected?.let { + it(amount.copy(amount = shortcut)) + } + }, + ) + } } } } @@ -234,7 +236,6 @@ fun AmountInputFieldPreview() { label = { Text("Amount to withdraw") }, isError = false, readOnly = false, - enabled = true, showShortcuts = true, onShortcutSelected = { amount = it }, ) diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt @@ -71,48 +71,11 @@ fun OutgoingPullComposable( defaultScope: ScopeInfo?, scopes: List<ScopeInfo>, getCurrencySpec: (scope: ScopeInfo) -> CurrencySpecification?, - checkPeerPullCredit: suspend (amount: AmountScope) -> CheckPeerPullCreditResult?, + checkPeerPullCredit: suspend (amount: AmountScope, loading: Boolean) -> CheckPeerPullCreditResult?, onCreateInvoice: (amount: AmountScope, subject: String, hours: Long, exchangeBaseUrl: String) -> Unit, onTosAccept: (exchangeBaseUrl: String) -> Unit, onClose: () -> Unit, ) { - when(state) { - is OutgoingChecking, is OutgoingCreating, is OutgoingResponse -> PeerCreatingComposable() - is OutgoingIntro, is OutgoingChecked -> OutgoingPullIntroComposable( - defaultScope = defaultScope, - scopes = scopes, - getCurrencySpec = getCurrencySpec, - checkPeerPullCredit = checkPeerPullCredit, - onCreateInvoice = onCreateInvoice, - onTosAccept = onTosAccept, - ) - is OutgoingError -> PeerErrorComposable(state, onClose) - } -} - -@Composable -fun PeerCreatingComposable() { - Box( - modifier = Modifier - .fillMaxSize(), - ) { - CircularProgressIndicator( - modifier = Modifier - .padding(32.dp) - .align(Center), - ) - } -} - -@Composable -fun OutgoingPullIntroComposable( - defaultScope: ScopeInfo?, - scopes: List<ScopeInfo>, - getCurrencySpec: (scope: ScopeInfo) -> CurrencySpecification?, - checkPeerPullCredit: suspend (amount: AmountScope) -> CheckPeerPullCreditResult?, - onCreateInvoice: (amount: AmountScope, subject: String, hours: Long, exchangeBaseUrl: String) -> Unit, - onTosAccept: (exchangeBaseUrl: String) -> Unit, -) { var subject by rememberSaveable { mutableStateOf("") } var amount by remember { val scope = defaultScope ?: scopes[0] @@ -126,12 +89,21 @@ fun OutgoingPullIntroComposable( var option by rememberSaveable { mutableStateOf(DEFAULT_EXPIRY) } var hours by rememberSaveable { mutableLongStateOf(DEFAULT_EXPIRY.hours) } - amount.useDebounce { - checkResult = checkPeerPullCredit(it) + val tosReview = checkResult != null && checkResult?.tosStatus != ExchangeTosStatus.Accepted + + amount.amount.useDebounce { + checkResult = checkPeerPullCredit(amount, false) + } + + LaunchedEffect(amount.scope) { + checkResult = checkPeerPullCredit(amount, true) } - LaunchedEffect(Unit) { - checkResult = checkPeerPullCredit(amount) + if (state is OutgoingChecking || + state is OutgoingCreating || + state is OutgoingResponse) { + PeerCreatingComposable() + return } Column( @@ -153,71 +125,94 @@ fun OutgoingPullIntroComposable( amount = amount.copy(amount = amount.amount.withSpec(selectedSpec)), scopes = scopes, readOnly = false, + enabledAmount = !tosReview, onAmountChanged = { amount = it }, isError = amount.amount.isZero(), label = { Text(stringResource(R.string.amount_receive)) }, ) - OutlinedTextField( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - singleLine = true, - value = subject, - onValueChange = { input -> - if (input.length <= MAX_LENGTH_SUBJECT) - subject = input.replace('\n', ' ') - }, - isError = subject.isBlank(), - label = { - Text( - stringResource(R.string.send_peer_purpose), - color = if (subject.isBlank()) { - MaterialTheme.colorScheme.error - } else Color.Unspecified, - ) - }, - supportingText = { - Text(stringResource(R.string.char_count, subject.length, MAX_LENGTH_SUBJECT)) - }, - ) + if (state is OutgoingError) { + PeerErrorComposable(state, onClose) + return@Column + } - if (res != null) { - if (res.amountEffective > res.amountRaw) { - val fee = res.amountEffective - res.amountRaw - Text( - modifier = Modifier.padding(vertical = 16.dp), - text = stringResource( - id = R.string.payment_fee, - fee.withSpec(selectedSpec) - ), - softWrap = false, - color = MaterialTheme.colorScheme.error, - ) + if (tosReview) { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource(R.string.receive_peer_review_terms) + ) + } else { + OutlinedTextField( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + singleLine = true, + value = subject, + onValueChange = { input -> + if (input.length <= MAX_LENGTH_SUBJECT) + subject = input.replace('\n', ' ') + }, + isError = subject.isBlank(), + label = { + Text( + stringResource(R.string.send_peer_purpose), + color = if (subject.isBlank()) { + MaterialTheme.colorScheme.error + } else Color.Unspecified, + ) + }, + supportingText = { + Text( + stringResource( + R.string.char_count, + subject.length, + MAX_LENGTH_SUBJECT + ) + ) + }, + ) + + if (res != null) { + if (res.amountEffective > res.amountRaw) { + val fee = res.amountEffective - res.amountRaw + Text( + modifier = Modifier.padding(vertical = 16.dp), + text = stringResource( + id = R.string.payment_fee, + fee.withSpec(selectedSpec) + ), + softWrap = false, + color = MaterialTheme.colorScheme.error, + ) + } } - } - checkResult?.exchangeBaseUrl?.let { exchangeBaseUrl -> - TransactionInfoComposable( - label = stringResource(id = R.string.withdraw_exchange), - info = cleanExchange(exchangeBaseUrl), + Text( + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), + text = stringResource(R.string.send_peer_expiration_period), + style = MaterialTheme.typography.bodyMedium, ) - } - Text( - modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), - text = stringResource(R.string.send_peer_expiration_period), - style = MaterialTheme.typography.bodyMedium, - ) + ExpirationComposable( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 8.dp, bottom = 16.dp), + option = option, + hours = hours, + onOptionChange = { option = it } + ) { hours = it } + } - ExpirationComposable( - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 8.dp, bottom = 16.dp), - option = option, - hours = hours, - onOptionChange = { option = it } - ) { hours = it } + // only show provider for global scope, + // otherwise it's already in scope selector + if (amount.scope is ScopeInfo.Global) { + checkResult?.exchangeBaseUrl?.let { exchangeBaseUrl -> + TransactionInfoComposable( + label = stringResource(id = R.string.withdraw_exchange), + info = cleanExchange(exchangeBaseUrl), + ) + } + } BottomInsetsSpacer() } @@ -226,7 +221,7 @@ fun OutgoingPullIntroComposable( Button( modifier = Modifier .systemBarsPaddingBottom(), - enabled = subject.isNotBlank() && res != null, + enabled = tosReview || (res != null && subject.isNotBlank()), onClick = { val ex = res?.exchangeBaseUrl ?: error("clickable without exchange") if (res.tosStatus == ExchangeTosStatus.Accepted) { @@ -250,6 +245,20 @@ fun OutgoingPullIntroComposable( } @Composable +fun PeerCreatingComposable() { + Box( + modifier = Modifier + .fillMaxSize(), + ) { + CircularProgressIndicator( + modifier = Modifier + .padding(32.dp) + .align(Center), + ) + } +} + +@Composable fun PeerErrorComposable(state: OutgoingError, onClose: () -> Unit) { Column( modifier = Modifier @@ -292,7 +301,7 @@ fun PeerPullComposableCreatingPreview() { ScopeInfo.Global("CHF"), ), getCurrencySpec = { null }, - checkPeerPullCredit = { null }, + checkPeerPullCredit = { _, _ -> null }, onCreateInvoice = { _, _, _, _ -> }, onTosAccept = {}, onClose = {}, @@ -313,7 +322,7 @@ fun PeerPullComposableCheckingPreview() { ScopeInfo.Global("CHF"), ), getCurrencySpec = { null }, - checkPeerPullCredit = { null }, + checkPeerPullCredit = { _, _ -> null }, onCreateInvoice = { _, _, _, _ -> }, onTosAccept = {}, onClose = {}, @@ -336,7 +345,7 @@ fun PeerPullComposableCheckedPreview() { ScopeInfo.Global("CHF"), ), getCurrencySpec = { null }, - checkPeerPullCredit = { null }, + checkPeerPullCredit = { _, _ -> null }, onCreateInvoice = { _, _, _, _ -> }, onTosAccept = {}, onClose = {}, @@ -359,7 +368,7 @@ fun PeerPullComposableErrorPreview() { ScopeInfo.Global("CHF"), ), getCurrencySpec = { null }, - checkPeerPullCredit = { null }, + checkPeerPullCredit = { _, _ -> null }, onCreateInvoice = { _, _, _, _ -> }, onTosAccept = {}, onClose = {}, diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt @@ -21,6 +21,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.platform.ComposeView import androidx.core.os.bundleOf import androidx.fragment.app.Fragment @@ -58,15 +59,15 @@ class OutgoingPullFragment : Fragment() { state = state, onCreateInvoice = this@OutgoingPullFragment::onCreateInvoice, onTosAccept = this@OutgoingPullFragment::onTosAccept, - defaultScope = selectedScope, + defaultScope = remember { selectedScope }, scopes = balanceManager.getScopes(), getCurrencySpec = balanceManager::getSpecForScopeInfo, - checkPeerPullCredit = { - exchangeManager.findExchange(it.scope)?.let { ex -> - peerManager.checkPeerPullCredit(it.amount, - exchangeBaseUrl = ex.exchangeBaseUrl, - scopeInfo = it.scope) - } + checkPeerPullCredit = { amount, loading -> + transactionManager.selectScope(amount.scope) + peerManager.checkPeerPullCredit(amount.amount, + scopeInfo = amount.scope, + loading = loading, + ) }, onClose = { findNavController().navigate(R.id.action_nav_peer_pull_to_nav_main) diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt @@ -225,7 +225,9 @@ fun OutgoingPushIntroComposable( onOptionChange = { option = it } ) { hours = it } - AnimatedVisibility(feeResult is Success) { + // only show provider for global scope, + // otherwise it's already in scope selector + AnimatedVisibility(feeResult is Success && amount.scope is ScopeInfo.Global) { (feeResult as? Success)?.let { Column( modifier = Modifier.padding(bottom = 8.dp), diff --git a/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt b/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.Serializable @@ -99,15 +100,18 @@ class PeerManager( suspend fun checkPeerPullCredit( amount: Amount, - exchangeBaseUrl: String? = null, - scopeInfo: ScopeInfo? = null, + scopeInfo: ScopeInfo, + loading: Boolean = false, ): CheckPeerPullCreditResult? { var response: CheckPeerPullCreditResult? = null - val exchangeItem = exchangeManager.findExchange(amount.currency) ?: return null + val exchangeItem = exchangeManager.findExchange(scopeInfo) ?: return null + + if (loading) { + _outgoingPullState.value = OutgoingChecking + } api.request("checkPeerPullCredit", CheckPeerPullCreditResponse.serializer()) { - exchangeBaseUrl?.let { put("exchangeBaseUrl", it) } - scopeInfo?.let { put("restrictScope", JSONObject(BackendManager.json.encodeToString(scopeInfo))) } + put("restrictScope", JSONObject(BackendManager.json.encodeToString(scopeInfo))) put("amount", amount.toJSONString()) }.onSuccess { response = CheckPeerPullCreditResult( @@ -120,6 +124,10 @@ class PeerManager( Log.e(TAG, "got checkPeerPullCredit error result $error") } + if (loading) { + _outgoingPullState.value = OutgoingIntro + } + return response } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt @@ -198,7 +198,7 @@ class TransactionManager( fun selectScope( scopeInfo: ScopeInfo?, stateFilter: TransactionStateFilter? = null, - ) = scope.launch { + ) { mSelectedScope.value = scopeInfo mStateFilter.value = stateFilter } diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt @@ -126,12 +126,7 @@ fun WithdrawalShowInfo( .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - if (status.status == TosReviewRequired) { - Text( - modifier = Modifier.padding(22.dp), - text = stringResource(R.string.withdraw_review_terms), - ) - } else if (status.isCashAcceptor) { + if (status.isCashAcceptor) { WarningLabel( label = stringResource(R.string.withdraw_cash_acceptor), modifier = Modifier @@ -149,6 +144,7 @@ fun WithdrawalShowInfo( amount = selectedAmount.amount.withSpec(spec)), scopes = scopes, editableScope = editableScope, + enabledAmount = status.status != TosReviewRequired, onAmountChanged = { amount -> selectedAmount = if (amount.scope != status.scopeInfo) { // if amount changes, reset to zero! @@ -172,6 +168,11 @@ fun WithdrawalShowInfo( } ) + if (status.status == TosReviewRequired) Text( + modifier = Modifier.padding(22.dp), + text = stringResource(R.string.withdraw_review_terms), + ) + LaunchedEffect(Unit) { focusRequester.requestFocus() } diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawSuccessFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawSuccessFragment.kt @@ -20,7 +20,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.activity.compose.BackHandler import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -225,6 +225,7 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="receive_peer_payment_instruction">Scan this QR code to pay %1$s</string> <string name="receive_peer_payment_intro">Do you want to receive this payment?</string> <string name="receive_peer_payment_title">Receive payment</string> + <string name="receive_peer_review_terms">You must first accept the payment service\'s terms of service before you can request electronic cash.</string> <string name="receive_peer_title">Request money</string> <!-- P2P send -->