Skip to content

Localize the Home experience on Android and iOS - #1490

Open
bhelm wants to merge 12 commits into
ryanbr:mainfrom
bhelm:feature/home-localization-upstream
Open

Localize the Home experience on Android and iOS#1490
bhelm wants to merge 12 commits into
ryanbr:mainfrom
bhelm:feature/home-localization-upstream

Conversation

@bhelm

@bhelm bhelm commented Aug 20, 2026

Copy link
Copy Markdown

Summary

  • localize the Android and iOS Home/Today surfaces, including indirect engine/helper copy, accessibility labels, empty states, provenance, readiness, metrics, workout prompts, and score explanation screens
  • keep analytics models UI-independent by exposing semantic values and resolving localized copy at the UI boundary
  • add locale-aware formatting, plural handling, and focused regression coverage for the Home localization closure
  • align the German score glossary around Energie, Belastung, and Erholung across the Home and explanation copy
  • improve Android's localized score-bubble and section-header layouts so longer German labels fit without clipping or unnecessary wrapping

Translation notes

Android in German was manually tested, including the adjusted Home layout. The additional locale content was translated automatically and is covered by catalog completeness, placeholder, echo, and semantic regression checks. Native-speaker corrections are welcome; having complete localized surfaces should make contextual issues easier to spot and report than the previous English fallbacks.

Validation

  • python3 Tools/test_home_i18n.py — 27 tests pass
  • python3 Tools/test_i18n_audit.py — 42 tests pass
  • python3 Tools/i18n_audit.py --ci origin/main — pass
  • ./gradlew :app:assembleFullDebug --no-parallel --max-workers=1 --no-daemon — pass
  • Android German Home flow and layout manually tested
  • two independent code/guideline reviews completed; findings resolved and delta-reviewed

Platform note

The Apple catalog and semantic localization paths are covered by the focused automated tests and audits. An iOS/macOS Xcode app build could not be run on the Linux host used for this work and still needs CI or macOS verification.

signal-2026-08-20-14-16-16-116 signal-2026-08-20-11-40-01-997_003

@ryanbr

ryanbr commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Gave this its own pass rather than folding it into the smaller ones. Mechanically it's in good shape — the
part I'd want fixed is one missing catalogue key, and the reason it's invisible.

What I verified as safe

  • Localizable.xcstrings: 3,562 → 3,598 keys, zero removed, and no entry lost a locale. The +527/-186
    line churn is entry rewrites, not lost translations.
  • Android: 273 new keys present in all seven locales. No new drift; values-zh actually improves
    (43 → 42 missing vs default). Both remaining gaps predate this.
  • lintVitalFullRelease -PstagingRelease clean — the fatal ExtraTranslation/MissingTranslation gate
    that plain compiles miss. i18n_audit --ci, doc_comment_lint, and 4,151 Android tests all green.
  • The semantic-enum refactor on IllnessSignalEngine and ReadinessEngine is additive — the English
    copy/suppressedBy payloads stay for existing callers, and IllnessSignal isn't persisted or carried
    in .noopbak, so the new Codable enums aren't a wire format.

The one live bug: a verdict that will render in English

ChargeDrivers.swift can emit this, and it is not in Localizable.xcstrings:

below baseline, limiting recovery, though low resting HR suggests this may be parasympathetic saturation rather than fatigue

I enumerated all 13 verdicts the engine can produce; 12 are present with German, this one isn't.

It's invisible because of how iOS localizes here — ChargeBreakdownFormat.swift:70:

let verdict = String(localized: String.LocalizationValue(d.verdict))

The engine's English string is the lookup key, resolved at runtime. A missing key doesn't fail; it
returns the English. So a German user who hits the HRV-saturation branch gets one English sentence inside
otherwise-German copy, and nothing reports it — not the compiler, not i18n_audit, not lintVital, not
the 28 new test_home_i18n.py tests. Every check on this PR is green with the hole in place.

Naturally it's the longest, rarest branch that's missing, which is exactly what manual catalogue upkeep
drops.

Why the two halves differ in robustness

Android iOS
mechanism typed enums → when English string → runtime key lookup
a new or reworded verdict compile error silent English fallback

RecoveryDrivers.kt moves to ChargeDriverLabel / ChargeDriverUnit / ChargeDriverVerdict;
ChargeDrivers.swift is untouched and still emits English. That's a legitimate design choice per platform,
but it means the iOS side has no compile-time safety by construction — and the PR removes the doc line
calling ChargeDriverRow a "SHARED CONTRACT… field names byte-identical across platforms", which is
honest, but leaves the divergence undocumented.

