View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0011950 | Taler | wallet (iOS App) | public | 2026-11-05 07:07 | 2026-11-05 08:26 |
| Reporter | bot-cg [agent] | Assigned To | Resolution deadline | ||
| Implementer | MarcS | Reviewer | Christian Grothoff | ||
| Release signed by | MarcS | Deployed by | MarcS | ||
| Verifier | Christian Grothoff | 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 | urgent | Severity | major | Reproducibility | always |
| Status | planning | Resolution | open | ||
| Summary | 0011950: Payment sheet: the bottom button changes from “Done” to “Cancel” to “Pay … now” in the same place within ~120 ms | ||||
| Description | Scanning a taler://pay/ URI opens the payment sheet, and the single button at the bottom of that sheet changes what it does twice while the user is looking at it, without moving: 1. "Done" — while talerTX is still the dummy transaction 2. "Cancel" — once it is a pay dialog but the amount is not known 3. "Pay <amount> now" — once wallet-core has returned the amount All three are drawn in the same rectangle by the same .safeAreaInset, so the user's thumb is already on its way to one of them when it becomes another. Measured by UI automation (iPhone 17, iOS 27.2, GNU_Taler debug build): a control at [32, 792, 338, 47] changed from "Cancel" to "Pay KUDOS 0.10 now" in 118 ms, in the same place and at the same size, while the sheet's title changed from "Payment" to "Confirm Payment". 118 ms is well under the time between deciding to press something and pressing it. Both directions cost the user something real: * aiming for "Done" — meaning "do not pay" — and pressing "Pay": money leaves the wallet on a decision the user had made the other way; * aiming for "Pay", because that is where a Pay button normally is, and pressing "Done": the payment is not made, the order is filed away among the scanned QR codes, and finding it again is not obvious. The duration of each state is one network round trip, so this is worst on a connection fast enough that the swap happens after the user has aimed, and on a slow one the misleading state is simply shown for longer. The same arrangement, elsewhere ------------------------------- This is not one screen's problem. Four views give the bottom bar a single slot whose occupant switches between committing and dismissing, each switch driven by something the network decides: TransactionSummaryList.swift:350 "Done" / "Cancel" / "Pay <amount> now" P2pPayURIView.swift:60 the confirm button / "Done", on peerPullDebitResponse.txState.isConfirmed P2pReceiveURIView.swift:96 the accept button / "Done", on peerPushCreditResponse.txState.isConfirmed WithdrawAcceptView.swift:119 empty / "Confirm Withdrawal", on tosAccepted The attached patch fixes the first, which is the one that was reported and the one where a mis-tap moves money on a decision the user made the other way. The other three deserve the same treatment, and would be better served by one shared bottom-bar component than by three copies of the fix — which is a design call for whoever owns these views, not something to settle in a patch from outside. What this campaign did and did not see -------------------------------------- The 118 ms measurement above is from a run against this same app source; the file has not changed since. A later campaign did **not** re-reproduce the full Done -> Cancel -> "Pay <amount> now" sequence, and it is only honest to say so and why: * most walks ended before the payment sheet opened at all -- the sheet is reached by handing the app a URI some seconds in, and episodes were being cut short (see issue 11949); * on the one run where the sheet did open, the wallet had no balance, so it read "Payment not possible" and no "Pay" button was ever constructed -- there was nothing for the dismiss control to turn into. What the campaign did observe is that this one rectangle holds different controls in different states, which is the mechanism rather than the timing: [32, 793, 338, 47] "Done" on 'Wire Transfer' [32, 793, 338, 47] "Cancel" on 'Loading...' [32, 762, 326, 47] "Cancel" on 'Payment not possible' So the geometry and the code are confirmed; the measured interval between two specific labels is from the earlier run. Anyone reproducing this should fund the wallet first, because an unfunded wallet never builds the commit button. Cause ----- TalerWallet1/Views/Transactions/TransactionSummaryList.swift, the .safeAreaInset(edge: .bottom). One slot, whose content is chosen by `isPayDialog` and then by whether `effective` and `contractTerms` have arrived; `PaymentScan` passes showDone: .prominent, so before wallet-core answers the slot holds "Done". Suggested fix (patch attached, compiles against current master) --------------------------------------------------------------- Give the payment sheet two slots that never trade places: the commit action on top, the dismiss action below it. The commit slot exists from the first frame and is merely disabled until the amount is known, so nothing changes meaning under the user's thumb. `expectPayDialog` is how a caller says a sheet is going to become a payment dialog before wallet-core has confirmed it, which is exactly the window in which the slot used to say "Done". A sheet waiting on that answer stays swipe-dismissable so that a disabled commit button is never a trap. The patch adds one string, "Pay…", which needs translating. A smaller mitigation, if the two-slot layout is unwanted: ignore taps on the bottom button for a few hundred milliseconds after its action changes. That treats the symptom rather than the cause, but it does prevent the mis-tap. How to reproduce ---------------- 1. Fresh install of the GNU_Taler scheme on an iPhone simulator (iOS 27.2). 2. Give the wallet a balance (Actions -> Withdraw, demo cash). 3. Hand the app a taler://pay/ URI for a small amount, e.g. xcrun simctl openurl booted 'taler://pay/...' 4. Watch the button at the bottom of the sheet: "Done", then "Cancel", then "Pay <amount> now", all in the same rectangle, each for as long as one network round trip. With the UI fuzzing harness (branch dev/phoenix-bot/fuzzbang) the churn oracle reports it as "control-churn", high, with the measured frame and interval. Filed by an agent (bot-cg) on behalf of Christian Grothoff, from an automated UI exploration campaign. Left unassigned deliberately. | ||||
| Tags | No tags attached. | ||||
| Attached Files | two-slot-bottom-bar.diff (6,689 bytes)
diff --git a/TalerWallet1/Views/Sheets/Payment/PaymentScan.swift b/TalerWallet1/Views/Sheets/Payment/PaymentScan.swift
index 6cf7b899..95c8d184 100644
--- a/TalerWallet1/Views/Sheets/Payment/PaymentScan.swift
+++ b/TalerWallet1/Views/Sheets/Payment/PaymentScan.swift
@@ -103,7 +103,8 @@ struct PaymentScan: View, Sendable {
hasDone: true, // conclude payment
showDone: .prominent,
url: url,
- withActions: false)
+ withActions: false,
+ expectPayDialog: true) // reserve the commit slot
#if TALER_NIGHTLY2
// if let preparePayResult {
let status = preparePayResult.status
diff --git a/TalerWallet1/Views/Transactions/TransactionSummaryList.swift b/TalerWallet1/Views/Transactions/TransactionSummaryList.swift
index d475186d..7a609769 100755
--- a/TalerWallet1/Views/Transactions/TransactionSummaryList.swift
+++ b/TalerWallet1/Views/Transactions/TransactionSummaryList.swift
@@ -35,6 +35,14 @@ struct TransactionSummaryList: View {
let showDone: TalerButtonStyleType?
let url: URL? // the scanned talerURL from PaymentView
let withActions: Bool // true if called from pending or history, or refund
+ /// true when the caller already knows this sheet is going to be a payment
+ /// dialog, before wallet-core has said so.
+ ///
+ /// Without it the bar cannot reserve the commit slot: for the first few
+ /// hundred milliseconds `talerTX` is still the dummy transaction, so
+ /// `isPayDialog` is false and the bar shows "Done" -- in the very place
+ /// that is about to hold "Pay <amount> now".
+ var expectPayDialog: Bool = false
@EnvironmentObject private var controller: Controller
@EnvironmentObject private var model: WalletModel
@@ -346,26 +354,58 @@ struct TransactionSummaryList: View {
.listStyle(myListStyle.style).anyView
.background(FullBackground())
.navigationBarBackButtonHidden(hasDone)
- .interactiveDismissDisabled(hasDone && !isPayDialog) // can only use "Done" button to dismiss
+ // "Done" is the only way out of a concluded transaction -- but a
+ // payment sheet whose commit button is still disabled must not be a
+ // trap, so a sheet the caller expects to become a pay dialog stays
+ // swipe-dismissable.
+ .interactiveDismissDisabled(hasDone && !isPayDialog && !expectPayDialog)
.safeAreaInset(edge: .bottom) {
let cancelButton = Button("Cancel") { dismissTop(stack.push()) }
.buttonStyle(TalerButtonStyle(type: .bordered))
.padding(is26 ? .horizontal : .all)
+ // A payment sheet's bar has two slots that never trade places:
+ // the commit action on top, the dismiss action below it.
+ //
+ // It used to have one slot, and that slot showed "Done", then
+ // "Cancel", then "Pay <amount> now" as wallet-core answered --
+ // 118 ms apart in a recorded run, which is less than the time
+ // between deciding to press something and pressing it. Both
+ // directions hurt: aiming for "Done" and paying, or aiming for
+ // "Pay" and instead filing the order away unpaid among the
+ // scanned codes, where it is hard to find again.
+ //
+ // So the commit slot exists from the start and is merely
+ // disabled until the amount is known, and "Cancel" has a place
+ // of its own instead of borrowing the commit slot.
+ let payPlaceholder = Button(String(localized: "Pay…",
+ comment: "disabled until wallet-core returns the amount")) { }
+ .buttonStyle(TalerButtonStyle(type: .prominent, disabled: true))
+ .disabled(true)
+ .accessibilityHint(Text("Waiting for the amount to pay.",
+ comment: "a11y"))
+ .padding(is26 ? .horizontal : .all)
let bottomButton = ZStack {
- if isPayDialog, case .payment(let paymentTransaction) = talerTX {
- let details = paymentTransaction.details
- if let effective, let terms = details.contractTerms, isFinished == false {
- let formatted = effective.formatted(currencyInfo)
- PaySafeArea(symLog: symLog,
- stack: stack.push(),
- terms: terms,
- amountString: formatted.0,
- amountA11y: formatted.1,
- isFinished: $isFinished,
- payNow: $payNow)
- } else {
+ if expectPayDialog || isPayDialog {
+ VStack(spacing: 0) {
+ if isPayDialog, case .payment(let paymentTransaction) = talerTX {
+ let details = paymentTransaction.details
+ if let effective, let terms = details.contractTerms, isFinished == false {
+ let formatted = effective.formatted(currencyInfo)
+ PaySafeArea(symLog: symLog,
+ stack: stack.push(),
+ terms: terms,
+ amountString: formatted.0,
+ amountA11y: formatted.1,
+ isFinished: $isFinished,
+ payNow: $payNow)
+ } else {
+ payPlaceholder
+ }
+ } else {
+ payPlaceholder
+ }
cancelButton
- } // Cancel
+ }
} else if let showDone {
Button("Done") { dismissTop(stack.push()) }
.buttonStyle(TalerButtonStyle(type: showDone))
| ||||
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-11-05 07:07 | bot-cg | New Issue | |
| 2026-11-05 07:07 | bot-cg | File Added: two-slot-bottom-bar.diff | |
| 2026-11-05 08:26 | Christian Grothoff | Implementer | => MarcS |
| 2026-11-05 08:26 | Christian Grothoff | Reviewer | => Christian Grothoff |
| 2026-11-05 08:26 | Christian Grothoff | Release signed by | => MarcS |
| 2026-11-05 08:26 | Christian Grothoff | Deployed by | => MarcS |
| 2026-11-05 08:26 | Christian Grothoff | Verifier | => Christian Grothoff |
| 2026-11-05 08:26 | Christian Grothoff | Priority | normal => urgent |
| 2026-11-05 08:26 | Christian Grothoff | Status | new => planning |