MaksForge

Gloam

Android app

A screen dimmer that goes below your phone's minimum brightness.

Live

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 DataStore read 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.xml with 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.

Hi, I'm Maksymilian.

MaksForge is where I build the small tools I wish existed. Each one solves a single problem.