The question
Our AI assistant for classroom panels ships in twelve languages. The product requirement was simple: use the system language until the teacher picks one, then always use their pick. Android 13’s per-app language API is built for exactly that.
The catch was the app itself. Most of its UI isn’t in an Activity. It’s a floating button and chat
panel drawn by a Service on top of other apps. Per-app languages are designed around activities that
recreate themselves.
My initial mental model
Call the API, and the app’s strings change:
localeManager.applicationLocales = LocaleList(Locale.forLanguageTag("ar"))
Activities get a configuration change and recreate. Done.
What actually happens
The API does three things well:
- It stores the choice. The OS remembers each app’s language, so there’s no preference to keep in sync.
- It follows the system until you choose. An empty
applicationLocalesmeans “use the system language”. - It sends a configuration change, with the new locale, to every component of your app that’s running.
What it doesn’t do is make already-resolved strings change. A string read from resources becomes a
plain String. Text that was resolved before the change stays in the old language until someone resolves
it again.
For an Activity, that someone is recreation, or Compose recomposition if you handle the configuration
change yourself. For a long-lived overlay Service, nobody does it. The service gets
onConfigurationChanged, and its views keep showing English.
Let’s look at the implementation
The chat activity: handle the change in place
The full-screen chat declares locale|layoutDirection in its configChanges, so the system doesn’t
recreate it. Compose re-reads string resources on the configuration change and the text updates live,
behind a short fade. Without that, switching language would recreate the activity, with a black flash
in the middle of a conversation.
The overlay: rebuild, but only for locale changes
The overlay’s views resolve their strings once, when they’re created. So the service rebuilds them, and only when the locale actually changed:
// Simplified from OverlayService.
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
if (newConfig.locales == lastLocales) return // ignore size, density, …
lastLocales = newConfig.locales
rootView.removeFromWindow()
rootView = OverlayRootView(this, controller).also { it.addToWindow() }
lastFrame?.let(::applyFrame) // restore what was on screen
notificationManager.notify(ID, createNotification()) // re-localise it too
}
Two details turned out to matter:
- Restore the frame. A fresh overlay starts in its default state, showing the main floating button. If the teacher switched language mid-split-screen, a naive rebuild would bring back a button that wasn’t supposed to be there. The service re-applies the last committed frame.
- The notification is UI too. A foreground service’s notification is text the user sees, and it doesn’t re-localise itself. Re-posting it after the change does.
Matching languages: legacy ISO codes
To highlight the current language in the picker, the app maps Locale.getLanguage() to its own list.
For some languages, Java still returns the legacy ISO 639 codes: in for Indonesian, iw for
Hebrew. A lookup for id never matches in, and the picker shows nothing selected. One normalisation
function now handles every comparison.
The surprising part
LocaleManager exists from API 33. The app’s minimum SDK is 30. Every panel we shipped ran API 36, so
nobody thought about it.
Then a panel in the field on an older Android crashed with NoClassDefFoundError the moment the settings
dialog composed. Composing it asked for the current language, which touched LocaleManager, a class
that doesn’t exist below API 33. A device you don’t expect is still a device you support.
Every entry point is now behind one check, and below API 33 the app follows the system language and hides the picker:
val isSupported: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
Lint’s NewApi check warns about calls like this. It doesn’t help if the warning gets suppressed, or if
the class is only touched through a path lint can’t see.
Practical consequences
- Declare the languages you ship in
localeConfig(res/xml/locales_config.xml). The system’s per-app language settings page uses it. - Handle
locale|layoutDirectionin place in activities that hold state worth keeping. - Anything long-lived that isn’t an
Activity, such as overlays, services, notifications and widgets, has to re-resolve its strings on a locale change itself. - Normalise language codes before comparing them.
- Gate the API on its real minimum version, even if “all our devices are newer”.
Translations were their own project. Eleven languages were drafted with AI and checked key-for-key against English. All 300 translatable keys were present, with no missing, extra or duplicate keys, and the resource merge was clean. One known defect was recorded at the time: three Thai strings were wrong. A hardcoded-string guard and lint warnings keep new English text from slipping in untranslated. Native-speaker review is the part that’s still missing.
What I would remember
- Per-app languages store the choice and send a configuration change. They don’t re-resolve strings you’ve already read.
- Activities can update in place. Overlays, services and notifications must rebuild themselves.
- Rebuilding UI means restoring its state, not just recreating it.
Locale.getLanguage()returns legacy codes, so normalise before comparing.- Gate API-33 classes even when every device you know of is newer.
Further reading
- Per-app language preferences.
LocaleManager.Locale.getLanguage()and its legacy ISO 639 codes.