View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0012148 | Taler | wallet (iOS App) | public | 2026-11-15 19:38 | 2026-11-15 19:38 |
| Reporter | bot-cg [agent] | ||||
| Priority | normal | Severity | minor | ||
| Status | new | ||||
| Summary | 0012148: A scanned code that cannot be acted on (expired or malformed pay request) stays in the Actions menu and cannot be removed (patch attached) | ||||
| Description | Scanning or opening a pay request that the wallet cannot act on -- expired, mistyped, unknown to the payment service -- leaves it in the Actions menu under "Scanned Taler codes:", and the user has no way to remove it. There never was a transaction, so nothing appears in the history either. Steps (iOS 27.2 simulator, fresh wallet): 1. Open taler://pay-pull/exchange.demo.taler.net/<52 arbitrary characters> (what an expired or mistyped request looks like to the app). 2. The sheet shows "Internal core error ... 7035 expected a taler://pay-pull URI" with one button, OK (scanned-1-refused.jpg). OK closes the sheet. 3. Tap the Taler logo (Actions). "Scanned Taler codes: [Pay others]" is listed (scanned-2-listed-no-remove.jpg). 4. Tapping the entry repeats step 2. Swiping it does nothing. There is no other control. To be exact about "never": the list is kept in memory only, and entries older than an hour are dropped the next time the Actions menu is opened (removeURLs(after: 60*60)). So it goes away after an hour or an app restart. What is missing is any way for the user to remove it. Two causes. (a) The list. ActionsSheet.swift, ScannedURLs: each entry is a Button inside a VStack with .swipeActions { Button { controller.removeURL(...) } } attached. .swipeActions only works on the rows of a List; on a view in a VStack it never fires. Coverage agrees: those four lines (55-58) were at zero in every fuzzing campaign and every scenario, while the list itself was on screen in 45 recorded episodes. (b) The code's own view. P2pPayURIView (and the other URL views) show a loading view until the request answers; when it fails, WalletModel puts up the error, and Sheet.swift's handler for its OK button dismisses the whole sheet. The error is therefore all this code's view ever shows, and it offers nothing but OK. Patch (scanned-code-remove.diff), built and run: - list: an explicit trash button next to each entry, in place of the dead swipe (scanned-3-patched-remove-button.jpg); - error view: when the sheet was opened for a scanned code that is still listed, a second button "Remove this scanned code" under OK (scanned-4-patched-error-view-remove.jpg); - the URL sheet's toolbar gets the same control, so a code that IS usable but unwanted can be discarded from its own view too. It adds one string, "Remove this scanned code", which needs translating. Tested with two harness scenarios (scanned-code-remove-from-list, -from-detail): on the current build both stop at the missing control; on the patched build both remove the entry and the Actions menu no longer lists it. Reported by the maintainer; reproduced and analysed with the UI fuzz harness. | ||||
| Tags | No tags attached. | ||||
| Attached Files | scanned-code-remove.diff (7,211 bytes)
diff --git a/TalerWallet1/Controllers/Controller.swift b/TalerWallet1/Controllers/Controller.swift
index 67f56ba1..213f8134 100755
--- a/TalerWallet1/Controllers/Controller.swift
+++ b/TalerWallet1/Controllers/Controller.swift
@@ -137,6 +137,8 @@ class Controller: ObservableObject {
@Published var subscriptions: [TalerToken] = []
@Published var defaultExchanges: [DefaultExchange] = []
@Published var scannedURLs: [ScannedURL] = []
+ /// the scanned code the URL sheet is currently showing, so that an error raised for it can offer to remove it
+ var openedScannedURL: URL? = nil
@Published var contacts: [ContactEntry] = []
@Published var mailboxConfiguration: MailboxConfiguration? = nil
@Published var messages: [MailboxMessage] = []
diff --git a/TalerWallet1/Views/Actions/ActionsSheet.swift b/TalerWallet1/Views/Actions/ActionsSheet.swift
index d91e7e16..faabadd7 100755
--- a/TalerWallet1/Views/Actions/ActionsSheet.swift
+++ b/TalerWallet1/Views/Actions/ActionsSheet.swift
@@ -30,6 +30,7 @@ struct ScannedURLs: View {
controller.talerURI = scannedURL.url // instead pass it via .onChange(of: controller.talerURI) directly to MainView
}
}
+ HStack(spacing: 8) {
Button(action: action) {
HStack {
Text(scannedURL.command.localizedCommand)
@@ -50,18 +51,21 @@ struct ScannedURLs: View {
.padding(.horizontal, 8)
}
.buttonStyle(TalerButtonStyle(type: .bordered, narrow: false, aligned: .center))
- .swipeActions(edge: .trailing) {
- Button {
- // symLog?.log("deleteAction")
- Task { // runs on MainActor
- controller.removeURL(scannedURL.url)
- }
- } label: {
- Label("Delete", systemImage: "trash")
+ // .swipeActions only works on the rows of a List. These rows live in a
+ // VStack, so the swipe that used to be here could never be triggered
+ // and a scanned code could not be removed at all.
+ Button(role: .destructive) {
+ withAnimation {
+ controller.removeURL(scannedURL.url)
}
- .tint(WalletColors().negative)
+ } label: {
+ Image(systemName: "trash")
+ .foregroundColor(WalletColors().negative)
}
-
+ .buttonStyle(TalerButtonStyle(type: .bordered, narrow: true, aligned: .center))
+ .accessibilityLabel(Text("Remove this scanned code", comment: "a11y"))
+ .accessibilityIdentifier("button.scanned.remove")
+ }
}
}.padding(.bottom, 20)
}
diff --git a/TalerWallet1/Views/Sheets/Sheet.swift b/TalerWallet1/Views/Sheets/Sheet.swift
index 48fbc684..1ca8eaf8 100644
--- a/TalerWallet1/Views/Sheets/Sheet.swift
+++ b/TalerWallet1/Views/Sheets/Sheet.swift
@@ -95,10 +95,29 @@ struct Sheet: View {
.accessibilityValue(idString)
}
if let error2 = model.error2 {
- ErrorView(stack.push(), data: error2, devMode: developerMode) {
- model.setError(nil)
- logger.log("ErrorSheet dismissTop")
- dismissTop(stack.push())
+ VStack(spacing: 0) {
+ ErrorView(stack.push(), data: error2, devMode: developerMode) {
+ model.setError(nil)
+ logger.log("ErrorSheet dismissTop")
+ dismissTop(stack.push())
+ }
+ // An error is all a scanned code ever shows when it cannot be acted on
+ // (expired, malformed, unknown to the payment service). "OK" closes the
+ // sheet and leaves the code in the Actions menu, so offer to remove it here.
+ if let scanned = controller.openedScannedURL,
+ controller.scannedURLs.contains(where: { $0.url == scanned }) {
+ Button(role: .destructive) {
+ controller.removeURL(scanned)
+ model.setError(nil)
+ dismissTop(stack.push())
+ } label: {
+ Label(String(localized: "Remove this scanned code", comment: "button"), systemImage: "trash")
+ }
+ .buttonStyle(TalerButtonStyle(type: .bordered, narrow: false, aligned: .center))
+ .accessibilityIdentifier("button.scanned.remove")
+ .padding(.horizontal)
+ .padding(.bottom)
+ }
}
.background(FullBackground())
}
diff --git a/TalerWallet1/Views/Sheets/URLSheet.swift b/TalerWallet1/Views/Sheets/URLSheet.swift
index 634207ec..00c955dc 100644
--- a/TalerWallet1/Views/Sheets/URLSheet.swift
+++ b/TalerWallet1/Views/Sheets/URLSheet.swift
@@ -46,7 +46,41 @@ struct URLSheet: View {
}
}
+ /// the scanned code this sheet was opened for, as long as the Actions menu still lists it
+ private var savedURL: URL? {
+ guard let passedURL else { return nil }
+ return controller.scannedURLs.contains(where: { $0.url == passedURL }) ? passedURL : nil
+ }
+
var body: some View {
+ content
+ .onChange(of: passedURL) { newURL in
+ controller.openedScannedURL = newURL
+ }
+ .onDisappear {
+ controller.openedScannedURL = nil
+ }
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ // A code that cannot be acted on (expired, malformed, unknown to the
+ // payment service) only ever shows its error here. Without this the
+ // user could neither use it nor get rid of it.
+ if let savedURL {
+ Button(role: .destructive) {
+ controller.removeURL(savedURL)
+ dismissTop(stack.push())
+ } label: {
+ Image(systemName: "trash")
+ }
+ .accessibilityLabel(Text("Remove this scanned code", comment: "a11y"))
+ .accessibilityIdentifier("button.scanned.remove.toolbar")
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var content: some View {
#if PRINT_CHANGES
let _ = Self._printChanges()
let _ = symLog.vlog(urlToOpen?.absoluteString) // just to get the # to compare it with .onAppear & onDisappear
| ||||
| child of | 0012147 | new | iOS wallet: state the user can never delete -- audit against the rule that every item must be individually removable [meta] |
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-11-15 19:38 | bot-cg | New Issue | |
| 2026-11-15 19:38 | bot-cg | Relationship added | child of 0012147 |
| 2026-11-15 19:38 | bot-cg | Implementer | => MarcS |
| 2026-11-15 19:38 | bot-cg | Reviewer | => bernaalp |
| 2026-11-15 19:38 | bot-cg | Deployed by | => mikolai |
| 2026-11-15 19:38 | bot-cg | Verifier | => Florian Dold |
| 2026-11-15 19:38 | bot-cg | Communicator | => CM_7 |
| 2026-11-15 19:38 | bot-cg | File Added: scanned-1-refused.jpg | |
| 2026-11-15 19:38 | bot-cg | File Added: scanned-2-listed-no-remove.jpg | |
| 2026-11-15 19:38 | bot-cg | File Added: scanned-3-patched-remove-button.jpg | |
| 2026-11-15 19:38 | bot-cg | File Added: scanned-4-patched-error-view-remove.jpg | |
| 2026-11-15 19:38 | bot-cg | File Added: scanned-code-remove.diff |