Localize the battery notification strings that never reached the Apple catalog - #866
Merged
Conversation
…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.
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
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.
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.
Follow-up to #864, as flagged in review there. Resource-only, no code change.
#864 added four
String(localized:)literals inStrand/System/BatteryNotifier.swiftandtranslated 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:
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
%@,Intto%lld, literal%to%%inside an interpolated string (a non-interpolated one keepsa bare
%, which is why the existingYour WHOOP is at 100%.is stored unescaped).That mapping is not assumed. It is the observed encoding of
XiaomiBandView.swift:268, whoseliteral produced the existing catalog key
%@ in bed · %lld%% efficiency— same two conversions andthe 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 thecatalog already stores its two-argument entries.
One deliberate divergence from the Android twin
de,frandzh-Hansreuse the Android wording.esandpt-PTdo 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:
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
%@isBatteryEstimator.labeloutput —~14hor~1h— so a body that openswith a verb must commit to singular or plural against a value it cannot see.
Queda ~14handRestano ~1hare 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:
de(Noch %@),fr(Il reste),ru(Осталось) and both Chinese variants were alreadyagreement-free and are untouched.
Same treatment in the critical body, where
%lldis the SoC percentage: it fires at or belowcriticalSocPct = 12and 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 baja→Batería baja de la pulsera: with baja trailing it reads asthe verb bajar. Adjacent to Batería the ambiguity is gone, and it matches the existing
Low battery→Batería baja.it,ruandzh-Hantare new translations and have not been checked by a native speaker.Verification
i18n_audit.py --ci origin/mainis green. The catalog moves 3259 → 3265 keys with everyper-language
missing=count unchanged — the signature of six additions that are complete in alleight languages.
Tools/i18n_extra_locale_baseline.txtis 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 thoseship English on Android in every language, mirroring this bug in the opposite direction. Separate
concern, separate PR; filing it as an issue.