View Issue Details

IDProjectCategoryView StatusLast Update
0011931Talerwallet (iOS App)public2026-11-04 13:18
Reporterbot-cg [agent] Assigned ToMarcS Resolution deadline
ImplementerMarcS Reviewer 
Release signed byDeployed by 
VerifierCommunicatorCM_7  
Data protection authority notification requiredNo  
CSIRT notification requiredNo  
Estimated effort (hours)Actual effort (hours)0 
Fast-trackNo
Work packagenone
PrioritynormalSeverityminorReproducibilityhave not tried
Status implementingResolutionopen 
Summary0011931: iOS wallet: no accessibility identifiers, so UI automation cannot address controls stably (patch attached)
DescriptionThe iOS wallet has no accessibility identifiers, so UI automation can only
address controls by element type, accessibility label and on-screen frame.
That makes automated tests fragile in three ways: a reworded label breaks the
test, a localized build cannot be driven at all, and two controls with the same
label on one screen cannot be told apart.

While building a coverage-guided UI exploration harness for the wallet I hit
all three. The attached patch adds stable identifiers to single-purpose leaf
controls and collects them in a new
TalerCommon/Helper/AccessibilityIdentifiers.swift, so they stay greppable and
unique.

Why leaves only
---------------

SwiftUI propagates .accessibilityIdentifier to every accessibility element
inside the view it is applied to, and OVERWRITES identifiers those elements
already carry. Measured on the onboarding screen by dumping the accessibility
tree before and after:

  * identifier on a leaf -> lands on exactly that element;
  * identifier on a container -> propagates to every descendant; in the probe,
    an existing "gear" identifier on the Settings button was replaced by the
    container's.

So annotating containers would produce duplicate identifiers (useless for
addressing) and silently clobber more specific ones. The patch therefore only
touches leaves, and the header comment in the new file records the rule.

What it does not change
-----------------------

Identifiers are never shown or spoken. Measured before/after on the same
screen: the element count is unchanged (75 -> 75), and no accessibility label,
hint or ordering changes. The existing deliberate tree shaping in this
codebase (.accessibilityElement(children: .combine), .accessibilityHint,
.accessibilityHidden) keeps working. Identifiers are placed next to the
existing accessibility modifiers, since SwiftUI is order-sensitive about them.

Scope of the patch
------------------

  * new TalerCommon/Helper/AccessibilityIdentifiers.swift with the A11yID
    constants and the leaves-only rule;
  * 14 single-purpose leaf buttons in TalerCommon/HelperViews/Buttons.swift
    (hamburger, QR, done, plus, more, settings, back, forward, scroll up/down,
    zoom in/out, reload, paste);
  * CopyButton and ShareButton gain an optional a11yID parameter. It is
    defaulted, so no existing call site has to change, but the wire-transfer
    screen now passes a distinct one per field - otherwise every copy button on
    that screen would share one identifier;
  * the leaves of the withdrawal path: onboarding "Get demo cash" and
    "Withdraw <currency>", Terms of Service open and accept, Confirm
    Withdrawal, and the loading/stalled Cancel and Retry buttons.

Verified
--------

Built for the simulator (GNU_Taler, Xcode 27.2, iOS 27.2) and driven through
onboarding -> Withdraw -> Terms of Service -> Accept -> Confirm Withdrawal with
the accessibility tree dumped at every step. The identifiers appear on the
intended controls:

  Accept Terms of Service -> button.withdraw.tos.accept
  Confirm Withdrawal -> button.withdraw.confirm
  Terms of Service -> button.withdraw.tos.open
  Withdraw CHF -> button.onboarding.withdrawReal
  Scan QR code -> button.qr
  Paste -> button.paste

and the identifiers the system or the app already provided (gear, BackButton,
dictation) are preserved.

The patch is against master (cb7e4bab) and applies cleanly. It is additive;
nothing is renamed or removed.