What I'd ask for

  1. Add the missing key with its German. One-line catalogue fix for a live gap.
  2. A test that enumerates every verdict ChargeDrivers.swift can emit and asserts each has a catalogue
    entry.
    That turns a silent runtime fallback into a build failure, and it's the only thing that stops
    this recurring — I found this one by writing that check ad hoc, and it should live in the repo.
  3. Optionally a note in ChargeDrivers.swift that its literals are localization keys, so the next person
    rewording a verdict knows the catalogue has to move with it.

Two things that are yours to decide, not defects

  • 126 existing German strings are rewritten (plus 14 pt-PT and a handful elsewhere) — the
    Ladungswert → Energiewert, Anstrengung → Belastung glossary realignment. That's a product-voice call
    about shipped copy; I can't evaluate German and neither can any gate here. Worth a German speaker's eye,
    and arguably separable from the coverage work.
  • Tools/test_home_i18n.py isn't wired into CI. 588 lines, 28 tests, passes locally, nothing runs it.
    It'll rot within a release or two.

Also flagging that I can't verify the Android layout changes for longer German labels — that needs a device.

@bhelm

bhelm commented Aug 21, 2026

Copy link
Copy Markdown
Author

Thanks for the detailed pass — the missing verdict and the silent runtime-key failure mode are both valid findings.

Fixed in 60a2ec05:

  • added the missing parasympathetic-saturation verdict to the Strand catalog for all currently maintained catalog locales
  • added a regression test that extracts every verdict return path from ChargeDrivers.swift (13 paths / 12 unique keys) and requires every unique key to exist with all focus-locale translations
  • documented directly in ChargeDrivers.swift that these English literals are runtime localization keys and must move together with the catalog

One clarification: Tools/test_home_i18n.py is already wired into CI through the Tools Python CI workflow's python3 -m unittest discover -p "test_*.py". The latest successful job ran all 28 Home tests as part of its 87 collected Tools/ tests. The new verdict-contract test will therefore run there as well.

On translation quality and scope: I manually tested the Android German Home flow, including the localized layout adjustments. I intend to do a separate content-quality and consistency pass over the German translations; there are also pre-existing translations outside this change that need improvement. Expanding this PR into a general German copy rewrite would make an already broad localization-enablement change larger still. The primary goal here is to make the Home strings localizable instead of leaving English hardcoded/fallback copy. Translation quality can then be iterated with native-speaker reports rather than being blocked on strings that cannot be translated at all.

Likewise, making missing iOS localization keys compile-time failures is worthwhile follow-up work, but not part of this PR. I already have separate parity-validation work that can compile iOS code on Linux; if that lands, it should improve this situation for future cross-platform development and validation.

@bhelm
bhelm force-pushed the feature/home-localization-upstream branch from 60a2ec0 to c9c7ede Compare August 21, 2026 12:30
@ryanbr

ryanbr commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Re-reviewed at c9c7edec. All three asks are done, one of my earlier claims was wrong, and there's one blocking finding that neither of us could have seen from this PR's checks.

First, my mistake

I said Tools/test_home_i18n.py "isn't wired into CI." That was wrong — you were right to correct me. tools-python.yml was already on main running python3 -m unittest discover -p "test_*.py", with a comment saying discover is used precisely so a new test file is picked up without editing the workflow, and the workflow is active. Adding the file wired it. It runs 29 tests and they pass.

The asks, verified

  1. Missing verdict key — present, with all 9 catalogue locales (de es fr it pl pt-PT ru zh-Hans zh-Hant).
  2. The enumeration testtest_apple_charge_driver_verdicts_are_complete_catalog_keys. I particularly like the assertEqual(12, len(verdicts)) pin: a regex that stops matching fails loudly instead of vacuously passing on an empty set, which is the failure mode that would otherwise make this test worthless.
  3. The doc noteChargeDrivers.swift:245-247, naming test_home_i18n.py as the enforcing check.

Re-verified on the current head, since the branch was rewritten

  • Catalogue: 3,562 → 3,599, zero removed, zero keys lost a locale.
  • Android: +279 default keys, drift does not worsende stays at 1 missing, zh improves 50 → 49. Others at 0.
  • i18n_audit --ci, doc lint, and lintVitalFullRelease -PstagingRelease all clean.
  • Android compiles; 4,189 tests, 0 failures (--no-build-cache --rerun-tasks), and again after merging onto current main.
  • Verdict parity holds: 12 Android enum cases ⇄ 12 Swift keys, 1:1, HRV_SATURATION_LIMITING included. The enum/string split means the two can drift, so I checked the sets rather than assuming.

Blocking: 6 Swift test failures

Build Strand compiles fine. Test Strand fails — 1,188 tests, 6 failures, all one root cause:

