SuriDevs Logo
Change App Language in Android (Jetpack Compose) — Runtime Locale Switch

Change App Language in Android (Jetpack Compose) — Runtime Locale Switch

By Sagar Maiyad  Apr 08, 2026

For years, runtime language switching on Android was painful. You'd update the Configuration, recreate the Activity, fight back-stack issues, and inevitably lose UI state somewhere along the way. Half the StackOverflow answers were outdated, the other half didn't survive a screen rotation.

Android 13 fixed this with per-app language preferences — a system-level API that handles locale persistence and recreation for you. And there's an AppCompat backport that gives you the same API on Android 5.0+.

I recently shipped this in a production app supporting 17 languages. Here's how it works, what the gotchas are, and a complete working example you can drop into a Compose project.

The Problem With the Old Approach

The classic way looked something like this:

// DON'T do this anymore
val config = resources.configuration
config.setLocale(Locale("es"))
resources.updateConfiguration(config, resources.displayMetrics)
recreate()

This had a long list of problems:

  • updateConfiguration is deprecated
  • You had to manually persist the choice and re-apply it on every Activity start
  • recreate() tore down your view tree, killing animations and transient UI state
  • Subclassing ContextWrapper to wrap attachBaseContext was a popular workaround — and a maintenance nightmare
  • System-level "language per app" in Settings → Apps → [Your App] didn't exist, so users had no way to override outside your settings screen

The New Approach (Android 13+)

In API 33, Google introduced two things:

  1. LocaleManager — a system service for getting/setting per-app locales
  2. System integration — users can change your app's language directly from Android Settings

For older devices, AppCompat 1.6+ ships a backport: AppCompatDelegate.setApplicationLocales(). It uses the system API on API 33+ and falls back to a SharedPreferences-backed persistence layer on older versions. Same call, both paths.

Both approaches handle Activity recreation automatically and preserve state correctly.

Setting Up Resource Folders

This part hasn't changed. Create one values-XX/ folder per language with a strings.xml:

app/src/main/res/
├── values/strings.xml          # default (English)
├── values-es/strings.xml       # Spanish
├── values-hi/strings.xml       # Hindi
└── values-ar/strings.xml       # Arabic (RTL)

A minimal strings.xml:

<!-- values/strings.xml -->
<resources>
    <string name="app_name">Recipe Notes</string>
    <string name="title_settings">Settings</string>
    <string name="label_language">Language</string>
    <string name="msg_welcome">Welcome to Recipe Notes</string>
</resources>
<!-- values-es/strings.xml -->
<resources>
    <string name="app_name">Notas de Recetas</string>
    <string name="title_settings">Ajustes</string>
    <string name="label_language">Idioma</string>
    <string name="msg_welcome">Bienvenido a Notas de Recetas</string>
</resources>

You also need to declare the supported locales in build.gradle.kts so the build system knows which languages to bundle:

android {
    defaultConfig {
        // ...
        resourceConfigurations += listOf("en", "es", "hi", "ar")
    }
}

This prevents Google Play from stripping unused locales when you upload an App Bundle, and it tells LocaleManager which locales your app actually supports. While you're tuning the build config, it's also a good time to look at R8 optimizedResourceShrinking — it works alongside resourceConfigurations to keep your APK lean.

The Locale Manager Wrapper

Here's the core helper. It works on Android 5.0+ by branching on API level:

import android.app.LocaleManager
import android.content.Context
import android.os.Build
import android.os.LocaleList
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat

object LocaleHelper {

    /**
     * Set the app's language. Pass null or empty to follow system default.
     *
     * Examples:
     *   setAppLocale(context, "es")     -> Spanish
     *   setAppLocale(context, "hi")     -> Hindi
     *   setAppLocale(context, null)     -> Follow system
     */
    fun setAppLocale(context: Context, languageTag: String?) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            // Android 13+ — use system LocaleManager
            val localeManager = context.getSystemService(LocaleManager::class.java)
            localeManager.applicationLocales = if (languageTag.isNullOrEmpty()) {
                LocaleList.getEmptyLocaleList()
            } else {
                LocaleList.forLanguageTags(languageTag)
            }
        } else {
            // Android 5.0–12 — use AppCompat backport
            val localeList = if (languageTag.isNullOrEmpty()) {
                LocaleListCompat.getEmptyLocaleList()
            } else {
                LocaleListCompat.forLanguageTags(languageTag)
            }
            AppCompatDelegate.setApplicationLocales(localeList)
        }
    }

    /**
     * Get the currently active language tag, or null if following system.
     */
    fun getCurrentLanguageTag(context: Context): String? {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            val localeManager = context.getSystemService(LocaleManager::class.java)
            localeManager.applicationLocales[0]?.toLanguageTag()
        } else {
            AppCompatDelegate.getApplicationLocales()[0]?.toLanguageTag()
        }
    }
}