Filed by an agent (bot-cg) on behalf of Christian Grothoff.
Additional InformationPatch attached: a11y-identifiers.patch (117 lines, 10 files, additive, applies to master cb7e4bab).
TagsNo tags attached.
Attached Files
a11y-identifiers.patch (20,658 bytes)   
diff --git a/TalerCommon/Helper/AccessibilityIdentifiers.swift b/TalerCommon/Helper/AccessibilityIdentifiers.swift
new file mode 100644
index 00000000..72876b82
--- /dev/null
+++ b/TalerCommon/Helper/AccessibilityIdentifiers.swift
@@ -0,0 +1,67 @@
+/*
+ * This file is part of GNU Taler, ©2026 Taler Systems S.A.
+ * See LICENSE.md
+ */
+/**
+ * Stable accessibility identifiers for UI automation.
+ *
+ * Identifiers are not shown or spoken anywhere — they exist so that a UI test
+ * can address a control by something that does not change when its label is
+ * reworded or translated.  Keeping them in one place makes them greppable and
+ * keeps them unique.
+ *
+ * Two rules when adding to this list:
+ *
+ * 1. **Put identifiers on leaves, never on containers.**  SwiftUI propagates
+ *    `.accessibilityIdentifier` to every accessibility element inside the view
+ *    it is applied to, *overwriting* identifiers those elements already carry.
+ *    On a container that yields a set of duplicates — useless for addressing —
+ *    and silently clobbers anything more specific underneath.
+ * 2. Applying an identifier does not change what VoiceOver reads, nor the
+ *    shape of the accessibility tree; `.accessibilityLabel`,
+ *    `.accessibilityHint` and `.accessibilityElement(children:)` keep doing
+ *    exactly what they did.  Order still matters, so add the identifier next
+ *    to the other accessibility modifiers rather than before them.
+ */
+import Foundation
+
+public enum A11yID {
+    // MARK: - toolbar and navigation leaves
+    public static let hamburgerButton = "button.hamburger"
+    public static let qrButton        = "button.qr"
+    public static let doneButton      = "button.done"
+    public static let plusButton      = "button.plus"
+    public static let moreButton      = "button.more"
+    public static let settingsButton  = "button.settings"
+    public static let backButton      = "button.back"
+    public static let forwardButton   = "button.forward"
+    public static let arrowUpButton   = "button.arrowUp"
+    public static let arrowDownButton = "button.arrowDown"
+    public static let zoomInButton    = "button.zoomIn"
+    public static let zoomOutButton   = "button.zoomOut"
+    public static let reloadButton    = "button.reload"
+    public static let pasteButton     = "button.paste"
+    public static let linkButton      = "button.link"
+
+    // MARK: - copy / share leaves
+    /// Generic fallback; call sites that matter pass their own, e.g.
+    /// `A11yID.copy("iban")`.
+    public static let copyButton      = "button.copy"
+    public static let shareButton     = "button.share"
+    public static func copy(_ what: String) -> String { "button.copy.\(what)" }
+    public static func share(_ what: String) -> String { "button.share.\(what)" }
+
+    // MARK: - loading / stalled leaves
+    public static let loadingCancelButton = "button.loading.cancel"
+    public static let stalledRetryButton  = "button.stalled.retry"
+
+    // MARK: - empty-wallet (onboarding) leaves
+    public static let demoCashButton      = "button.onboarding.demoCash"
+    public static let withdrawRealButton  = "button.onboarding.withdrawReal"
+
+    // MARK: - withdrawal flow leaves
+    public static let tosOpenButton       = "button.withdraw.tos.open"
+    public static let tosAcceptButton     = "button.withdraw.tos.accept"
+    public static let withdrawConfirmButton = "button.withdraw.confirm"
+    public static let withdrawAcceptButton  = "button.withdraw.accept"
+}
diff --git a/TalerCommon/HelperViews/Buttons.swift b/TalerCommon/HelperViews/Buttons.swift
index 025b7cf7..7c63b049 100755
--- a/TalerCommon/HelperViews/Buttons.swift
+++ b/TalerCommon/HelperViews/Buttons.swift
@@ -29,6 +29,7 @@ struct HamburgerButton : View  {
         }
         .talerFont(.title)
         .accessibilityLabel(Text("Main Menu", comment: "a11y"))
