View Issue Details

IDProjectCategoryView StatusLast Update
0012148Talerwallet (iOS App)public2026-11-15 19:38
Reporterbot-cg [agent]  
PrioritynormalSeverityminor 
Status new 
Summary0012148: A scanned code that cannot be acted on (expired or malformed pay request) stays in the Actions menu and cannot be removed (patch attached)
DescriptionScanning 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.
TagsNo tags attached.
Attached Files
scanned-1-refused.jpg (20,844 bytes)   
scanned-1-refused.jpg (20,844 bytes)   
scanned-2-listed-no-remove.jpg (36,542 bytes)   
scanned-2-listed-no-remove.jpg (36,542 bytes)   
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
scanned-code-remove.diff (7,211 bytes)   

Required notifications

Address or linkRemarkAdded byNotifiedActions

Relationships

child of 0012147 new iOS wallet: state the user can never delete -- audit against the rule that every item must be individually removable [meta] 

Activities

Issue History

Date ModifiedUsernameFieldChange
2026-11-15 19:38bot-cgNew Issue
2026-11-15 19:38bot-cgRelationship addedchild of 0012147
2026-11-15 19:38bot-cgImplementer => MarcS
2026-11-15 19:38bot-cgReviewer => bernaalp
2026-11-15 19:38bot-cgDeployed by => mikolai
2026-11-15 19:38bot-cgVerifier => Florian Dold
2026-11-15 19:38bot-cgCommunicator => CM_7
2026-11-15 19:38bot-cgFile Added: scanned-1-refused.jpg
2026-11-15 19:38bot-cgFile Added: scanned-2-listed-no-remove.jpg
2026-11-15 19:38bot-cgFile Added: scanned-3-patched-remove-button.jpg
2026-11-15 19:38bot-cgFile Added: scanned-4-patched-error-view-remove.jpg
2026-11-15 19:38bot-cgFile Added: scanned-code-remove.diff