Localise every Android notification string, and let them follow a language change (#867) - #902
Merged
Merged
Conversation
…guage change (#867) Five notifier files built their text from raw Kotlin literals, so every Android notification shipped English in all six locales: the foreground-service notification that is on screen the whole time a strap is connected, the three older battery alerts from #867, and the illness, inactivity and smart-alarm alerts. Both notification CHANNELS were hardcoded too, and those are visible in system Settings. Twenty-two strings, all six locales. Most translations are the Apple catalog's, moved across unchanged — those strings already existed there, several of them translated in #866. Seven had no Apple counterpart and are new: the four foreground-service phrases, the smart-alarm body and the two channel descriptions. They follow the register the catalog already uses (informal de / es / pt-PT, formal fr) and are the part of this change most worth a native reader's eye. Two locale-staleness bugs come with it, because localising alone would have introduced the first and left the second: - postNotification dedupes on connected|backfilling|recovery|battery. None of those changes when the user switches language, and a Service is not re-posted on a configuration change, so the notification would have kept the previous language until the connection or battery state happened to move — hours, on a stable link with a charged strap. The locale tag is now part of the key. - Both ensureChannel implementations returned early when the channel already existed, so a channel's name and description were fixed in the install-time language forever. createNotificationChannel is idempotent and updates them, so the early return is gone. In the service that path runs once per start; in BatteryAlertNotifier every caller is behind the persisted once-per-crossing gates and #886's (SoC, charging) key, so it runs when an alert is actually posted, not on the ~1 Hz tick. i18n_audit.py could not see any of this. Its Android scan roots are ui/ and widget/ only, and its patterns are Compose-shaped (Text(, title=/text= kwargs), which a NotificationCompat builder chain matches none of. Adding com/noop/ble and com/noop/notif to the roots alone still found nothing — verified, not assumed — so the call pattern now also matches setContentTitle/setContentText. That extension is what surfaced the illness, inactivity and smart-alarm strings, which were not in #867 and which nobody had noticed. Two tests pin it: one that the builder chain's literals are found, one that the getString form is not flagged, so the pattern is not reverted the first time it looks like noise. Verified: all six strings.xml parse and each gained the same 22 keys, with no duplicate keys, no unescaped apostrophes and no string carrying more than one non-positional %; all 26 R.string references across the five files resolve in the default catalogue and in all five translations; and every formatted string's specifiers are identical in all six locales, which is what would otherwise throw IllegalFormatConversionException for one language only. Tools/i18n_audit.py --ci, its 36 unit tests, and doc_comment_lint pass, and the audit now reports zero hardcoded literals under ble/ and notif/. Android is compiled by no CI job and cannot be built on this host (aapt2 is x86-64 only), so the resource and R references were checked statically rather than by a build — worth a compile before merge.
ryanbr
force-pushed
the
fix/android-notification-strings-l10n
branch
from
July 27, 2026 21:11
6d12ff9 to
41bdf16
Compare
This was referenced Jul 27, 2026
Open
ryanbr
added a commit
that referenced
this pull request
Jul 28, 2026
@NoBrainNoHeadache — a native French speaker, first contribution — reviewed the Apple catalogue's French and attached a corrected file to #884. Applied here as a cherry-pick of exactly the 31 changed fr entries onto current main, not as the wholesale file: their base predated #890 (4 keys would have been deleted) and Xcode's re-extraction had added 209 keys the catalogue deliberately does not carry. What the corrections fix is not polish, it is machine-translation output that was wrong to the point of nonsense: RESTORATIVE SLEEP "POULET RESTORATOIRE" (restorative CHICKEN) one-glance read "lecture d'un verre" (reading of a drinking GLASS) Deep sleep pools HRV "piscines de sommeil" (SWIMMING POOLS of sleep) HOURS OF SLEEP "HORAIRES DE DOUCEE" (not a French word) RECOVERY "RECOUVREMENT" (debt collection — the same error #902 found in Android's catalogue exists here too) Plus consistent terminology: HRV is now VFC (variabilité de la fréquence cardiaque) everywhere instead of the invented VHR/VRH, RHR is FCR, and the strap is "bracelet" rather than "bande"/"sangle". The one change verified against code rather than language: the bare "W" and "M" keys. Every localized use is `case .week` / `case .month` in seven range pickers, so their W→"S" (Semaine) / M→"M" (Mois) is right — and the OLD French had M→"L", someone's guess at Monday→Lundi, so French users saw an L on a Month toggle. A translation fix that is also a UI bug fix. Two integration repairs, both mechanical: - One entry arrived as "%6$lldcalculée": a positional %6$ against an English string with a single argument (String(format:) would reach for a sixth that does not exist — French-only runtime breakage, most likely Xcode's editor numbering around the %% escapes), and a missing space. Now "%lld calculée". - All 31 arrived state=needs_review, which Tools/i18n_audit.py counts as untranslated — a straight merge would have hard-failed the fr focus-locale gate. Flipped to translated after reading all 31 as a French reader. Format specifiers verified preserved on the other 30; serialization confirmed byte-stable (json round-trip) before writing; diff is 31 value lines and nothing else. i18n --ci passes.
ryanbr
added a commit
that referenced
this pull request
Jul 28, 2026
@NoBrainNoHeadache — a native French speaker, first contribution — reviewed the Apple catalogue's French and attached a corrected file to #884. Applied here as a cherry-pick of exactly the 31 changed fr entries onto current main, not as the wholesale file: their base predated #890 (4 keys would have been deleted) and Xcode's re-extraction had added 209 keys the catalogue deliberately does not carry. What the corrections fix is not polish, it is machine-translation output that was wrong to the point of nonsense: RESTORATIVE SLEEP "POULET RESTORATOIRE" (restorative CHICKEN) one-glance read "lecture d'un verre" (reading of a drinking GLASS) Deep sleep pools HRV "piscines de sommeil" (SWIMMING POOLS of sleep) HOURS OF SLEEP "HORAIRES DE DOUCEE" (not a French word) RECOVERY "RECOUVREMENT" (debt collection — the same error #902 found in Android's catalogue exists here too) Plus consistent terminology: HRV is now VFC (variabilité de la fréquence cardiaque) everywhere instead of the invented VHR/VRH, RHR is FCR, and the strap is "bracelet" rather than "bande"/"sangle". The one change verified against code rather than language: the bare "W" and "M" keys. Every localized use is `case .week` / `case .month` in seven range pickers, so their W→"S" (Semaine) / M→"M" (Mois) is right — and the OLD French had M→"L", someone's guess at Monday→Lundi, so French users saw an L on a Month toggle. A translation fix that is also a UI bug fix. Two integration repairs, both mechanical: - One entry arrived as "%6$lldcalculée": a positional %6$ against an English string with a single argument (String(format:) would reach for a sixth that does not exist — French-only runtime breakage, most likely Xcode's editor numbering around the %% escapes), and a missing space. Now "%lld calculée". - All 31 arrived state=needs_review, which Tools/i18n_audit.py counts as untranslated — a straight merge would have hard-failed the fr focus-locale gate. Flipped to translated after reading all 31 as a French reader. Format specifiers verified preserved on the other 30; serialization confirmed byte-stable (json round-trip) before writing; diff is 31 value lines and nothing else. i18n --ci passes.
ryanbr
added a commit
that referenced
this pull request
Jul 28, 2026
@NoBrainNoHeadache — a native French speaker, first contribution — reviewed the Apple catalogue's French and attached a corrected file to #884. Applied here as a cherry-pick of exactly the 31 changed fr entries onto current main, not as the wholesale file: their base predated #890 (4 keys would have been deleted) and Xcode's re-extraction had added 209 keys the catalogue deliberately does not carry. What the corrections fix is not polish, it is machine-translation output that was wrong to the point of nonsense: RESTORATIVE SLEEP "POULET RESTORATOIRE" (restorative CHICKEN) one-glance read "lecture d'un verre" (reading of a drinking GLASS) Deep sleep pools HRV "piscines de sommeil" (SWIMMING POOLS of sleep) HOURS OF SLEEP "HORAIRES DE DOUCEE" (not a French word) RECOVERY "RECOUVREMENT" (debt collection — the same error #902 found in Android's catalogue exists here too) Plus consistent terminology: HRV is now VFC (variabilité de la fréquence cardiaque) everywhere instead of the invented VHR/VRH, RHR is FCR, and the strap is "bracelet" rather than "bande"/"sangle". The one change verified against code rather than language: the bare "W" and "M" keys. Every localized use is `case .week` / `case .month` in seven range pickers, so their W→"S" (Semaine) / M→"M" (Mois) is right — and the OLD French had M→"L", someone's guess at Monday→Lundi, so French users saw an L on a Month toggle. A translation fix that is also a UI bug fix. Two integration repairs, both mechanical: - One entry arrived as "%6$lldcalculée": a positional %6$ against an English string with a single argument (String(format:) would reach for a sixth that does not exist — French-only runtime breakage, most likely Xcode's editor numbering around the %% escapes), and a missing space. Now "%lld calculée". - All 31 arrived state=needs_review, which Tools/i18n_audit.py counts as untranslated — a straight merge would have hard-failed the fr focus-locale gate. Flipped to translated after reading all 31 as a French reader. Format specifiers verified preserved on the other 30; serialization confirmed byte-stable (json round-trip) before writing; diff is 31 value lines and nothing else. i18n --ci passes.
ryanbr
added a commit
that referenced
this pull request
Jul 28, 2026
) @NoBrainNoHeadache — a native French speaker, first contribution — reviewed the Apple catalogue's French and attached a corrected file to #884. Applied here as a cherry-pick of exactly the 31 changed fr entries onto current main, not as the wholesale file: their base predated #890 (4 keys would have been deleted) and Xcode's re-extraction had added 209 keys the catalogue deliberately does not carry. What the corrections fix is not polish, it is machine-translation output that was wrong to the point of nonsense: RESTORATIVE SLEEP "POULET RESTORATOIRE" (restorative CHICKEN) one-glance read "lecture d'un verre" (reading of a drinking GLASS) Deep sleep pools HRV "piscines de sommeil" (SWIMMING POOLS of sleep) HOURS OF SLEEP "HORAIRES DE DOUCEE" (not a French word) RECOVERY "RECOUVREMENT" (debt collection — the same error #902 found in Android's catalogue exists here too) Plus consistent terminology: HRV is now VFC (variabilité de la fréquence cardiaque) everywhere instead of the invented VHR/VRH, RHR is FCR, and the strap is "bracelet" rather than "bande"/"sangle". The one change verified against code rather than language: the bare "W" and "M" keys. Every localized use is `case .week` / `case .month` in seven range pickers, so their W→"S" (Semaine) / M→"M" (Mois) is right — and the OLD French had M→"L", someone's guess at Monday→Lundi, so French users saw an L on a Month toggle. A translation fix that is also a UI bug fix. Two integration repairs, both mechanical: - One entry arrived as "%6$lldcalculée": a positional %6$ against an English string with a single argument (String(format:) would reach for a sixth that does not exist — French-only runtime breakage, most likely Xcode's editor numbering around the %% escapes), and a missing space. Now "%lld calculée". - All 31 arrived state=needs_review, which Tools/i18n_audit.py counts as untranslated — a straight merge would have hard-failed the fr focus-locale gate. Flipped to translated after reading all 31 as a French reader. Format specifiers verified preserved on the other 30; serialization confirmed byte-stable (json round-trip) before writing; diff is 31 value lines and nothing else. i18n --ci passes.
ryanbr
added a commit
that referenced
this pull request
Jul 28, 2026
…884) The Android half of the native-speaker review that landed on Apple in #919. 87 strings in values-fr rewritten; one Apple es string repaired; #920 filed for the 209 translations blocked on extraction — which together close #884. Terminology, matching the contributor's Apple standard: sangle (a harness strap, 70 strings) and bande (8) become bracelet; VHR/VRH become VFC; RECOUVREMENT becomes RÉCUPÉRATION; RHR becomes FCR; the two streaming strings align on Transmission (including fgs_detail_streaming from #902 — my own string, written before the standard existed). This was hand-agreement work, not a sed, in both directions: sangle→bracelet flips feminine to masculine ("La sangle … elle ne captera pas" → "Le bracelet … il ne captera pas", "sangle active"→"bracelet actif", "chargée"→"chargé"), and VHR→VFC flips masculine to feminine ("du VHR"→"de la VFC"). One legitimate "bande" survives, deliberately: "la bande haute" is an HR zone, not a strap — the one string the pattern matched where the old word was right. The sweep also surfaced MT damage beyond terminology in the same 87 strings, fixed in place rather than left half-corrected: "Diffusion des ressources humaines" broadcast HUMAN RESOURCES, for HR (x2) "Il fait feu" the alarm OPENS FIRE, for "it fires" "briquet sur la batterie" a CIGARETTE LIGHTER, for "lighter on battery" "des bûches de sangles" FIREWOOD, for strap logs "Les partitions en direct" SHEET MUSIC, for live scores "données bancaires" BANKING data, for banked data "Lentise le sangle de fond" not French at all "piscines de sommeil profonde" the same swimming-pools string Apple had plus the informal tu forms ("ta sangle", "Lis") brought to the catalogue's vous, "l'histoire"→"l'historique" (the contributor's own #919 fix, same class), and the 5/MG deep-data toggle paragraph rewritten from word salad into French. The Apple es Charge explainer gets its four flat errors fixed ("frecuencia cardíaca restante" = REMAINING heart rate, "de vez en cuando" = from time to time, "es escenario del movimiento", "corrientes crudas" = raw currents); terminology choices there are left for a native es reader, as recorded on #884. Verified: XML parses; zero leftovers of any swept term; format specifiers byte-identical to the default catalogue on every changed string; no unescaped apostrophes; i18n --ci and doc lint pass. Android CI compiles the resources.
ryanbr
added a commit
that referenced
this pull request
Jul 28, 2026
…884) (#921) The Android half of the native-speaker review that landed on Apple in #919. 87 strings in values-fr rewritten; one Apple es string repaired; #920 filed for the 209 translations blocked on extraction — which together close #884. Terminology, matching the contributor's Apple standard: sangle (a harness strap, 70 strings) and bande (8) become bracelet; VHR/VRH become VFC; RECOUVREMENT becomes RÉCUPÉRATION; RHR becomes FCR; the two streaming strings align on Transmission (including fgs_detail_streaming from #902 — my own string, written before the standard existed). This was hand-agreement work, not a sed, in both directions: sangle→bracelet flips feminine to masculine ("La sangle … elle ne captera pas" → "Le bracelet … il ne captera pas", "sangle active"→"bracelet actif", "chargée"→"chargé"), and VHR→VFC flips masculine to feminine ("du VHR"→"de la VFC"). One legitimate "bande" survives, deliberately: "la bande haute" is an HR zone, not a strap — the one string the pattern matched where the old word was right. The sweep also surfaced MT damage beyond terminology in the same 87 strings, fixed in place rather than left half-corrected: "Diffusion des ressources humaines" broadcast HUMAN RESOURCES, for HR (x2) "Il fait feu" the alarm OPENS FIRE, for "it fires" "briquet sur la batterie" a CIGARETTE LIGHTER, for "lighter on battery" "des bûches de sangles" FIREWOOD, for strap logs "Les partitions en direct" SHEET MUSIC, for live scores "données bancaires" BANKING data, for banked data "Lentise le sangle de fond" not French at all "piscines de sommeil profonde" the same swimming-pools string Apple had plus the informal tu forms ("ta sangle", "Lis") brought to the catalogue's vous, "l'histoire"→"l'historique" (the contributor's own #919 fix, same class), and the 5/MG deep-data toggle paragraph rewritten from word salad into French. The Apple es Charge explainer gets its four flat errors fixed ("frecuencia cardíaca restante" = REMAINING heart rate, "de vez en cuando" = from time to time, "es escenario del movimiento", "corrientes crudas" = raw currents); terminology choices there are left for a native es reader, as recorded on #884. Verified: XML parses; zero leftovers of any swept term; format specifiers byte-identical to the default catalogue on every changed string; no unescaped apostrophes; i18n --ci and doc lint pass. Android CI compiles the resources.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #867, and covers the rest of the same defect.
Five notifier files built their text from raw Kotlin literals, so every Android notification shipped English in all six locales — including the foreground-service notification that is on screen the entire time a strap is connected. Both notification channels were hardcoded too, and those are visible in system Settings.
22 strings, six locales.
Where the translations came from
Most are the Apple catalog's, moved across unchanged — those strings already existed there, several translated in #866. Seven are new: the four foreground-service phrases, the smart-alarm body, and the two channel descriptions. They follow the register the catalog already uses (informal de/es/pt-PT, formal fr) and are the part of this PR most worth a native reader's eye.
Worth noting the existing Android catalogue is not internally consistent —
RECOUVREMENTfor Recovery in fr (that is debt collection), de mixingErholung/Genesungand formal/informal. I anchored on the Apple catalogue instead, which is the better-maintained of the two. Not fixing the pre-existing drift here.Two locale-staleness bugs, because localising alone was not enough
Localising the strings would have introduced the first of these and left the second:
postNotificationdedupes onconnected|backfilling|recovery|battery. None of those changes when the user switches language, and aServiceis not re-posted on a configuration change — so the notification would have kept the previous language until the connection or battery state happened to move. On a stable link with a charged strap, that is hours. The locale tag is now part of the key.ensureChannels returned early when the channel already existed, so a channel's name and description were fixed in the install-time language forever.createNotificationChannelis idempotent and updates them, so the early return is gone. In the service that path runs once per start; inBatteryAlertNotifierevery caller sits behind the persisted once-per-crossing gates and Battery drain: diagnose the reconnect loop, and stop re-asking the battery policies every second #886's(SoC, charging)key, so it runs when an alert is actually posted — not on the ~1 Hz live-state tick.Why the i18n gate never caught it
i18n_audit.py's Android scan roots areui/andwidget/only, and its patterns are Compose-shaped (Text(,title=/text=kwargs) — which aNotificationCompatbuilder chain matches none of.Adding
com/noop/bleandcom/noop/notifto the roots alone still found nothing; I verified that rather than assuming it. So the call pattern now also matchessetContentTitle/setContentText.That extension is what surfaced the illness, inactivity and smart-alarm strings — four more literals that were not in #867 and that nobody had noticed. The audit now reports zero hardcoded literals under
ble/andnotif/.Two tests pin the extension: one that a builder chain's literals are found, one that the
getStringform is not flagged — so it does not get reverted the first time it looks like noise. Removing the pattern makes the first test fail, which I checked rather than assumed.Verification
strings.xmlparse; each gained the same 22 keys, with no duplicate keys, no unescaped apostrophes, and no string carrying more than one non-positional%.R.stringreferences across the five Kotlin files resolve in the default catalogue and in all five translations; format arities match their call sites.%1$swhere the default has%1$dthrowsIllegalFormatConversionExceptionfor that language only, which no test here would otherwise catch.Tools/i18n_audit.py --ci origin/main, its 36 unit tests, andTools/doc_comment_lint.pypass.Not compiled.
android.ymlis disabled so no CI job builds this, and the host cannot (aapt2 is x86-64 only). The resource andRreferences were checked statically, which catches a missing key or a wrong arity but not everything a build would. Worth a local./gradlew assembleFullDebug— or enablingandroid.yml— before merge.