View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0011938 | Taler | wallet (iOS App) | public | 2026-11-04 09:04 | 2026-11-04 12:59 |
| Reporter | bot-cg [agent] | Assigned To | Resolution deadline | ||
| Implementer | MarcS | Reviewer | Christian Grothoff | ||
| Release signed by | MarcS | Deployed by | MarcS | ||
| Verifier | MarcS | Communicator | |||
| 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 | minor | Reproducibility | have not tried |
| Status | planning | Resolution | open | ||
| Summary | 0011938: Withdraw / amount entry: amounts render as "0,00 XXX" when the locale has no region, while the title shows the right currency | ||||
| Description | Withdraw / Amount entry: every amount on the screen reads "XXX" instead of the currency, when the app runs in a locale that has no region. What is shown ------------- In `de` (a bare language, no region), the withdrawal amount screen renders: Fr. abheben <- navigation title, knows the currency Von der Bank abzuhebender Betrag: 0,00 XXX <- the amount field 5,00 XXX 10,00 XXX 25,00 XXX 50,00 XXX <- the shortcuts Keine Gebühr Attached: xxx-currency-de.png. The title and the amounts disagree on the same screen, which is what makes it obvious rather than merely wrong. XXX is ISO 4217's code for "no currency". Foundation substitutes it when a NumberFormatter's locale carries no region to take a currency from, and the wallet then fails to replace it. Why the replacement does not happen ----------------------------------- TalerCommon/Helper/CurrencySpecification.swift, currencyString(_:useISO:) tries three sources in order and gives up: if !useISO { if let aSymbol = altUnitSymbol { ... } // nil here if let aName = altUnitName0 { ... } // nil here } let currency = formatter.currency if !currency.isEmpty { ... } // empty here return aString // <- XXX leaves here All three are empty or nil precisely because the locale has no region, so the formatted string is returned untouched, placeholder and all. Deliberately no patch --------------------- I wrote one and it did not work, so I am not attaching it. It extended the helper that performs the replacement to also match the "XXX" placeholder -- but that helper is never called on this path, because currencyString returns before reaching it. Measured: with and without the change the screen still reads "Kurzwahl 5,00 XXX". The attempt and its withdrawal are recorded on branch dev/phoenix-bot/fix-xxx-currency so nobody repeats it. The real fix has to decide what that last branch should display when Foundation offers the formatter no currency at all. The wallet does know -- the Amount being rendered carries its own currency, and the navigation title is built from it -- so the information is available; it is a question of which of these layers should supply it. That is a decision for whoever owns this file. How to reproduce ---------------- The harness is on branch dev/phoenix-bot/fuzzbang, under harness/: python3 -m taler_fuzz build python3 -m taler_fuzz --locale de --max-steps 8 \ --prefer "withdraw,accept,terms,balance,abheben" \ replay --tape "1,0,1,0,2,1,0,1,0" --shots --label xxx-check and the shortcut labels are in the recorded tree: python3 -c 'import json; [print([e["label"] for e in json.loads(l).get("elements",[]) if "Kurzwahl" in (e.get("label") or "")]) for l in open("$work_dir/out/replay-xxx-check/screens.jsonl") if l.strip()]' By hand: run the app with the scheme option Application Language = German (not "German (Germany)"), tap Withdraw, accept the terms, and look at the amount shortcuts. With `de_DE` instead of `de` the currency renders correctly, so a region is all that separates the two. Related ------- Found while verifying the fix for issue 11933, which is the crash on this same screen in this same locale. Until that fix lands the app terminates before this screen can be seen, so this is only reachable with 11933 applied. It is a separate defect with a separate cause: 11933 was an unguarded nil, this is a fall-through that returns an unsubstituted string. Estimated severity ------------------ Minor - every amount in the app is unreadable as money in such a locale, but only in locales set without a region, which is mostly developers and UI tests rather than users from the App Store. Estimated effort to fix ----------------------- Small, once the question above is answered: one branch in one function. The decision of which layer supplies the currency is the work; the edit is not. Found by automated UI exploration. Filed by an agent (bot-cg) on behalf of Christian Grothoff. Left unassigned deliberately. | ||||
| Tags | No tags attached. | ||||
| Attached Files | fix-currency-no-region.patch (4,370 bytes)
From 814618c30329e64b43ee4a98576e06ef1eed67c2 Mon Sep 17 00:00:00 2001
From: Christian Grothoff <christian@grothoff.org>
Date: Wed, 4 Nov 2026 10:18:57 +0100
Subject: [PATCH] AI: give the formatter the currency the locale has not got
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
With a locale that carries no region -- `de` rather than `de_DE` --
NumberFormatter has no currency to format with, so Foundation renders ISO
4217's XXX, its code for "no currency", and every amount in the app reads
"5,00 XXX" while the navigation title above it correctly says "Fr. abheben".
Nothing downstream can repair that. currencyString() rewrites whichever
markers the formatter reports, and in that locale currencySymbol is the
generic placeholder "¤", which does not occur in the formatted string at all,
while currencyCode is nil. An earlier attempt to special-case the literal
"XXX" there was measured and did nothing, for exactly this reason.
The currency belongs to the amount rather than to the locale, and the factory
already has it, so hand it to the formatter. currencySymbol and currencyCode
then carry real markers and the existing replacement works as it does for a
locale with a region. A locale that does supply a currency keeps it.
The guard in replacingCurrencyMarkers is required by the above: once both
markers read "CHF", rewriting the same marker twice inserted the
replacement's leading space twice and produced "5,00 CHF".
Verified by A/B on the simulator, same tape, 10 screens each:
without, --locale de 5 screens contain XXX; "Kurzwahl 5,00 XXX"
with, --locale de 0 screens contain XXX; "Kurzwahl 5,00 CHF"
with, --locale de_DE 0; identical to de_DE before the patch
Assisted by LLM
---
.../Helper/CurrencySpecification.swift | 20 ++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/TalerCommon/Helper/CurrencySpecification.swift b/TalerCommon/Helper/CurrencySpecification.swift
index 5ea2b6ac..30ad151d 100644
--- a/TalerCommon/Helper/CurrencySpecification.swift
+++ b/TalerCommon/Helper/CurrencySpecification.swift
@@ -197,7 +197,11 @@ public struct CurrencyInfo: Sendable {
if let symbol = formatter.currencySymbol, !symbol.isEmpty {
result = result.replacingOccurrences(of: symbol, with: replacement)
}
- if let code = formatter.currencyCode, !code.isEmpty {
+ // Only if it is a different marker: for a currency the locale knows nothing
+ // about, ICU reports the code as the symbol too, and rewriting the same marker
+ // a second time inserts the replacement's leading space twice ("5,00 CHF").
+ if let code = formatter.currencyCode, !code.isEmpty,
+ code != formatter.currencySymbol {
result = result.replacingOccurrences(of: code, with: replacement)
}
return result
@@ -371,6 +375,20 @@ public class CurrencyFormatter: NumberFormatter, @unchecked Sendable {
}
formatter.name = specs.name
formatter.currency = currency
+ // `NumberFormatter´ takes the currency it formats with from its locale, and a
+ // locale with no region -- `de´ rather than `de_DE´ -- has none, so Foundation
+ // falls back to ISO 4217's code for "no currency" and every amount reads
+ // "5,00 XXX". Nothing downstream can repair that: `currencyString´ rewrites
+ // whichever markers the formatter reports, and `currencySymbol´ is then the
+ // generic "¤", which does not occur in the formatted string at all.
+ // The currency belongs to the amount, not to the locale, and it is right here --
+ // so hand it to the formatter. `currencySymbol´ and `currencyCode´ then carry
+ // real markers, which `currencyString´ replaces with the alternative unit
+ // symbol, name or ISO code, exactly as it does for a locale that has a region.
+ // A locale which does supply a currency keeps it.
+ if (formatter.currencyCode as String?)?.isEmpty ?? true, !currency.isEmpty {
+ formatter.currencyCode = currency
+ }
// formatter.setCode(to: EUR_4217)
// formatter.setSymbol(to: "€")
// clamp: specs comes from the exchange and is untrusted; an out-of-range or negative
--
2.47.3
| ||||
| Patch attached and verified, and one thing in the report above is wrong -- correcting it here rather than leaving it to mislead. Correction to the diagnosis --------------------------- The report says "all three are empty or nil" and that the formatted string is returned untouched from the final `return aString`. The second half is right; the first is not. Checked directly against Foundation on the simulator: locale "de" currencySymbol = "¤" currencyCode = nil string(for: 5.0) = "5,00 XXX" locale "de_DE" currencySymbol = "€" currencyCode = "EUR" So `currencySymbol` is *not* nil: it is ICU's generic currency placeholder "¤", which simply never occurs in the formatted string, so replacing it achieves nothing. That is also the real reason the earlier attempt failed -- not that the helper was unreachable, as I wrote, but that it ran and matched nothing. The cause, stated properly -------------------------- `NumberFormatter` takes the currency it formats with from its *locale*. A locale with no region has none, so Foundation falls back to ISO 4217's XXX. No amount of rewriting downstream can fix that, because the formatter never knew what the currency was. The fix ------- Attached: fix-currency-no-region.patch (one file, +19/-1), against master; also branch dev/phoenix-bot/fix-currency-no-region. The currency belongs to the amount, not to the locale, and `CurrencyFormatter`'s factory already receives it -- so hand it to the formatter when the locale did not supply one: if (formatter.currencyCode as String?)?.isEmpty ?? true, !currency.isEmpty { formatter.currencyCode = currency } `currencySymbol` and `currencyCode` then carry real markers and the existing replacement in `currencyString` works exactly as it does for a locale with a region. A locale that does supply a currency keeps it. The second hunk is required by the first: once both markers read "CHF", `replacingCurrencyMarkers` rewrote the same marker twice and inserted the replacement's leading space twice, giving "5,00 CHF". It now skips the code when it is identical to the symbol. Verification ------------ A/B on the simulator, same tape, same walk, 10 recorded screens each: without the patch, --locale de 5 screens contain XXX "Kurzwahl 5,00 XXX" (and 10/25/50) with the patch, --locale de 0 screens contain XXX "Kurzwahl 5,00 CHF" (and 10/25/50) with the patch, --locale de_DE 0 screens contain XXX; renders identically to de_DE before the patch The third row is the regression check: the normal case, where the locale does carry a region, is unaffected. Attached after-currency-fixed-de.png is the patched build in `de`: the amount field and all four shortcuts now read "Fr." where they read "XXX", and the navigation title is unchanged. The screenshot in the report above is the same screen before the patch. To re-run, on branch dev/phoenix-bot/fuzzbang under harness/ (dev/phoenix-bot/verify-currency is that harness with this patch applied): python3 -m taler_fuzz build python3 -m taler_fuzz --locale de --max-steps 8 \ --prefer "withdraw,accept,terms,balance,abheben" \ replay --tape "1,0,1,0,2,1,0,1,0" --shots --label cur-check python3 -c 'import json; [print([e["label"] for e in json.loads(l).get("elements",[]) if "Kurzwahl" in (e.get("label") or "")]) for l in open("$work_dir/out/replay-cur-check/screens.jsonl") if l.strip()]' Added by an agent (bot-cg) on behalf of Christian Grothoff. | |
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-11-04 09:04 | bot-cg | New Issue | |
| 2026-11-04 09:04 | bot-cg | File Added: xxx-currency-de.png | |
| 2026-11-04 09:45 | bot-cg | File Added: fix-currency-no-region.patch | |
| 2026-11-04 09:45 | bot-cg | File Added: after-currency-fixed-de.png | |
| 2026-11-04 09:45 | bot-cg | Note Added: 0030232 | |
| 2026-11-04 10:02 | Christian Grothoff | Implementer | => MarcS |
| 2026-11-04 10:02 | Christian Grothoff | Reviewer | => Christian Grothoff |
| 2026-11-04 10:02 | Christian Grothoff | Release signed by | => MarcS |
| 2026-11-04 10:02 | Christian Grothoff | Deployed by | => MarcS |
| 2026-11-04 10:02 | Christian Grothoff | Verifier | => MarcS |
| 2026-11-04 10:02 | Christian Grothoff | Communicator | CM_7 => |
| 2026-11-04 10:02 | Christian Grothoff | Description Updated | |
| 2026-11-04 12:59 | Christian Grothoff | Status | new => planning |