View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0012070 | Taler | wallet (Android App) | public | 2026-11-11 01:28 | 2026-11-11 01:28 |
| Reporter | bot-cg [agent] | Assigned To | Resolution deadline | ||
| Implementer | Reviewer | ||||
| Release signed by | Deployed by | ||||
| Verifier | Communicator | CM_7 | |||
| Data protection authority notification required | No | ||||
| CSIRT notification required | No | ||||
| Estimated effort (hours) | Actual effort (hours) | 0 | |||
| Fast-track | No | ||||
| Work package | none | ||||
| Priority | normal | Severity | major | Reproducibility | have not tried |
| Status | new | Resolution | open | ||
| Summary | 0012070: Android: a payto:// link's "Check fees" button makes the deposit immediately (no fees, no confirmation); payto://x-taler-bank is rejected | ||||
| Description | Opening a payto:// link (or scanning a payto QR code) in the Android wallet leads to a screen titled "Send" with an amount field and a button labelled "Check fees". That button does not check fees: it navigates to the Deposit destination, whose LaunchedEffect immediately calls createDepositGroup. The user never sees the fee, the amount the account will receive, or a confirmation step, and the deposit cannot be undone. In addition only payto://iban is accepted; a payto://x-taler-bank link (the only wire method of the demo and of regional-currency exchanges) is rejected as "Not a valid Taler link". What the user sees (screenshots attached, demo backend, wallet holding KUDOS:20): - before-android-1.png: payto://iban/DE90…?receiver-name=Alice%20Example -> "Send", amount KUDOS 1.00, "Check fees". - before-android-2.png: after tapping "Check fees". Here the demo exchange refuses IBAN, so the page is an error; before-android-logcat.txt shows that the app sent createDepositGroup straight away (no checkDeposit). With an exchange that accepts IBAN this would have been a real deposit made by a button called "Check fees". - before-android-3.png: payto://x-taler-bank/bank.demo.taler.net/<account> -> "Not a valid Taler link". - before-web-1..3.png (web wallet, reference): the same x-taler-bank link opens the deposit form with that destination; "Review deposit" shows "Bank account receives", "Wallet spends", coin / wire / renewal fees; then "Confirm deposit". - before-ios-1.png: iOS does not handle payto: at all (openurl fails with LSApplicationWorkspaceErrorDomain 115). Code (taler-android origin/master 4c483527, wallet/src/main/java/net/taler/wallet/): - HandleUriScreen.kt:58-61: payto:// -> WalletDestination.PaytoUri - deposit/PayToUriScreen.kt:81, 96-101: isSupportedPayToUri else uri_invalid; deposit/DepositManager.kt:61-66: authority must be "iban" - deposit/PayToUriScreen.kt:145-163: button send_deposit_check_fees_button ("Check fees") -> WalletDestination.Deposit(amount, receiverName, IBAN) - deposit/DepositScreen.kt:60-66: LaunchedEffect(Unit) { if (presetAmount != null && receiverName != null && iban != null) depositManager.makeDeposit(...) } - deposit/DepositManager.kt:149-167: makeDeposit = createDepositGroup without checkDeposit The regular deposit path (DepositScreen.kt:140-156, DepositAmountComposable.kt) already does everything that is missing: getMaxDepositAmount, checkDeposit with fee and net amount, "Make deposit". Patch (attached, android-payto-deposit-review.patch, applies to origin/master): - WalletDestination.Deposit carries the payto URI instead of amount/name/IBAN. - DepositScreen: a payto destination is selected as a transient KnownBankAccountInfo (label = receiver-name) via depositManager.selectAccount(), so the normal amount screen with fee check and "Make deposit" is shown; nothing is deposited until the user confirms. - PayToUriScreen hands over to that screen; its own amount step and the "Check fees" button are removed. - isSupportedPayToUri accepts every type PaytoUri.parse() knows (iban, x-taler-bank, cyclos, bitcoin). The string send_deposit_check_fees_button becomes unused (kept to avoid touching the translations). Verification: built :wallet:assembleFdroidSim; after-android-1.png: the x-taler-bank link opens "Deposit to bank account" with the destination, "Available for transfer: ク20.00", amount, "Make deposit"; after-android-logcat.txt shows getMaxDepositAmount and checkDeposit, no createDepositGroup. after-android-2-iban.png: the IBAN link shows "Available for transfer: ク0.00" / "Insufficient balance" and a disabled button; no deposit is attempted. Assisted by LLM | ||||
| Steps To Reproduce | 1. Fund the wallet with KUDOS from https://bank.demo.taler.net/ (exchange.demo.taler.net). 2. Open payto://iban/DE90517954249548141481?receiver-name=Alice%20Example (adb shell am start -a android.intent.action.VIEW -d '<uri>'). 3. Enter 1, tap "Check fees": the app calls createDepositGroup at once (logcat), no fee or confirmation screen. 4. Open payto://x-taler-bank/bank.demo.taler.net/<account>?receiver-name=X: "Not a valid Taler link". | ||||
| Tags | No tags attached. | ||||
| Attached Files | before-android-logcat.txt (244 bytes)
11-11 02:01:43.664 2985 3154 D BackendManager: "operation": "createDepositGroup",
11-11 02:01:43.670 2985 3151 D taler-wallet-embedded: 2026-11-11T01:01:43.669Z wallet.ts INFO finished wallet core request createDepositGroup with error: {
after-android-logcat.txt (254 bytes)
11-11 02:06:02.536 5359 5417 D BackendManager: "operation": "getMaxDepositAmount", 11-11 02:06:13.057 5359 5418 D BackendManager: "operation": "checkDeposit", 11-11 02:06:13.070 5359 5416 D BackendManager: "operation": "getMaxDepositAmount", android-payto-deposit-review.patch (11,439 bytes)
diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt b/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt
index 3d4c2eaa..bf1df1fb 100644
--- a/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt
+++ b/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt
@@ -80,11 +80,8 @@ sealed interface WalletDestination {
@Serializable
data class Deposit(
- val amount: String? = null,
- val receiverName: String? = null,
- val receiverPostalCode: String? = null,
- val receiverTown: String? = null,
- val IBAN: String? = null
+ /** Destination given by a payto:// link; the user still picks the amount and confirms. */
+ val paytoUri: String? = null,
) : WalletDestination
@Serializable
diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt
index 9bbf9d22..aa7ff2ce 100644
--- a/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt
+++ b/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt
@@ -30,6 +30,7 @@ import net.taler.common.Amount
import net.taler.common.CurrencySpecification
import net.taler.wallet.main.TAG
import net.taler.wallet.accounts.KnownBankAccountInfo
+import net.taler.wallet.accounts.PaytoUri
import net.taler.wallet.accounts.PaytoUriBitcoin
import net.taler.wallet.accounts.PaytoUriIban
import net.taler.wallet.accounts.PaytoUriTalerBank
@@ -42,7 +43,6 @@ import net.taler.wallet.balances.BalanceManager
import net.taler.wallet.balances.ScopeInfo
import net.taler.wallet.exchanges.ExchangeManager
import org.json.JSONObject
-import androidx.core.net.toUri
class DepositManager(
private val api: WalletBackendApi,
@@ -58,12 +58,8 @@ class DepositManager(
exchangeManager.getCachedSpecForCurrency(currency)
?: exchangeManager.getSpecForScopeInfo(ScopeInfo.Global(currency))
- fun isSupportedPayToUri(uriString: String): Boolean {
- if (!uriString.startsWith("payto://")) return false
- val u = uriString.toUri()
- if (!u.authority.equals("iban", ignoreCase = true)) return false
- return u.pathSegments.isNotEmpty()
- }
+ fun isSupportedPayToUri(uriString: String): Boolean =
+ PaytoUri.parse(uriString) != null
@UiThread
fun selectAccount(account: KnownBankAccountInfo) = scope.launch {
diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositScreen.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositScreen.kt
index 605298a5..63f78718 100644
--- a/wallet/src/main/java/net/taler/wallet/deposit/DepositScreen.kt
+++ b/wallet/src/main/java/net/taler/wallet/deposit/DepositScreen.kt
@@ -28,11 +28,12 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.map
-import net.taler.common.Amount
import net.taler.wallet.NavigateCallback
import net.taler.wallet.R
import net.taler.wallet.WalletDestination
+import net.taler.wallet.accounts.KnownBankAccountInfo
import net.taler.wallet.accounts.ListBankAccountsResult
+import net.taler.wallet.accounts.PaytoUri
import net.taler.wallet.balances.ScopeInfo.ExchangeLegacyKeys
import net.taler.wallet.compose.ErrorComposable
import net.taler.wallet.compose.GlobalScaffold
@@ -51,16 +52,19 @@ fun DepositScreen(
val accountManager = model.accountManager
val exchangeManager = model.exchangeManager
- val presetAmount = dest.amount?.let { Amount.fromJSONString(it) }
- val receiverName = dest.receiverName
- val receiverPostalCode = dest.receiverPostalCode
- val receiverTown = dest.receiverTown
- val iban = dest.IBAN
-
LaunchedEffect(Unit) {
- if (presetAmount != null && receiverName != null && iban != null) {
- val paytoUri = getIbanPayto(receiverName, receiverPostalCode, receiverTown, iban)
- depositManager.makeDeposit(presetAmount, paytoUri)
+ // A payto:// link only names the destination: continue with the amount
+ // step, which shows fees and the maximum, and deposit when the user confirms.
+ dest.paytoUri?.let { paytoUri ->
+ depositManager.selectAccount(
+ KnownBankAccountInfo(
+ bankAccountId = "",
+ paytoUri = paytoUri,
+ kycCompleted = false,
+ currencies = null,
+ label = PaytoUri.parse(paytoUri)?.receiverName,
+ )
+ )
}
accountManager.listBankAccounts()
}
diff --git a/wallet/src/main/java/net/taler/wallet/deposit/PayToUriScreen.kt b/wallet/src/main/java/net/taler/wallet/deposit/PayToUriScreen.kt
index 019fc840..39210fdf 100644
--- a/wallet/src/main/java/net/taler/wallet/deposit/PayToUriScreen.kt
+++ b/wallet/src/main/java/net/taler/wallet/deposit/PayToUriScreen.kt
@@ -16,40 +16,21 @@
package net.taler.wallet.deposit
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
-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.Button
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import androidx.core.net.toUri
-import net.taler.common.Amount
-import net.taler.common.CurrencySpecification
-import net.taler.wallet.BottomInsetsSpacer
import net.taler.wallet.NavigateCallback
import net.taler.wallet.R
import net.taler.wallet.WalletDestination
-import net.taler.wallet.compose.AmountCurrencyField
import net.taler.wallet.compose.GlobalScaffold
+import net.taler.wallet.compose.LoadingScreen
import net.taler.wallet.compose.TalerSurface
-import net.taler.wallet.main.AmountResult
import net.taler.wallet.main.MainViewModel
@Composable
@@ -59,11 +40,14 @@ fun PayToUriScreen(
onNavigate: NavigateCallback,
onNavigateBack: () -> Unit,
) {
- val u = uri.toUri()
- val receiverName = u.getQueryParameter("receiver-name")?.replace('+', ' ') ?: ""
- val iban = u.pathSegments.lastOrNull() ?: ""
-
val currencies = model.balanceManager.getCurrencies()
+ val supported = model.depositManager.isSupportedPayToUri(uri)
+
+ // The link names the destination; amount, fees and confirmation are
+ // handled by the regular deposit screen, as for a saved bank account.
+ if (currencies.isNotEmpty() && supported) LaunchedEffect(uri) {
+ onNavigate(WalletDestination.Deposit(paytoUri = uri), true)
+ }
TalerSurface {
GlobalScaffold(
@@ -78,21 +62,8 @@ fun PayToUriScreen(
text = stringResource(id = R.string.payment_balance_insufficient),
color = MaterialTheme.colorScheme.error,
)
- } else if (model.depositManager.isSupportedPayToUri(uri)) {
- PayToComposable(
- currencies = currencies,
- getAmount = model::createAmount,
- onAmountChosen = { amount ->
- onNavigate(
- WalletDestination.Deposit(
- amount = amount.toJSONString(),
- receiverName = receiverName,
- IBAN = iban
- ), true,
- )
- },
- getCurrencySpec = model.exchangeManager::getSpecForCurrency,
- )
+ } else if (supported) {
+ LoadingScreen()
} else {
Text(
modifier = Modifier.padding(16.dp),
@@ -104,77 +75,3 @@ fun PayToUriScreen(
}
}
}
-
-@Composable
-private fun PayToComposable(
- currencies: List<String>,
- getAmount: (String, String) -> AmountResult,
- getCurrencySpec: (String) -> CurrencySpecification?,
- onAmountChosen: (Amount) -> Unit,
-) {
- val scrollState = rememberScrollState()
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 16.dp)
- .verticalScroll(scrollState),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- var amount by remember { mutableStateOf(Amount.zero(currencies[0])) }
- val currencySpec = remember(amount.currency) { getCurrencySpec(amount.currency) }
- var amountError by rememberSaveable { mutableStateOf("") }
-
- AmountCurrencyField(
- modifier = Modifier
- .padding(horizontal = 16.dp)
- .fillMaxWidth(),
- amount = amount.withSpec(currencySpec),
- currencies = currencies,
- readOnly = false,
- onAmountChanged = { amount = it },
- label = { Text(stringResource(R.string.amount_send)) },
- isError = amountError.isNotBlank(),
- supportingText = {
- if (amountError.isNotBlank()) {
- Text(amountError)
- }
- }
- )
-
- val focusManager = LocalFocusManager.current
- val errorStrInvalidAmount = stringResource(id = R.string.amount_invalid)
- val errorStrInsufficientBalance = stringResource(id = R.string.payment_balance_insufficient)
- Button(
- modifier = Modifier.padding(16.dp),
- enabled = !amount.isZero(),
- onClick = {
- when (val amountResult = getAmount(amount.amountStr, amount.currency)) {
- is AmountResult.Success -> {
- focusManager.clearFocus()
- onAmountChosen(amountResult.amount)
- }
- is AmountResult.InvalidAmount -> amountError = errorStrInvalidAmount
- is AmountResult.InsufficientBalance -> amountError = errorStrInsufficientBalance
- }
- },
- ) {
- Text(text = stringResource(R.string.send_deposit_check_fees_button))
- }
-
- BottomInsetsSpacer()
- }
-}
-
-@Preview
-@Composable
-fun PreviewPayToComposable() {
- Surface {
- PayToComposable(
- currencies = listOf("KUDOS", "TESTKUDOS", "BTCBITCOIN"),
- getAmount = { _, _ -> AmountResult.InvalidAmount },
- onAmountChosen = {},
- getCurrencySpec = { null }
- )
- }
-}
| ||||
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-11-11 01:28 | bot-cg | New Issue | |
| 2026-11-11 01:28 | bot-cg | File Added: before-android-1.png | |
| 2026-11-11 01:28 | bot-cg | File Added: before-android-2.png | |
| 2026-11-11 01:28 | bot-cg | File Added: before-android-3.png | |
| 2026-11-11 01:28 | bot-cg | File Added: before-android-logcat.txt | |
| 2026-11-11 01:28 | bot-cg | File Added: before-web-1.png | |
| 2026-11-11 01:28 | bot-cg | File Added: before-web-2.png | |
| 2026-11-11 01:28 | bot-cg | File Added: before-web-3.png | |
| 2026-11-11 01:28 | bot-cg | File Added: before-ios-1.png | |
| 2026-11-11 01:28 | bot-cg | File Added: after-android-1.png | |
| 2026-11-11 01:28 | bot-cg | File Added: after-android-2-iban.png | |
| 2026-11-11 01:28 | bot-cg | File Added: after-android-logcat.txt | |
| 2026-11-11 01:28 | bot-cg | File Added: android-payto-deposit-review.patch |