Skip to content

Localize the battery notification strings that never reached the Apple catalog - #866

Merged
ryanbr merged 3 commits into
mainfrom
i18n/battery-notifier-catalog
Jul 27, 2026
Merged

Localize the battery notification strings that never reached the Apple catalog#866
ryanbr merged 3 commits into
mainfrom
i18n/battery-notifier-catalog

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Follow-up to #864, as flagged in review there. Resource-only, no code change.

#864 added four String(localized:) literals in Strand/System/BatteryNotifier.swift and
translated its Android twin into all five shipped locale files — but the Apple catalog was never
updated, so both escalations ship English on iOS/macOS in every language. Two further literals
in the same file (the #713 runtime alert) were already missing before that PR.

Six entries added, all eight shipped Apple languages:

Strap battery low
%@ left on your WHOOP — recharge tonight.
Charge your WHOOP now
%lld%% left. The strap stops recording near 10%% — it won't capture tonight unless you charge it.
Won't last the night
%@ of recording left, but tonight needs about %@. Charge before bed.

Why CI was green on #864

The audit checks that keys already in a catalog carry every language. A String(localized:)
literal that was never extracted is invisible to it — it is not a missing translation, it is a
missing key. That is #844 exactly, and this PR is the manual patch, not the fix.

Getting the keys right

Derived mechanically from the source literals rather than retyped: String interpolation to %@,
Int to %lld, literal % to %% inside an interpolated string (a non-interpolated one keeps
a bare %, which is why the existing Your WHOOP is at 100%. is stored unescaped).

That mapping is not assumed. It is the observed encoding of XiaomiBandView.swift:268, whose
literal produced the existing catalog key %@ in bed · %lld%% efficiency — same two conversions and
the same escaped percent, in one string.

Every translation was then checked to carry the same argument types in the same positions and the
same literal-percent count as its key, so no format string can garble or crash at substitution.
Keys stay non-positional and translations use %1$/%2$ where word order moves, matching how the
catalog already stores its two-argument entries.

One deliberate divergence from the Android twin

de, fr and zh-Hans reuse the Android wording. es and pt-PT do not.

#864 translated those two formally — Cargue su WHOOP, Carregue já a sua WHOOP, Cárguela antes
de acostarse
— but this catalog is informal by a wide margin, and the battery strings it already
ships next to these are informal:

informal formal
es tu/tus/tú 762 su/sus/usted 66
pt-PT teu/tua 689 seu/sua/você 20

So the new strings are informal here. Each language also uses the word for "strap" the catalog
already established for it (Strap, pulsera, bracelet, fascia, correia, браслет, 手环, 手環) rather
than importing Android's. Register and vocabulary only — the numbers are identical either way, and
no Android file is touched.

Grammatical agreement

The substituted %@ is BatteryEstimator.label output — ~14h or ~1h — so a body that opens
with a verb must commit to singular or plural against a value it cannot see. Queda ~14h and
Restano ~1h are each wrong half the time, and because the argument is an opaque string,
xcstrings plural variations cannot key on it either.

The three languages that inflect here are recast as label-then-value:

Queda %@ en tu WHOOP       ->  Autonomía restante en tu WHOOP: %@
Restano %@ sul tuo WHOOP   ->  Autonomia rimanente sul tuo WHOOP: %@
Restam %@ no teu WHOOP     ->  Autonomia restante no teu WHOOP: %@

de (Noch %@), fr (Il reste), ru (Осталось) and both Chinese variants were already
agreement-free and are untouched.

Same treatment in the critical body, where %lld is the SoC percentage: it fires at or below
criticalSocPct = 12 and can be anything down to 1, so Queda 1% and Quedan 12% both occur.
It now states the level — Batería al %lld%% — rather than the remainder.

And Batería de la pulsera bajaBatería baja de la pulsera: with baja trailing it reads as
the verb bajar. Adjacent to Batería the ambiguity is gone, and it matches the existing
Low batteryBatería baja.

it, ru and zh-Hant are new translations and have not been checked by a native speaker.

Verification

i18n_audit.py --ci origin/main is green. The catalog moves 3259 → 3265 keys with every
per-language missing= count unchanged
— the signature of six additions that are complete in all
eight languages. Tools/i18n_extra_locale_baseline.txt is untouched and unaffected.

Not in scope

Android hardcodes six battery strings as raw Kotlin literals rather than R.string
(BatteryAlertNotifier.kt:123,124,151,152,163,164 — the runtime, low and full alerts), so those
ship English on Android in every language, mirroring this bug in the opposite direction. Separate
concern, separate PR; filing it as an issue.

…e catalog

#864 added four String(localized:) literals in Strand/System/BatteryNotifier.swift and translated
its Android twin into all five shipped locale files, but the Apple catalog was never updated — so
the two escalations ship English on iOS/macOS in every language. Two further literals in the same
file (the #713 runtime alert) were already missing before that PR.

Six entries, all eight shipped Apple languages, no code change:

  Strap battery low
  %@ left on your WHOOP — recharge tonight.
  Charge your WHOOP now
  %lld%% left. The strap stops recording near 10%% — it won't capture tonight unless you charge it.
  Won't last the night
  %@ of recording left, but tonight needs about %@. Charge before bed.

The audit cannot see this class of gap, which is why it went green on #864: it checks that keys
already IN a catalog carry every language, so a String(localized:) literal that was never extracted
is invisible to it. That is #844.

Keys derived mechanically from the source literals rather than retyped — String interpolation to %@,
Int to %lld, literal % to %% inside an interpolated string. That mapping is not assumed: it is the
observed encoding of XiaomiBandView.swift:268, whose literal produced the existing catalog key
"%@ in bed · %lld%% efficiency". Every translation was then checked to carry the same argument
types in the same positions and the same literal-percent count as its key, so no format string can
garble or crash at substitution time. Keys stay non-positional and translations use %1$/%2$ where
word order moves, matching how the catalog already stores its two-argument entries.

de, fr and zh-Hans reuse the Android wording. es and pt-PT do not: #864 translated those formally
(Cargue su WHOOP, Carregue já a sua WHOOP) while this catalog is informal by a wide margin — tu/tus
762 against su/usted 66, teu/tua 689 against seu/sua 20 — and the neighbouring battery strings it
already ships are informal. Matching the surrounding register mattered more than matching the twin
word for word; the numbers are identical either way. Each language also uses the word for "strap"
the catalog already established for it (Strap, pulsera, bracelet, fascia, correia, браслет, 手环,
手環) rather than importing Android's.

it, ru and zh-Hant are new translations and have not been checked by a native speaker.

Resource-only, so nothing to compile. i18n_audit.py --ci origin/main is green; the catalog moves
3259 to 3265 keys with every per-language missing count unchanged, and the non-focus ratchet in
Tools/i18n_extra_locale_baseline.txt is untouched and unaffected.
ryanbr added 2 commits July 26, 2026 21:58
The substituted %@ is BatteryEstimator.label output — "~14h" or "~1h" — so a sentence that opens
with a verb has to commit to singular or plural against a value it cannot see. "Queda ~14h" and
"Restano ~1h" are each wrong half the time, and the argument is an opaque string, so xcstrings
plural variations cannot key on it either.

Recast those three bodies as label-then-value in the three languages that inflect here:

  Queda %@ en tu WHOOP        -> Autonomía restante en tu WHOOP: %@
  Restano %@ sul tuo WHOOP    -> Autonomia rimanente sul tuo WHOOP: %@
  Restam %@ no teu WHOOP      -> Autonomia restante no teu WHOOP: %@

and the same for the bedtime body. de ("Noch %@"), fr ("Il reste"), ru ("Осталось") and both
Chinese variants were already agreement-free and are untouched.

Same problem in the critical body, where %lld is the SoC percentage. That fires at or below
criticalSocPct = 12 and can be any value down to 1, so "Queda 1%" and "Quedan 12%" both occur.
State the level rather than the remainder: "Batería al %lld%%", "Batteria al %lld%%",
"Bateria a %lld%%".

Also "Batería de la pulsera baja" -> "Batería baja de la pulsera": with "baja" trailing it can be
read as the verb bajar rather than the adjective. Putting it straight after "Batería" removes the
ambiguity and matches the existing "Low battery" -> "Batería baja".

Wording only, no keys added or removed. Argument types, positions and literal-percent counts
re-checked against every key after the rewrite; i18n_audit.py --ci origin/main still green.
…c in the es bedtime body

Two things a read-through of the finished strings turned up.

The low-battery title put the adjective after the strap noun — "Batterie du bracelet faible",
"Batteria della fascia scarica", "Bateria da correia fraca" — where it attaches to the strap rather
than the battery. In fr and it it is worse than ambiguous: "faible" and "scarica" are also verb
forms, so the phrase can be read as a sentence about the strap discharging. Moved next to the noun
it qualifies, which also matches how each language already renders the existing "Low battery" key
(Batterie faible, Batteria scarica, Bateria fraca). es was already corrected this way.

The es bedtime body ended "Cárgala antes de acostarte" — a feminine clitic with no antecedent in
the sentence. Every other Spanish string in this feature treats the device as masculine ("tu
WHOOP", "Recárgalo", "Carga tu WHOOP ahora"); the only feminine noun, "la pulsera", appears in the
critical body and not here. Names the object instead: "Carga tu WHOOP antes de acostarte."

Inherited from the Android wording in #864, which has the same dangling pronoun.

Wording only. Argument types, positions and literal-percent counts re-checked against every key;
i18n_audit.py --ci origin/main green. Diff against main remains 6 keys added, 0 removed, 0
pre-existing entries modified.
@ryanbr
ryanbr merged commit 99e8201 into main Jul 27, 2026
2 checks passed
@ryanbr
ryanbr deleted the i18n/battery-notifier-catalog branch July 27, 2026 05:02
ryanbr added a commit that referenced this pull request Jul 27, 2026
…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 added a commit that referenced this pull request Jul 27, 2026
…guage change (#867) (#902)

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.
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.

1 participant