+        .accessibilityIdentifier(A11yID.hamburgerButton)
     }
 }
 
@@ -131,6 +132,7 @@ struct QRButton : View  {
             }
         }
         .accessibilityLabel(scanText)
+        .accessibilityIdentifier(A11yID.qrButton)
         .alert("Scanning QR-codes requires access to the camera",
                isPresented: $showCameraAlert,
                    actions: {   openSettingsButton
@@ -155,6 +157,7 @@ struct DoneButton : View  {
         .tint(WalletColors().primaryAccent)
         .talerFont(.title)
         .accessibilityLabel(accessibilityLabelStr)
+        .accessibilityIdentifier(A11yID.doneButton)
     }
 }
 
@@ -169,6 +172,7 @@ struct PlusButton : View  {
         .tint(WalletColors().primaryAccent)
         .talerFont(.title)
         .accessibilityLabel(accessibilityLabelStr)
+        .accessibilityIdentifier(A11yID.plusButton)
     }
 }
 
@@ -183,6 +187,7 @@ struct MoreButton : View  {
         .tint(WalletColors().primaryAccent)
         .talerFont(.title)
         .accessibilityLabel(accessibilityLabelStr)
+        .accessibilityIdentifier(A11yID.moreButton)
     }
 }
 
@@ -215,6 +220,7 @@ struct SettingsButton : View  {
         .tint(WalletColors().primaryAccent)
         .talerFont(.title)
         .accessibilityLabel(accessibilityLabelStr)
+        .accessibilityIdentifier(A11yID.settingsButton)
 //        if #available(iOS 26.0, *) {
 //            return button.buttonStyle(.glass)
 //        }
@@ -232,6 +238,7 @@ struct TalerPasteButton : View  {
             .tint(WalletColors().primaryAccent)
             .talerFont(.title)
             .accessibilityLabel(accessibilityLabelStr)
+            .accessibilityIdentifier(A11yID.pasteButton)
 //        if #available(iOS 26.0, *) {
 //            return button.buttonStyle(.glass)
 //        }
@@ -250,6 +257,7 @@ struct ZoomInButton : View  {
         .tint(WalletColors().primaryAccent)
         .talerFont(.title)
         .accessibilityLabel(accessibilityLabelStr)
+        .accessibilityIdentifier(A11yID.zoomInButton)
     }
 }
 
@@ -264,6 +272,7 @@ struct ZoomOutButton : View  {
         .tint(WalletColors().primaryAccent)
         .talerFont(.title)
         .accessibilityLabel(accessibilityLabelStr)
+        .accessibilityIdentifier(A11yID.zoomOutButton)
     }
 }
 
@@ -279,6 +288,7 @@ struct BackButton : View  {
         .tint(WalletColors().primaryAccent)
         .talerFont(.largeTitle)
         .accessibilityLabel(Text("Back", comment: "a11y"))
+        .accessibilityIdentifier(A11yID.backButton)
     }
 }
 
@@ -302,6 +312,7 @@ struct ForwardButton : View  {
         .tint(WalletColors().primaryAccent)
         .talerFont(.largeTitle)
         .accessibilityLabel(Text("Continue", comment: "a11y"))
+        .accessibilityIdentifier(A11yID.forwardButton)
     }
 }
 
@@ -315,6 +326,7 @@ struct ArrowUpButton : View  {
         .tint(WalletColors().primaryAccent)
         .talerFont(.title2)
         .accessibilityLabel(Text("Scroll up", comment: "a11y"))
+        .accessibilityIdentifier(A11yID.arrowUpButton)
     }
 }
 
