Skip to content

Localise every Android notification string, and let them follow a language change (#867) - #902

Merged
ryanbr merged 1 commit into
mainfrom
fix/android-notification-strings-l10n
Jul 27, 2026
Merged

Localise every Android notification string, and let them follow a language change (#867)#902
ryanbr merged 1 commit into
mainfrom
fix/android-notification-strings-l10n

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 27, 2026

Copy link
Copy Markdown
Owner

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 — RECOUVREMENT for Recovery in fr (that is debt collection), de mixing Erholung/Genesung and 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:

  • 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. On a stable link with a charged strap, that is hours. The locale tag is now part of the key.
  • Both ensureChannels 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 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 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; I verified that rather than assuming it. So the call pattern now also matches setContentTitle/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/ and notif/.

Two tests pin the extension: one that a builder chain's literals are found, one that the getString form 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

  • All six strings.xml parse; 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 Kotlin files resolve in the default catalogue and in all five translations; format arities match their call sites.
  • Every formatted string's specifiers are identical across all six locales — a %1$s where the default has %1$d throws IllegalFormatConversionException for that language only, which no test here would otherwise catch.
  • Tools/i18n_audit.py --ci origin/main, its 36 unit tests, and Tools/doc_comment_lint.py pass.

Not compiled. android.yml is disabled so no CI job builds this, and the host cannot (aapt2 is x86-64 only). The resource and R references were checked statically, which catches a missing key or a wrong arity but not everything a build would. Worth a local ./gradlew assembleFullDebug — or enabling android.yml — before merge.

…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
ryanbr force-pushed the fix/android-notification-strings-l10n branch from 6d12ff9 to 41bdf16 Compare July 27, 2026 21:11
@ryanbr
ryanbr merged commit 5a9e726 into main Jul 27, 2026
2 checks passed
@ryanbr
ryanbr deleted the fix/android-notification-strings-l10n branch July 27, 2026 21:16
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android hardcodes six battery notification strings as Kotlin literals, so they ship English in every locale

1 participant