Skip to main content

Migrate an Android Language Picker to Per-App Language Preferences

2026-09-16

Migrate an Android Language Picker to Per-App Language Preferences

An Android app can have a working language picker and still mishandle Android per-app language preferences. The failure appears during an upgrade: the app keeps a legacy language in shared preferences, Android 13 stores another choice in system settings, and the two selectors disagree. Some users see their language reset. Others change it in Settings, reopen the app, and find the old value restored. The fix is to establish one runtime source of truth, migrate the legacy choice once, and test upgrades separately from clean installs.

A safe migration keeps the existing choice, hands future changes to the platform locale API, and leaves older Android releases on the AndroidX compatibility path. It also needs separate tests for installation, upgrade, restore, and changes made outside the app.

Why an existing language picker becomes risky

Before Android 13, many apps implemented language selection entirely inside the app. A picker wrote a tag such as fr or pt-BR to custom storage, then the app wrapped a context or replaced resources during startup. That design made the app responsible for persistence, configuration changes, process recreation, and fallback.

Android 13 added a centralized system location for each app's language. Android also provides APIs that synchronize an app's selection with that system setting. The official guide to Android per-app language preferences recommends migrating custom picker logic to those APIs and calls out existing user choices as a migration case.

Trouble starts when the new API is added but the old preference remains authoritative. The app then has at least three possible values:

  1. A language tag in legacy app storage.
  2. The locale selected through the platform or AndroidX API.
  3. The device locale used when no application locale is selected.

Reading all three on every launch creates a race. The last value applied wins, and the order can change after process death, restore, activity recreation, or a framework update.

After the first upgraded launch, the platform locale API should own the current app locale. Legacy storage is migration input. It must not remain a competing preference.

Define the target state before changing code

Before editing the picker, record the intended behavior as a short contract.

Use a contract like this:

  • A nonempty application locale list is the user's explicit app-language choice.
  • An empty application locale list means follow the system language.
  • The in-app picker reads from and writes to the application locale API.
  • Android system Settings and the in-app picker display the same effective selection.
  • Legacy language storage is read only until one migration attempt succeeds.
  • Resource fallback still depends on complete default resources.

Locale selection cannot repair a missing default resource. Android's broader app localization guidance explains why default resources must remain complete. If the migration exposes a locale in system Settings before its resources are ready, users may see missing text or an unexpected fallback. Designing that fallback deliberately is its own task, covered in preventing missing translations with fallback languages.

Decide whether the picker includes a "System default" option. It should map to an empty application locale list, not to the device's current language tag. Saving the current device tag would freeze a value that should remain dynamic when the user later changes the device language.

Also decide whether the app supports one selected locale or an ordered list. Most pickers present one language, but the underlying APIs accept locale lists. Do not silently convert a product requirement for regional fallback into an unordered set.

Inventory every source of locale state

Inspect the startup path, settings UI, dependency injection graph, and backup configuration before implementing the migration. Record every locale read and write.

Common sources include:

  • A shared-preference key such as language, locale, or selected_language.
  • A database profile field synchronized from an account.
  • A custom ContextWrapper that replaces the configuration.
  • A static locale variable initialized in Application.
  • A picker that writes storage and immediately recreates an activity.
  • Remote account settings restored after sign-in.
  • Backup data restored before the first normal launch.
  • Tests or debug menus that set a locale through a separate path.

For each source, label it as authority, migration input, or display-only. There should be one authority after migration. If account-level language is a product feature, define whether it seeds the app locale only on a new device or continuously overrides local choice. Continuous override can conflict with Android system Settings, so it needs an explicit product decision rather than an accidental startup write.

Capture the legacy tag format too. A saved value might be pt_BR, iw, a display label, or an internal enum rather than a valid BCP 47 tag. Normalize only mappings the product actually used. Reject an unknown value and fall back to system behavior instead of guessing a language from a label.

Generate the supported locale configuration deliberately

Android can generate the locale configuration from app and library resources when the project uses the supported Android Gradle Plugin setup. The per-app language guide describes automatic generation and warns production apps to ensure that locales contributed by app and library modules are ready to publish.

Check this in the release build. A dependency may contribute resource directories for languages the product team has never reviewed. Android can then expose those locales in system Settings even though only part of the app is translated.

Inspect the merged release artifact, not just the source tree. Build the production variant and compare its advertised locales with the approved launch list. The expected set should come from localization release policy, while the generated set comes from packaged resources. A mismatch is a build failure until someone confirms whether the resource or the approved list is wrong.

If automatic generation does not fit the build, use a manually maintained locale configuration and give it an owner. Do not maintain both generated and handwritten lists without an assertion that they match.

Some apps still need an in-app picker. A translator requesting an OpenTracks language selector gave one reason: the app can support a language that Android does not offer as a device language. The picker should present the approved app locales rather than copying the device-language list.

Migrate the saved choice exactly once

Treat migration as a versioned state transition. It should run only when a legacy value exists and no platform-owned application locale has already been selected. Never overwrite a nonempty platform value. That value may have come from system Settings, a restored app preference, or a previous successful migration.

The decision order can be expressed as Kotlin-style pseudocode. This is an implementation pattern, not a drop-in library:

fun migrateLegacyAppLocaleIfNeeded(store: LegacyLocaleStore) {
    if (store.migrationVersion >= 1) return

    val current = AppCompatDelegate.getApplicationLocales()
    if (!current.isEmpty) {
        store.migrationVersion = 1
        return
    }

    val legacyTag = store.readValidatedLanguageTag()
    if (legacyTag != null) {
        val migrated = LocaleListCompat.forLanguageTags(legacyTag)
        AppCompatDelegate.setApplicationLocales(migrated)
    }

    store.migrationVersion = 1
}

The exact call location depends on the app architecture and AndroidX setup. Follow the timing requirements in the current Android per-app language documentation, especially for the first run on Android 13 and for compatibility storage on Android 12 and earlier.

A migration marker stops later launches from replaying stale data. Write it only after validating the legacy value and calling the application-locale API. If durable storage fails, leave the migration incomplete and emit a diagnostic event. A later launch can repeat the guarded check without deleting the only record of the user's choice.

Do not immediately delete the legacy key in the same release unless rollback behavior is proven. A previous app version may still read it after a downgrade. One conservative sequence is to stop writing the key, retain it for one compatibility window, then remove it after adoption and rollback monitoring confirm that it is no longer needed.

Make the picker a view over platform state

After migration, the picker should read getApplicationLocales() and write setApplicationLocales(). It should not read the legacy key for display. If the locale list is empty, select "System default." If it contains a locale, map the primary tag to the corresponding picker item.

Use stable locale tags as item identities and localized language names as labels. Never save the translated label. Labels change with the display language and may not uniquely identify script or region variants.

When a user chooses a language:

  1. Validate that the tag is in the app's approved locale registry.
  2. Write it through the application-locale API.
  3. Let the platform and support library perform the required configuration update.
  4. Re-read the effective locale after recreation.
  5. Record success or a bounded diagnostic failure without logging personal data.

A Delta Chat contributor described consistent selection through in-app and out-of-app language selectors. That is the behavior to verify. The two surfaces do not need identical visual design, but they must resolve to the same platform-owned value.

Avoid manually recreating several activities before confirming what the locale API already triggers in the supported configuration. Duplicate recreation can produce flicker, lose navigation state, or run migration twice. Keep the picker action idempotent so selecting the current locale does not restart the app unnecessarily.

Handle failures without restoring the old race

Do not recover from an error by reapplying the legacy preference on every launch. That restores the two-authority defect. Error handling must keep the platform locale as the authority.

If a legacy tag is invalid, keep the application locale empty and report the rejected tag category. If a supported locale has incomplete resources, block it before release rather than substituting another region at runtime. If migration storage cannot persist its version marker, retry the migration guard on the next process start but never overwrite an application locale that is already nonempty.

Backup and restore needs its own decision. Android's locale APIs and compatibility support can participate in app-level persistence, while an old custom preference may also return from backup. On restore, platform-owned locale state wins. The legacy value is eligible only when no application locale exists and the migration marker has not completed.

Account synchronization needs the same precedence rule. A server profile can offer a suggested language on first sign-in, but it should not silently replace a choice made in Android Settings unless the product explicitly tells the user that account language controls the app.

Verify clean installs, upgrades, and external changes

A clean install never exercises legacy state. Test each upgrade, restore, and external-setting transition separately.

Cover at least these cases:

  • Clean install on Android 13 or later with system default selected.
  • Clean install with a language selected through Android Settings before first normal use.
  • Upgrade from a build with a valid legacy language.
  • Upgrade with an invalid or retired legacy tag.
  • Upgrade when a platform application locale already exists.
  • Android 12 or earlier using the AndroidX compatibility path.
  • Backup restore containing legacy storage.
  • Change from the in-app picker, followed by verification in system Settings.
  • Change from system Settings, followed by verification in the app picker.
  • Reset to system default, then change the device language.
  • Select an app-supported language that is unavailable as a device language.
  • Process death and relaunch after each selection path.

For every case, assert the stored migration version, application locale list, visible picker selection, rendered language, and expected fallback. Also verify a screen owned by a library module, because generated locale configuration and packaged resources can differ across modules.

Monitor the rollout for spikes in startup recreation, locale-reset support requests, and mismatches between approved locales and release artifacts. Do not collect raw user text or account identifiers for this check. Locale tags, app version, Android version, migration outcome, and source surface are enough to diagnose most failures.

Next action

Create a one-page locale-state inventory for the current production build. List the storage key, every startup write, the approved locales, and the code that applies resources. Then implement a one-time migration that refuses to overwrite a nonempty application locale. Start the staged rollout only after the upgrade matrix passes on Android 12 and Android 13 or later.

References

  1. Android Developers: Per-app language preferences supports the system-settings model, application locale APIs, automatic locale configuration, AndroidX compatibility, and migration corner cases.
  2. Android Developers: Localize your app supports the requirement for complete default resources and predictable resource fallback.
  3. GitHub: Delta Chat language selector consistency provides a practitioner report about keeping in-app and system language selectors aligned.
  4. GitHub: OpenTracks app language picker request provides a practitioner case for selecting a supported app language that Android does not expose as a device language.