← Back to writing

How I Added a Language Picker to an Android App in 12 Languages

Letting teachers pick the app's language instead of the phone's, in an app that floats over other apps: what Android handles for you, what it doesn't, and a crash on a device nobody expected.

  • Android
  • Internationalisation
  • LocaleManager

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 applicationLocales means “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|layoutDirection in 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

  1. Per-app languages store the choice and send a configuration change. They don’t re-resolve strings you’ve already read.
  2. Activities can update in place. Overlays, services and notifications must rebuild themselves.
  3. Rebuilding UI means restoring its state, not just recreating it.
  4. Locale.getLanguage() returns legacy codes, so normalise before comparing.
  5. Gate API-33 classes even when every device you know of is newer.

Further reading