@@ -328,6 +340,7 @@ struct ArrowDownButton : View  {
         .tint(WalletColors().primaryAccent)
         .talerFont(.title2)
         .accessibilityLabel(Text("Scroll down", comment: "a11y"))
+        .accessibilityIdentifier(A11yID.arrowDownButton)
     }
 }
 
@@ -342,6 +355,7 @@ struct ReloadButton : View  {
         .tint(WalletColors().primaryAccent)
         .talerFont(.title)
         .accessibilityLabel(Text("Reload", comment: "a11y"))
+        .accessibilityIdentifier(A11yID.reloadButton)
         .disabled(disabled)
     }
 }
diff --git a/TalerCommon/HelperViews/CopyShare.swift b/TalerCommon/HelperViews/CopyShare.swift
index 826556c4..b115418b 100644
--- a/TalerCommon/HelperViews/CopyShare.swift
+++ b/TalerCommon/HelperViews/CopyShare.swift
@@ -73,6 +73,10 @@ struct CopyButton: View {
     let image: UIImage?
     let vertical: Bool
     let title: String?
+    /// Stable handle for UI tests; see A11yID.  Optional so existing call
+    /// sites keep working, but anything a test needs to address should pass
+    /// one, otherwise every copy button on a screen shares an identifier.
+    let a11yID: String?
 
     @Environment(\.isEnabled) private var isEnabled: Bool
     @EnvironmentObject private var controller: Controller
@@ -80,21 +84,23 @@ struct CopyButton: View {
     @State private var scale: CGFloat = 1.0
 
     init(_ textToCopy: String, isCopied: Binding<Bool?>? = nil,
-             vertical: Bool, image: UIImage? = nil) {
+             vertical: Bool, image: UIImage? = nil, a11yID: String? = nil) {
         self.textToCopy = textToCopy
         self._isCopied = isCopied ?? Binding.constant(nil)
         self.image = image
         self.vertical = vertical
         self.title = nil
+        self.a11yID = a11yID
     }
 
     init(_ textToCopy: String, isCopied: Binding<Bool?>? = nil,
-         title: String, image: UIImage? = nil) {
+         title: String, image: UIImage? = nil, a11yID: String? = nil) {
         self.textToCopy = textToCopy
         self._isCopied = isCopied ?? Binding.constant(nil)
         self.image = image
         self.vertical = false
         self.title = title
+        self.a11yID = a11yID
     }
 
     func copyAction() -> Void {
@@ -157,6 +163,7 @@ struct CopyButton: View {
         .talerFont(.body)
         .scaleEffect(scale)
         .disabled(!isEnabled)
+        .accessibilityIdentifier(a11yID ?? A11yID.copyButton)
     }
 }
 // MARK: -
@@ -178,16 +185,21 @@ struct ShareButton: View {
     let textToShare: String
     let image: UIImage?
     let title: String
+    /// Stable handle for UI tests; see A11yID.
+    let a11yID: String?
 
-    init(_ textToShare: String, image: UIImage? = nil) {
+    init(_ textToShare: String, image: UIImage? = nil, a11yID: String? = nil) {
         self.textToShare = textToShare
         self.image = image
         self.title = String(localized: "Share")
+        self.a11yID = a11yID
     }
-    init(_ textToShare: String, title: String, image: UIImage? = nil) {
+    init(_ textToShare: String, title: String, image: UIImage? = nil,
+         a11yID: String? = nil) {
         self.textToShare = textToShare
         self.image = image
         self.title = title
+        self.a11yID = a11yID
     }
 
     @Environment(\.fromSheet) private var fromSheet
@@ -242,6 +254,7 @@ struct ShareButton: View {
         .talerFont(.body)
         .scaleEffect(scale)
         .disabled(!isEnabled)
+        .accessibilityIdentifier(a11yID ?? A11yID.shareButton)
     }
 }
 // MARK: -
diff --git a/TalerWallet1/Views/Actions/Banking/ManualWithdraw.swift b/TalerWallet1/Views/Actions/Banking/ManualWithdraw.swift
index 861d324c..559c2259 100644
--- a/TalerWallet1/Views/Actions/Banking/ManualWithdraw.swift
+++ b/TalerWallet1/Views/Actions/Banking/ManualWithdraw.swift
@@ -278,6 +278,7 @@ struct ManualWithdrawContent: View {
                         Text(withdrawButtonTitle(currency))
                     }
                     .buttonStyle(TalerButtonStyle(type: .prominent, disabled: disabled))
+                    .accessibilityIdentifier(A11yID.withdrawConfirmButton)
                     .disabled(disabled)
                     .padding(.top)
                 } else {
diff --git a/TalerWallet1/Views/HelperViews/LoadingView.swift b/TalerWallet1/Views/HelperViews/LoadingView.swift
index b0864c85..01fd9859 100644
--- a/TalerWallet1/Views/HelperViews/LoadingView.swift
+++ b/TalerWallet1/Views/HelperViews/LoadingView.swift
@@ -28,6 +28,7 @@ struct StalledView: View {
             }
         }
         .buttonStyle(TalerButtonStyle(type: .bordered, disabled: disabled))
+        .accessibilityIdentifier(A11yID.stalledRetryButton)
         .padding(.horizontal)
     }
 
@@ -135,6 +136,7 @@ struct LoadingView: View {
             }
         }
             .buttonStyle(TalerButtonStyle(type: .bordered, disabled: false))
+            .accessibilityIdentifier(A11yID.loadingCancelButton)
             .padding(.horizontal)
     }
 
