English is the base language, and the app carries its own language switcher
Context
Android 13 added per-app languages: the system builds a Language entry in the app’s settings from
res/xml/locales_config.xml, and setApplicationLocales() overrides the phone’s locale for this app
alone. Below 13 the platform has none of that.
Two things follow that are easy to get wrong:
- Persistence. On 13+ the framework stores the choice. Below 13 something has to, and it has to
be readable before the first frame — a
DataStoreread is asynchronous, so the app would draw its first screen in the phone’s language and then repaint. - Completeness. “Every string has a translation in every locale” is the right rule at the wrong boundary if it is a test: it turns a red build on the moment an English string is written, which puts every feature branch behind a translation round and gets the copy translated twice — once against the draft wording, and again after review reworded it.
Decision
English is the base and the fallback for every unmatched locale. res/values/strings.xml is the
source; translations are made from English only, never from another translation.
AppCompat provides the switcher, which costs three things and is worth all of them: the
appcompat dependency, MainActivity : AppCompatActivity, and a root theme descending from
Theme.AppCompat. Persistence below 13 is AppCompat’s, through the disabled
AppLocalesMetadataHolderService in the manifest — a manifest entry used as a storage slot, read
synchronously before anything draws.
Completeness is a merge gate, not a test. scripts/translation-gate.py refuses a merge where a
shipped language is incomplete, or where the English changed on this branch and the translation did
not. Free while you work, strict before it merges.
Everything else about a translation stays a continuous test. TranslationTest holds format
arguments, plural categories per CLDR, orphans, translatable="false" and string-array lengths.
The switcher’s list and locales_config.xml are asserted to agree, because “remember to edit
both” survives right up until the translation lands and nobody does.
Alternatives
A DataStore key for the language. Read asynchronously, so the first frame is in the wrong language. Also a second copy of an answer the user can change in system Settings, where the app would never see it.
Follow the phone’s language only. Loses the case this exists for: someone whose phone is in a language they read poorly, or who wants one app in a different language from the rest.
Make completeness a test. See above — it costs a translation round per branch and buys nothing a gate does not.
Consequences
- A locale in
locales_config.xmlwith no strings behind it offers the user a language the app cannot speak. Add the entry when the translation is ready, not before. - A stale translation is invisible to every other check: it parses, it carries its format arguments,
and it says something the English no longer says. That is the case the gate’s merge-base comparison
exists for — which is why a shallow CI checkout silently disables it, and why the workflow uses
fetch-depth: 0. - Language names in the switcher are endonyms — each language named in its own language, in every locale — so someone who has landed somewhere they cannot read finds their way out.
Amendment, 2026-09-02: the below-13 half of persistence is removed. minSdk is 33 (ADR-0008),
so the disabled AppLocalesMetadataHolderService manifest entry — AppCompat’s store for devices
with no per-app locale of their own — existed only for devices this app never ships to. On 13+ the
framework persists the choice and the entry was ignored, which means removing it changes nothing a
user can observe; it is amended here rather than tidied away because the Persistence bullet in the
Context above is what it was answering.
AppCompat itself stays, and for two reasons rather than one: ADR-0006 needs
setDefaultNightMode, and the switcher is still AppCompatDelegate.setApplicationLocales — which
on 13+ is a thin pass-through to the framework’s LocaleManager. Confirmed on the phone rather than
argued (phase-2.md R12): tapping Polski leaves cmd locale get-app-locales reporting [pl],
and a force-stopped app comes back up Polish. Everything else in this decision is unchanged.
Second amendment, 2026-10-31: the below-13 half of persistence is back, because minSdk is 26
again (ADR-0008’s amendment). The disabled AppLocalesMetadataHolderService entry with
autoStoreLocales is restored, so AppCompat keeps the choice on devices with no per-app locale
store. Known limit below 13: AppCompat applies the locale to activities, not to the application
or a service, so the shade’s notification, the tile’s label and the built-in preset names follow the
system language there. Accepted rather than worked around — the screens are right, nothing traps
the user, and the fix is a per-process locale override on shared resources.