TodayExplainabilityTests.swift:283: XCTAssertEqual failed: ("WHOOP") is not equal to ("Whoop")
VitalReadingsTableTests.swift:49:  ("[…, "WHOOP"]") is not equal to ("[…, "Whoop"]")

The PR renames the brand label "Whoop""WHOOP" (private static let whoopBrandName = "WHOOP", and today_source_whoop on Android). The Kotlin twin was updated — TodayExplainabilityTest.kt now asserts res(R.string.today_source_whoop) — but the Swift twin was not:

  • StrandTests/TodayExplainabilityTests.swift lines 284, 297, 342, 354, and the composite "Whoop + Oura" in testLiquidHeroSourceLabel_capsMixedProvidersAtTwoInScoreOrder
  • StrandTests/VitalReadingsTableTests.swift line 49 (and the doc line at 8)

This is invisible from this PR's checks by construction: StrandTests runs only in app-build, which is disabled by default, so nothing here compiles or tests the 5 app-target Swift files you changed. I ran it against your branch merged onto current main: run 32525350378 — iOS leg green, macOS leg red on those 6.

The rename itself looks right to me (the brand is stylised all-caps, matching WHOOP 4.0 elsewhere), so the fix is to move the Swift expectations, not to revert. Flagging separately that it changes shipped English copy everywhere the provenance badge appears — same category as the German glossary realignment, a product-voice call rather than a defect.

Conflict with main — mine, not yours

TodayScreen.kt conflicts because I merged #1521 a few hours after your last push. Resolving it takes your side: you made stepsCalibrationPrompt @Composable so it can call uiString, and a @Composable cannot be called inside remember {}, so dropping the memo is forced rather than careless. It also happens to dissolve the staleness bug #1521 fixed, by recomputing every pass instead of keying the memo. Net effect: the Android half of #1521 is superseded by this PR, which is fine — I'd rather say so out loud than have it look like a silent revert.

One note, not a request

The new test enforces audit.LANGS = ["de", "es", "fr", "pt-PT"] — 4 of the catalogue's 9 locales. A future verdict translated only into those 4 would pass it. That's a pre-existing property of the audit, not something you introduced, and the key you added is complete in all 9.

Once the 6 Swift expectations move, I'll re-run app-build and merge.

…lling

This PR renames the provenance brand label "Whoop" -> "WHOOP" (whoopBrandName in
TodayView, today_source_whoop on Android) and updates the Kotlin twin, but the
Swift twin still asserted the old spelling, so StrandTests failed 6 of 1188.

Nothing in this PR's checks could show it: StrandTests runs only in app-build,
which is disabled by default, so the five app-target Swift files here are neither
compiled nor tested by any check that ran.

Moves the expectations rather than the rename -- the stylised all-caps matches
how the brand is written everywhere else in the project.

  TodayExplainabilityTests: provenanceDisplayLabel x2, todayProvenanceChipLabel,
  todayScoreProviderLabel, and the "WHOOP + Oura" hero composite
  VitalReadingsTableTests: the per-sample source row

Doc lines naming the literal label moved with them.

Verified: app-build on this branch merged onto main -- see the PR comment.
@ryanbr

ryanbr commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Pushed the fix to your branch as 5dddc3dd — it was my finding and a mechanical one, so it seemed unfair to hand it back to you as homework.

Moved the expectations rather than the rename: the stylised all-caps matches how the brand is written everywhere else in the project, so "WHOOP" is the right answer and the Swift twin was simply the half that didn't move.

  • TodayExplainabilityTestsprovenanceDisplayLabel ×2, todayProvenanceChipLabel, todayScoreProviderLabel, and the "WHOOP + Oura" hero composite
  • VitalReadingsTableTests — the per-sample source row
  • the two doc lines that quote the literal label

I checked the negative test (testTodayScoreProviderLabel_unknownSourceDoesNotPretendToBeWhoop) before touching anything — it asserts different literals and was left alone.

Verified: app-build 32526588100 on your branch merged onto current main — both legs green, and Test Strand now reports 1,188 tests, 0 failures against the 6 from before. Same total, so the six went green rather than going missing.

Still outstanding, and mine rather than yours: TodayScreen.kt conflicts with main because of #1521. I'll resolve it your way at merge — stepsCalibrationPrompt is @Composable now, so the remember has to go — and you don't need to do anything about it.

Everything else from my last pass stands verified. Ready to merge once you're happy with the change to your branch.

@bhelm

bhelm commented Aug 21, 2026

Copy link
Copy Markdown
Author

@ryanbr Looks good to me, thanks for fixing and verifying it. Happy to 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.

2 participants