diff --git a/TalerWallet1/Views/HelperViews/ToSButtonView.swift b/TalerWallet1/Views/HelperViews/ToSButtonView.swift
index 85ead05b..7f46c5e4 100644
--- a/TalerWallet1/Views/HelperViews/ToSButtonView.swift
+++ b/TalerWallet1/Views/HelperViews/ToSButtonView.swift
@@ -37,6 +37,7 @@ struct ToSButtonView: View {
             Text("Terms of Service")  // VIEW_WITHDRAW_TOS
         }.buttonStyle(TalerButtonStyle(type: .prominent))
             .padding(.horizontal)
+            .accessibilityIdentifier(A11yID.tosOpenButton)
 #if false
         let title = "getPerformanceStats"
         Button(title) {
diff --git a/TalerWallet1/Views/Main/WalletEmptyView.swift b/TalerWallet1/Views/Main/WalletEmptyView.swift
index 5f97830f..cee682a0 100644
--- a/TalerWallet1/Views/Main/WalletEmptyView.swift
+++ b/TalerWallet1/Views/Main/WalletEmptyView.swift
@@ -101,6 +101,7 @@ struct ProdSectionView: View {
                                            aligned: .center))
                 .padding(.top, -6)
                 .padding(.bottom, 6)
+                .accessibilityIdentifier(A11yID.withdrawRealButton)
             } // showDropdown
         } header: {
             if isEmpty {
@@ -255,6 +256,7 @@ struct WalletEmptyView: View {
                 }
                 .buttonStyle(TalerButtonStyle(type: .prominent, narrow: false, disabled: withDrawStarted, aligned: .center))
                 .disabled(withDrawStarted)
+                .accessibilityIdentifier(A11yID.demoCashButton)
 //            } header: {
 //                let secondHeader = String(localized: "Demo", comment: "section header")
 //                Text(secondHeader)
diff --git a/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawAcceptView.swift b/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawAcceptView.swift
index a05dc506..c0cb7a66 100644
--- a/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawAcceptView.swift
+++ b/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawAcceptView.swift
@@ -127,6 +127,7 @@ struct WithdrawAcceptView: View {
                             Text("Confirm Withdrawal")      // SHEET_WITHDRAW_ACCEPT
                         }
                         .buttonStyle(TalerButtonStyle(type: .prominent))
+                        .accessibilityIdentifier(A11yID.withdrawAcceptButton)
                         .padding(.horizontal)
                     }
                 }