A few things worth noting:

  • Empty locale list = "follow system". This is the documented signal for both APIs. Don't pass null to forLanguageTags() or you'll get a crash.
  • The backport (AppCompatDelegate) persists the choice for you in its own internal storage. You don't need to wire up SharedPreferences yourself.
  • On Android 13+, the system LocaleManager persists too — and the user's choice survives even if your app data is partially cleared.

LocaleManager vs AppCompatDelegate

Here's a quick comparison so you know what each path does:

Feature LocaleManager (API 33+) AppCompatDelegate (backport)
Min API 33 14 (works back to 5.0 via AppCompat)
Persistence System-managed App-internal SharedPreferences
Visible in system Settings Yes (per-app language) No
Survives app data clear Partially (system stores it) No
Triggers Activity recreate Yes Yes
Locale fallback System handles AppCompat handles

The good news: you write the call once. The wrapper above branches automatically.

A ViewModel for Language State

Wrapping the locale helper in a ViewModel keeps your Compose UI clean and gives you a single source of truth:

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

data class Language(val tag: String?, val label: String)

class LanguageViewModel(application: Application) : AndroidViewModel(application) {

    val supportedLanguages = listOf(
        Language(null, "System default"),
        Language("en", "English"),
        Language("es", "Español"),
        Language("hi", "हिन्दी"),
        Language("ar", "العربية")
    )

    private val _selectedTag = MutableStateFlow(
        LocaleHelper.getCurrentLanguageTag(application)
    )
    val selectedTag: StateFlow<String?> = _selectedTag.asStateFlow()

    fun changeLanguage(tag: String?) {
        LocaleHelper.setAppLocale(getApplication(), tag)
        _selectedTag.value = tag
    }
}

A Compose Settings Screen

Now the UI. This is a minimal language picker using Material 3's ExposedDropdownMenuBox:

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LanguageSettingsScreen(
    viewModel: LanguageViewModel = viewModel()
) {
    val selectedTag by viewModel.selectedTag.collectAsState()
    val languages = viewModel.supportedLanguages
    val currentLanguage = languages.firstOrNull { it.tag == selectedTag }
        ?: languages.first()

    var expanded by remember { mutableStateOf(false) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Text(
            text = stringResource(R.string.title_settings),
            style = MaterialTheme.typography.headlineSmall
        )

        Spacer(Modifier.height(24.dp))

        ExposedDropdownMenuBox(
            expanded = expanded,
            onExpandedChange = { expanded = it }
        ) {
            OutlinedTextField(
                value = currentLanguage.label,
                onValueChange = {},
                readOnly = true,
                label = { Text(stringResource(R.string.label_language)) },
                trailingIcon = {
                    ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
                },
                modifier = Modifier
                    .menuAnchor()
                    .fillMaxWidth()
            )

            ExposedDropdownMenu(
                expanded = expanded,
                onDismissRequest = { expanded = false }
            ) {
                languages.forEach { language ->
                    DropdownMenuItem(
                        text = { Text(language.label) },
                        onClick = {
                            viewModel.changeLanguage(language.tag)
                            expanded = false
                        }
                    )
                }
            }
        }

        Spacer(Modifier.height(24.dp))

        Text(
            text = stringResource(R.string.msg_welcome),
            style = MaterialTheme.typography.bodyLarge
        )
    }
}

When the user picks a language, changeLanguage() calls into the helper, which calls LocaleManager or AppCompatDelegate. The system handles the Activity recreation, your composables re-resolve stringResource() against the new locale, and the welcome message updates instantly.

You don't need recreate(). You don't need to wrap attachBaseContext. You don't need Configuration.setLocale. It just works.

Handling System Default

The "System default" entry maps to a null tag. When the user picks it, we pass null to setAppLocale, which sets an empty locale list. The system then falls back to whatever the device language is.

If the user later changes their device language in system settings, your app picks it up automatically — no code on your side. That's the beauty of the empty-list signal.

RTL Gotcha

Arabic, Hebrew, Urdu, and Farsi are right-to-left languages. Compose handles most of the layout mirroring for you, but you need two things:

1. Declare RTL support in AndroidManifest.xml:

<application
    android:supportsRtl="true"
    ...>

2. Use start and end instead of left and right in your modifiers and alignments:

// Good — mirrors automatically in RTL
Row(horizontalArrangement = Arrangement.Start) { ... }
Modifier.padding(start = 16.dp)