diff --git a/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawTOSView.swift b/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawTOSView.swift
index 84b0b4e3..0fb8d162 100644
--- a/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawTOSView.swift
+++ b/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawTOSView.swift
@@ -196,6 +196,7 @@ extension WithdrawTOSView {
                         Button(String(localized: "Accept Terms of Service", comment: "Button"), action: acceptAction)
                             .buttonStyle(TalerButtonStyle(type: .prominent))
                             .accessibility(sortPriority: 2)     // read this second
+                            .accessibilityIdentifier(A11yID.tosAcceptButton)
                             .padding(.horizontal)
                 }
             }
diff --git a/TalerWallet1/Views/Transactions/ManualDetailsWireV.swift b/TalerWallet1/Views/Transactions/ManualDetailsWireV.swift
index b7709d56..fbcf7f17 100644
--- a/TalerWallet1/Views/Transactions/ManualDetailsWireV.swift
+++ b/TalerWallet1/Views/Transactions/ManualDetailsWireV.swift
@@ -112,7 +112,7 @@ struct PayeeZip: View {
             }   .frame(maxWidth: .infinity, alignment: .leading)
                 .accessibilityElement(children: .combine)
                 .accessibilityLabel(Text("Zip code", comment: "a11y"))
-            CopyButton(receiverZip, isCopied: $isCopied, vertical: true)
+            CopyButton(receiverZip, isCopied: $isCopied, vertical: true, a11yID: A11yID.copy("zip"))
                 .accessibilityLabel(Text("Copy the zip code", comment: "a11y"))
                 .disabled(false)
         }   .padding(.top, -8)
@@ -134,7 +134,7 @@ struct PayeeReceiver: View {
             }   .frame(maxWidth: .infinity, alignment: .leading)
                 .accessibilityElement(children: .combine)
                 .accessibilityLabel(Text("Recipient", comment: "a11y"))
-            CopyButton(receiverStr, isCopied: $isCopied, vertical: true)
+            CopyButton(receiverStr, isCopied: $isCopied, vertical: true, a11yID: A11yID.copy("recipient"))
                 .accessibilityLabel(Text("Copy the recipient", comment: "a11y"))
                 .disabled(false)
         }   .padding(.top, -8)
@@ -156,7 +156,7 @@ struct PayeeTown: View {
             }   .frame(maxWidth: .infinity, alignment: .leading)
                 .accessibilityElement(children: .combine)
                 .accessibilityLabel(Text("City", comment: "a11y"))
-            CopyButton(receiverTown, isCopied: $isCopied, vertical: true)
+            CopyButton(receiverTown, isCopied: $isCopied, vertical: true, a11yID: A11yID.copy("town"))
                 .accessibilityLabel(Text("Copy the city", comment: "a11y"))
                 .disabled(false)
         }   .padding(.top, -8)
@@ -177,7 +177,7 @@ struct Cryptocode: View {
                 .accessibilityLabel(isChQRr ? Text("QR reference", comment: "a11y")
                                             : Text("Cryptocode", comment: "a11y"))
                 .frame(maxWidth: .infinity, alignment: .leading)
-            CopyButton(chQRr ?? cryptoString, isCopied: $isCopied, vertical: true)
+            CopyButton(chQRr ?? cryptoString, isCopied: $isCopied, vertical: true, a11yID: A11yID.copy("cryptocode"))
                 .accessibilityLabel(isChQRr ? Text("Copy the QR reference", comment: "a11y")
                                             : Text("Copy the cryptocode", comment: "a11y"))
                 .disabled(false)
@@ -200,7 +200,7 @@ struct IbanCode: View {
             }   .frame(maxWidth: .infinity, alignment: .leading)
                 .accessibilityElement(children: .combine)
                 .accessibilityLabel(Text("IBAN of the recipient", comment: "a11y")) // TODO: BBAN
-            CopyButton(iban, isCopied: $isCopied, vertical: true)
+            CopyButton(iban, isCopied: $isCopied, vertical: true, a11yID: A11yID.copy("iban"))
                 .accessibilityLabel(Text("Copy the IBAN", comment: "a11y"))         // TODO: BBAN
                 .disabled(false)
         } //  .padding(.top, -8)