// Bad — stays on the physical left even in Arabic
Modifier.padding(left = 16.dp)

If you stick to start/end, your layouts will mirror correctly when the user switches to Arabic.

Common Mistakes

1. Forgetting to add resourceConfigurations in build.gradle

Without it, Play Store may strip locales from your App Bundle download. Users get the default values/ strings even after switching language.

2. Hardcoding strings in Composables

// Bad
Text("Welcome")

// Good
Text(stringResource(R.string.msg_welcome))

Hardcoded strings won't react to locale changes because they're not resources.

3. Caching context references across recreates

When setApplicationLocales triggers an Activity recreate, your old Activity and its Context get destroyed. If you stored a reference somewhere (a singleton, a static field), you're now holding a stale Context. Always grab the current context inside your composable or ViewModel.

4. Calling recreate() yourself

You don't need to. The locale APIs trigger recreation for you. Calling recreate() on top causes a double-recreate and looks janky.

5. Mixing Locale("es") with forLanguageTags("es")

Stick to language tags (forLanguageTags). The Locale constructor is older and has some weird edge cases around region codes. Tags like "zh-CN" and "pt-BR" work cleanly.

6. Using only AppCompatDelegate on Android 13+

This one bit me. If you only call AppCompatDelegate.setApplicationLocales() and skip the LocaleManager path, the change works on most devices — but on some Android 13+ OEM builds it silently fails to persist after process death. The user picks Spanish, kills the app, reopens it, and they're back to English.

The fix is the dual-path wrapper shown earlier: call LocaleManager directly on API 33+, fall back to AppCompatDelegate only on older versions. Don't rely on AppCompat alone once you're targeting Android 13+.

7. Crashing when parsing locale tags from unknown sources

If you ever parse a language tag from an external source — a config file, a deep link parameter, an app split name, anything you didn't generate yourself — wrap it in a try/catch:

val locale = try {
    Locale.forLanguageTag(tag).takeIf { it.language.isNotEmpty() }
} catch (e: IllformedLocaleException) {
    null
}

Strings that look like locale tags but aren't (config.etc2, group_high_performance, random user input) will throw IllformedLocaleException from the strict parser. I've seen this crash a release build because a code path assumed every config.* string was a valid locale. Validate at the boundary.

Testing Tips

When testing locally:

  • Toggle through every language at least once after a fresh install — it catches missing translations early
  • Test on both an Android 12 device and an Android 13+ device — the code paths are different
  • Open system Settings → Apps → Your App on Android 13+ — you should see a "Language" entry. If you don't, check that LocaleConfig is set up (more on that below)
  • Rotate the screen after changing language — make sure state survives

Bonus: LocaleConfig for Android 13+

For Android 13+ to show your language picker in system Settings, declare a LocaleConfig XML file:

res/xml/locales_config.xml:

<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
    <locale android:name="en"/>
    <locale android:name="es"/>
    <locale android:name="hi"/>
    <locale android:name="ar"/>
</locale-config>

Reference it in AndroidManifest.xml:

<application
    android:localeConfig="@xml/locales_config"
    ...>

Now your app shows up in Settings → System → Languages → App languages, and users can change your app's language without ever opening it.

Wrapping Up

The Android 13 per-app language APIs solve a problem that haunted Android developers for a decade. The migration is small — replace your old ContextWrapper hack with LocaleHelper.setAppLocale() and let the system handle the rest. You get state preservation, system Settings integration, and a cleaner codebase as a bonus.

If you're building the rest of your settings screen in Compose, you might also be looking at theme switching — light/dark/system. The same ViewModel + StateFlow pattern works there too, just swap LocaleHelper for an AppCompatDelegate.setDefaultNightMode() call. For a deeper look at structuring screens like this, see my guide on MVVM with Jetpack Compose authentication patterns — same architecture applies to any settings flow.

And if you're starting a fresh project, set up your values/, values-es/, etc. folders from day one — even with just one language. Retrofitting localization into a codebase full of hardcoded strings is one of the most tedious refactors I've ever done. Future you will be grateful.

If you're still evaluating whether Compose is right for your project, I wrote about the strengths and weaknesses of Jetpack Compose from a production perspective — localization is one of the areas where Compose actually shines compared to the old XML + Activity.recreate() dance.

Keep Reading

Android Jetpack Compose Kotlin Localization

Author

Sagar Maiyad
Written By
Sagar Maiyad

Sagar Maiyad - Android developer specializing in Kotlin, Jetpack Compose, and modern Android architecture. Sharing practical tutorials and real-world development insights.

View All Posts →

Related Posts

Latest Tags