@@ -224,7 +224,7 @@ struct AmountCode: View {
             }   .frame(maxWidth: .infinity, alignment: .leading)
                 .accessibilityElement(children: .combine)
                 .accessibilityLabel(Text("Amount to transfer", comment: "a11y"))
-            CopyButton(amountValue, isCopied: $isCopied, vertical: true)
+            CopyButton(amountValue, isCopied: $isCopied, vertical: true, a11yID: A11yID.copy("amount"))
             // only digits + separator, no currency name or symbol
                 .accessibilityLabel(Text("Copy the amount", comment: "a11y"))
                 .disabled(false)
@@ -247,7 +247,7 @@ struct XTalerCode: View {
             }   .frame(maxWidth: .infinity, alignment: .leading)
                 .accessibilityElement(children: .combine)
                 .accessibilityLabel(Text("account of the recipient", comment: "a11y"))
-            CopyButton(xTaler, isCopied: $isCopied, vertical: true)
+            CopyButton(xTaler, isCopied: $isCopied, vertical: true, a11yID: A11yID.copy("subject"))
                 .accessibilityLabel(Text("Copy the account", comment: "a11y"))
                 .disabled(false)
         }   .padding(.top, -8)
@@ -400,10 +400,11 @@ struct ManualDetailsWireV: View {
             list.toolbar {
                 ToolbarTitleMenu {
                     if let debitPaytoStr {
-                        CopyButton(paytoStr, isCopied: $isCopied1, title: "Copy validation payto")
-                        CopyButton(debitPaytoStr, isCopied: $isCopied2, title: "Copy deposit payto")
+                        CopyButton(paytoStr, isCopied: $isCopied1, title: "Copy validation payto", a11yID: A11yID.copy("validationPayto"))
+                        CopyButton(debitPaytoStr, isCopied: $isCopied2, title: "Copy deposit payto", a11yID: A11yID.copy("depositPayto"))
                     } else {
-                        CopyButton(paytoStr, isCopied: $isCopied1, title: "Copy transfer payto")
+                        CopyButton(paytoStr, isCopied: $isCopied1, title: "Copy transfer payto",
+                                   a11yID: A11yID.copy("payto"))
                     }
                 }
             }
a11y-identifiers.patch (20,658 bytes)   

Required notifications

Address or linkRemarkAdded byNotifiedActions

Activities

MarcS

2026-11-04 06:01

developer   ~0030222

lgtm

bot-cg [agent]

2026-11-04 11:01

updater   ~0030241

Patch verified by A/B on the running app. The previous note only said the
findings were gone after merging it, which is not the same thing.

Method: the identifiers are merged into the exploration harness branch, so the
baseline is that branch with both identifier commits reverted
(dev/phoenix-bot/a11y-baseline) rather than a plain checkout of it. Same tape,
same walk, 20 steps, 22 recorded screens on each side.

    build screens control elements carrying an identifier
    baseline 22 127 13
    with patch 22 127 50

The 13 on the baseline are incidental -- the only stable-looking one is a URL
string that happens to sit on a link. With the patch, seven controls on the
withdrawal path are addressable by a name that does not change with the
wording or the language:

    button.onboarding.demoCash button.onboarding.withdrawReal
    button.withdraw.tos.open button.withdraw.tos.accept
    button.withdraw.confirm button.copy button.share

The walk now drives the app through those names: its recorded actions read
`tap button:button.withdraw.tos.accept`, `tap button:button.withdraw.confirm`
and so on, where before it had to match on visible labels and so broke in any
locale but English.

Two things the same comparison settles, both of which were open questions when
this was filed:

  * **Nothing about the UI changed.** The two runs record the same number of
    screens and exactly the same 127 control elements; only the identifier
    field differs. That was the original concern about adding identifiers at
    all.

  * **They are on leaves, as intended.** Across every recorded screen, no
    identifier is carried by more than one element except `button.share`,
    where two distinct share buttons are on screen at once, and in zero cases
    is an identifier inherited by a descendant. All 37 elements carrying one
    are of type `button`. That was the specific risk: an identifier applied
    to a container propagates to its descendants and overwrites theirs.

To re-run:

    git checkout dev/phoenix-bot/a11y-baseline && python3 -m taler_fuzz build
    python3 -m taler_fuzz --max-steps 20 \
        --prefer "withdraw,confirm,accept,terms,balance" \
        replay --tape "3,1,0,2,1,0,1,2,0,1,3,0,2,1,0,1,0,2,1,0,1" \
        --label a11y-before
    # then the same on dev/phoenix-bot/fuzzbang as a11y-after, and count
    # elements with a non-empty identifier in out/replay-a11y-*/screens.jsonl

Added by an agent (bot-cg) on behalf of Christian Grothoff.

bot-cg [agent]

2026-11-04 13:16

updater   ~0030248

Repeated across three different walks, since the previous note rested on one
tape and a single walk could have reached an unrepresentative set of screens.

Three tapes per arm, same preference list, 20 steps each:

    arm screens controls with an identifier
    baseline (patch reverted) 59 281 42 (14.9%)
    with the patch 59 264 102 (38.6%)

The seven stable names appear on every arm that has the patch and on none of
the baseline walks:

    button.copy button.share
    button.onboarding.demoCash button.onboarding.withdrawReal
    button.withdraw.tos.open button.withdraw.tos.accept
    button.withdraw.confirm

The baseline's 42 are incidental -- URLs and system-supplied labels, none of
them a name the harness can steer by, which is why the "distinct harness
names" column is zero there.

One difference worth not glossing over: the control counts are 281 and 264
rather than identical. The walks are not step-for-step identical across arms
-- a tape resolves against whatever is on screen, and the two builds take
slightly different paths once a run diverges. The single-walk comparison in
the previous note, where both arms recorded exactly 127 controls over 22
screens, is the tighter evidence that the UI is unchanged; this one is the
broader evidence that the identifiers are present wherever the walk goes.
Neither on its own would be enough.

Added by an agent (bot-cg) on behalf of Christian Grothoff.

bot-cg [agent]

2026-11-04 13:18

updater   ~0030250

Correction to this issue's summary line, recorded as a note because the agent
API has no way to edit a summary -- the only PATCH it offers is on notes.

The house convention for iOS wallet reports is that the summary leads with the
screen title. Mine does not, and should read:

    All screens: no accessibility identifiers, so UI automation cannot
    address controls stably

"All screens" rather than a single title because this one genuinely is
app-wide: the recorded trees show 127 controls across 22 screens, 13 of which
carried an incidental identifier and none a stable one. The other reports in
this series do carry a screen title -- "Terms of Service / Balances",
"Withdraw / Loading", "Loading / stalled connection", "Withdraw / amount
entry" -- so this is the one that slipped, not the convention.

If a maintainer with edit rights would rather change the summary itself, the
line above is the intended text. Nothing else about the report changes.

Added by an agent (bot-cg) on behalf of Christian Grothoff.

Issue History

Date ModifiedUsernameFieldChange
2026-11-03 21:44bot-cgNew Issue
2026-11-03 21:44bot-cgFile Added: a11y-identifiers.patch
2026-11-04 06:01MarcSAssigned To => MarcS
2026-11-04 06:01MarcSImplementer => MarcS
2026-11-04 06:01MarcSStatusnew => implementing
2026-11-04 06:01MarcSNote Added: 0030222
2026-11-04 11:01bot-cgNote Added: 0030241
2026-11-04 13:16bot-cgNote Added: 0030248
2026-11-04 13:18bot-cgNote Added: 0030250