Language Localization in Android with Example

I still remember shipping an app that silently assumed English-only text. A week after launch, crash reports poured in from devices in São Paulo and Jakarta because formatted dates and missing strings blew up. That sting taught me that language localization is not a garnish—it is core functionality. In this walkthrough I will show you, step by step, how I build modern Android apps that greet people in their own language, adapt resources at runtime, and stay maintainable as product teams add more locales.

Why localization must be a first-class feature

  • Android runs on hundreds of hardware tiers, but the bigger variability is human language. Ignoring it means broken UI, mistranslated errors, and alienated users.
  • Translations touch strings, plurals, formats, RTL layout, media assets, and even analytics labels. Treat it as an architecture concern, not a late-stage patch.
  • Modern Play Store quality guidelines increasingly check for proper locale declarations and screenshots. Shipping multilingual support can lift store rankings and retention.
  • In a privacy-first 2026 world, on-device language choice matters: some users prefer a different app language than the system’s, and you should respect that without server calls.

Designing a locale strategy before writing code

  • Scope the locales: Start with two languages (e.g., English and Hindi) so you can validate folder structure and QA flows. Add more only after pipeline tooling is proven.
  • Choose the locale source: You can read the system locale, let users override it per app, or tie it to account preferences fetched from the backend. I prefer an in-app switch plus a persisted choice because it keeps UX predictable when the system language changes unexpectedly.
  • Define a locale manager: Centralize locale changes in a single component that updates context, resources, and persisted preference. Avoid sprinkling Locale() creation across activities.
  • Plan for dynamic delivery: If your app will support dozens of languages, split them with Play Feature Delivery so rarely used packs don’t bloat the base APK.
  • Agree on terminology early: Before translators touch the project, align on glossary terms (product name, legal notices, units). This prevents costly rework.
  • Decide fallback rules: When a string is missing in the selected locale, should you fall back to English, the device language, or another default? Document it so QA can test predictably.

Project setup for multiple locales

  • Resource folders: Create values/strings.xml for the default locale and one folder per additional language, such as values-hi/strings.xml. Keep keys identical across files; mismatched keys are the top source of runtime crashes.
  • Gradle settings: Set resConfigs only if you intentionally limit shipped locales. Otherwise, leave it open to include all provided translations. In 2026, AGP 9+ handles resource shrinking more intelligently, so you can often skip manual filtering.
  • Lint and CI: Enable Android Lint checks MissingTranslation and ExtraTranslation. Configure CI to fail builds when keys are missing or extra. This prevents silent regressions when teams add features fast.
  • Branching model: Require that new features add English strings and placeholder translations (even if duplicated) so translators see the delta clearly.
  • Vector assets: Prefer text-free vector drawables; when text is unavoidable (e.g., logos), store per-locale variants in drawable- folders.

Example app: two languages, one toggle

Below is a minimal app using Kotlin, ViewBinding, and a simple in-app switch. It keeps the default language in values/strings.xml and Hindi in values-hi/strings.xml.

strings.xml (default)


Locale Lab
This app proves that language switching works.
Change language

strings.xml (Hindi)


लोकल लैब
यह ऐप दिखाता है कि भाषा स्विच कैसे काम करता है।
भाषा बदलें

activity_main.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"

android:layoutwidth="matchparent"

android:layoutheight="matchparent"

android:gravity="center"

android:orientation="vertical"

android:padding="24dp">

<TextView

android:id="@+id/message"

android:layoutwidth="wrapcontent"

android:layoutheight="wrapcontent"

android:gravity="center"

android:text="@string/custom_message"

android:textSize="22sp" />

<Button

android:id="@+id/changeLangButton"

android:layoutwidth="wrapcontent"

android:layoutheight="wrapcontent"

android:layout_marginTop="16dp"

android:text="@string/change_language" />

LocaleManager.kt

package com.example.localelab

import android.content.Context

import android.content.SharedPreferences

import android.os.Build

import java.util.Locale

class LocaleManager(context: Context) {

private val prefs: SharedPreferences =

context.getSharedPreferences("localeprefs", Context.MODEPRIVATE)

fun getSavedLocale(): Locale? {

val tag = prefs.getString("locale_tag", null) ?: return null

return Locale.forLanguageTag(tag)

}

fun saveLocale(locale: Locale) {

prefs.edit().putString("locale_tag", locale.toLanguageTag()).apply()

}

fun updateContext(base: Context, locale: Locale): Context {

val config = base.resources.configuration

val newLocale = Locale(locale.language, locale.country)

Locale.setDefault(newLocale)

if (Build.VERSION.SDKINT >= Build.VERSIONCODES.TIRAMISU) {

config.setLocales(android.os.LocaleList(newLocale))

return base.createConfigurationContext(config)

}

@Suppress("DEPRECATION")

config.setLocale(newLocale)

@Suppress("DEPRECATION")

return base.createConfigurationContext(config)

}

}

MainActivity.kt

package com.example.localelab

import android.os.Bundle

import androidx.activity.ComponentActivity

import androidx.activity.viewModels

import androidx.lifecycle.lifecycleScope

import com.example.localelab.databinding.ActivityMainBinding

import java.util.Locale

import kotlinx.coroutines.flow.collectLatest

class MainActivity : ComponentActivity() {

private lateinit var binding: ActivityMainBinding

private val viewModel: MainViewModel by viewModels()

override fun attachBaseContext(newBase: android.content.Context) {

val manager = LocaleManager(newBase)

val target = manager.getSavedLocale() ?: Locale.getDefault()

val wrapped = manager.updateContext(newBase, target)

super.attachBaseContext(wrapped)

}

override fun onCreate(savedInstanceState: Bundle?) {

super.onCreate(savedInstanceState)

binding = ActivityMainBinding.inflate(layoutInflater)

setContentView(binding.root)

lifecycleScope.launchWhenStarted {

viewModel.state.collectLatest { state ->

val manager = LocaleManager(this@MainActivity)

val wrapped = manager.updateContext(this@MainActivity, state.locale)

applyOverrideConfiguration(wrapped.resources.configuration)

binding.message.text = wrapped.getString(R.string.custom_message)

binding.changeLangButton.text = wrapped.getString(R.string.change_language)

}

}

binding.changeLangButton.setOnClickListener {

viewModel.toggleLocale()

}

}

}

MainViewModel.kt

package com.example.localelab

import androidx.lifecycle.ViewModel

import kotlinx.coroutines.flow.MutableStateFlow

import kotlinx.coroutines.flow.StateFlow

import java.util.Locale

class MainViewModel : ViewModel() {

private val locales = listOf(Locale.ENGLISH, Locale("hi"))

private var index = 0

private val _state = MutableStateFlow(LocaleState(locale = locales[index]))

val state: StateFlow = _state

fun toggleLocale() {

index = (index + 1) % locales.size

_state.value = LocaleState(locale = locales[index])

}

}

data class LocaleState(val locale: Locale)

This sample keeps logic small: a view model rotates between English and Hindi, the activity wraps context with the selected locale, and UI pulls strings from the updated configuration. In production, persist the selection through LocaleManager.saveLocale and restore it in attachBaseContext.

Handling configuration changes without surprises

  • attachBaseContext: Overriding this method ensures the activity boots with the chosen locale before layout inflation. It prevents a brief flash in the wrong language.
  • applyOverrideConfiguration: After updating the locale, apply the new configuration so existing views refresh resources without recreating the activity.
  • Process death: Store the selected tag in SharedPreferences or DataStore. When the process restarts, attachBaseContext pulls it back, keeping the experience consistent.
  • Multi-activity apps: Wrap context in a base activity or a ContextWrapper to avoid duplicated code.
  • Fragments: When you host fragments, inflate them with the activity’s updated context; otherwise, they may keep the old locale until recreated.

Formatting numbers, dates, and currencies

Strings are only half the story. In 2026, Play Store review commonly checks for locale-correct formats. Use the user’s locale for everything human-facing.

  • Dates: DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) handles day-month order and script differences.
  • Numbers: NumberFormat.getInstance(locale).format(value) respects grouping separators.
  • Currency: NumberFormat.getCurrencyInstance(locale) uses the correct symbol and spacing.
  • Plural rules: Android’s plurals resources already apply locale rules. Supply entries for one, few, many, and other when relevant languages require them.

Example: localized date and price

val locale = Locale("hi")

val today = java.time.LocalDate.now()

val dateFormatter = java.time.format.DateTimeFormatter

.ofLocalizedDate(java.time.format.FormatStyle.MEDIUM)

.withLocale(locale)

val priceFormatter = java.text.NumberFormat.getCurrencyInstance(locale)

val dateText = dateFormatter.format(today)

val priceText = priceFormatter.format(1999.0)

In Hindi, this will display a Devanagari date and the rupee symbol without custom logic.

Dealing with right-to-left layouts

Supporting RTL languages like Arabic is straightforward if you let Android handle mirroring.

  • Set android:supportsRtl="true" in the manifest.
  • Avoid absolute layout directions; use start and end instead of left and right in XML and code.
  • Test with adb shell setprop persist.sys.locale ar; adb reboot on a spare device or emulator to validate mirrored icons and paddings.
  • If you draw custom canvas graphics, respect layoutDirection and mirror manually when necessary.

Dynamic feature modules for translation-heavy apps

If you ship 20+ locales with audio or images, APK size balloons. Dynamic feature modules keep the base lean.

  • Place locale-specific media (audio prompts, tutorial videos) in a language feature module named feature-lang-fr, feature-lang-pt, etc.
  • Mark each module with dist:module attributes so Play delivers them on demand based on user language.
  • In code, gate access with SplitInstallManager. Request the pack when the user switches to that language, and cache the selection.
  • Keep code that references those assets defensive: show a lightweight placeholder until the pack downloads.

AI-assisted translation workflow (2026 reality)

Human translators remain essential, but 2026 workflows mix AI and human review.

  • Drafting: Use on-device translation models (e.g., ML Kit or custom TensorFlow Lite) to create first-pass strings so engineers can validate UI fit early.
  • Review queue: Send generated strings to translators through a TMS (Phrase, Lokalise). Include screenshots produced by your UI tests to preserve context.
  • Glossaries: Maintain a project glossary in version control. Integrate it into your CI so inconsistent terminology is flagged before merging.
  • Quality gates: Add a screenshot diff step in CI that renders each locale. Fail builds when text truncates, overflows, or collides with UI elements.
  • Offline privacy: For sensitive apps, keep translation inference on-device to avoid transmitting user-visible text to cloud services.

Common mistakes and how I avoid them

  • Missing keys: Always add lint checks; fail the build when translations are missing.
  • Incorrect locale tags: Use Locale.forLanguageTag("pt-BR") instead of the legacy two-letter constructor when you need region-specific resources.
  • Caching old configuration: Never keep a static reference to Resources; re-fetch after locale change.
  • String concatenation: Use getString(R.string.items_count, count) with proper placeholders. Concatenation breaks grammar in many languages.
  • Images with text: Replace embedded text with vector drawables or use TextView overlays so translations do not require asset edits.
  • Forgetting direction-aware padding: Use android:paddingStart/paddingEnd to let RTL mirroring work automatically.
  • Hard-coded fonts: Some scripts need fallback glyphs. Test that your chosen font covers glyphs for every supported language, or declare font fallback chains.

Testing strategy that actually catches issues

  • Automated locale matrix: Run instrumented tests on at least three locales: one LTR Latin (en), one non-Latin LTR (hi or ja), and one RTL (ar). This catches font, width, and mirroring defects.
  • Screenshot comparisons: Use tools like Paparazzi or Shot to generate locale-specific screenshots in CI. Diff them against baselines to detect truncation.
  • Accessibility checks: Verify that TalkBack reads localized text and that contentDescription values are translated.
  • Performance: Locale switching should stay under 50ms on mid-tier devices. If switching triggers expensive recomposition, lazy-load heavy assets and cache them.
  • Unit tests for formatting: Add tests that assert formatted dates, currencies, and pluralizations for each critical locale to prevent regressions when libraries update.

When not to add an in-app language switch

  • If your product must always match the device language for compliance (e.g., regulated healthcare apps), respect the system setting and avoid overrides.
  • If you cannot fund professional translation, shipping partial translations may erode trust. In that case, keep a single high-quality locale until you can cover the whole surface.
  • If your app is kiosk-style and locked down, a hidden or accidental language switch can be harmful. Gate the switch behind admin settings.

Production hardening: persistence and process restarts

Persisting the chosen locale matters when the OS kills your process.

  • Save the toLanguageTag() in SharedPreferences or Jetpack DataStore whenever the user switches.
  • Restore it in attachBaseContext of every activity or in a custom Application subclass that wraps the base context before components initialize.
  • If you use dependency injection (Hilt), provide a @Singleton LocaleManager that reads the saved tag once and exposes a Flow so UI can react.

Example with DataStore

class LocaleRepository(context: Context) {

private val Context.dataStore by preferencesDataStore("locale_store")

private val KEY = stringPreferencesKey("locale_tag")

val localeFlow = context.dataStore.data

.map { prefs -> prefs[KEY]?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault() }

suspend fun setLocale(locale: Locale) {

context.dataStore.edit { prefs ->

prefs[KEY] = locale.toLanguageTag()

}

}

}

This approach keeps state resilient to process death and device reboots.

Edge cases worth testing in 2026

  • Per-app language (Android 13+): Users can pick a language per app in system settings. Ensure your app respects that choice even if you have an internal switch. Query AppLocaleManager on startup when available.
  • Instant apps: Provide at least one fallback locale to avoid missing resources when the instant experience launches from the web.
  • Split-screen/multi-window: Activities can be recreated independently; verify each window keeps the selected locale.
  • Locale lists: Some users configure multiple preferred languages. Android will try them in order; confirm your resources cover the top choices.
  • Downloaded fonts: If you rely on DownloadableFonts, handle the brief window before the font arrives; show a fallback font without clipping characters.
  • Voice input: When you switch languages, update RecognizerIntent.EXTRA_LANGUAGE so speech-to-text matches the UI language.

Deeper view: per-feature localization boundaries

Large apps often have multiple teams owning screens. Define clear boundaries so teams can move independently.

  • Module ownership: Each feature module maintains its own strings.xml and tests. Shared strings (like OK/Cancel) live in a core UI module.
  • Versioning translations: Tag translation PRs with the feature version they belong to. If you cherry-pick fixes, you can cherry-pick the matching translations.
  • Experiment flags: When A/B testing, include experiment variants in translation keys from day one. Otherwise, one cohort sees untranslated text.

Gradle and build-time optimizations

  • Turn on android.enableAppLocaleWarning=true to surface per-app language issues during builds.
  • Use resguard or resource shrinking only after verifying that locale-specific resources are preserved. Shrinking misconfigurations can drop non-default locales silently.
  • Generate translation keys from source-of-truth files (YAML/JSON) using a Gradle task, then render strings.xml to avoid manual drift.
  • If you use Compose, keep string resources in strings.xml and call stringResource(id); avoid hard-coded strings inside composables.

Compose-specific considerations

  • Use CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) for custom RTL screens; most built-in components honor locale automatically.
  • For dynamic locale changes, hoist a MutableState in a shared ViewModel and recreate the Configuration for LocalContext using a ContextThemeWrapper.
  • Remember that preview uses the IDE locale. Add @Preview(locale = "ar") and @Preview(locale = "hi") variants to catch truncation early.
  • Replace text-in-Image assets with Icon + Painter so Compose can mirror and recolor them as needed.

Keeping analytics and logging locale-aware

  • Include Locale.getDefault().toLanguageTag() in analytics events to slice retention by language.
  • When logging errors that contain user-visible text, avoid logging the translated string; log the key instead to keep logs language-neutral and privacy-friendly.
  • If you run experiments by locale, ensure remote config fetches are keyed by language tag, not country, unless you explicitly need regional splits.

Offline-first and limited-data scenarios

  • Cache translations locally; never block on network to show UI.
  • If you must fetch translations (e.g., remote dynamic content), ship a minimal offline baseline so the app still launches when the network is down.
  • Compress locale packs with Brotli or WebP for text-in-image assets; they decompress quickly on mid-tier devices.
  • Provide a “Reset to device language” action so users who get stuck in an unfamiliar language can recover offline.

Accessibility and localization together

  • Translate contentDescription, hint, and labelFor attributes. Screen readers surface these first.
  • Keep semantic ordering. In RTL, ensure traversal order matches reading order by using android:layoutDirection and android:importantForAccessibility when custom layouts reorder views.
  • Avoid embedding direction-specific arrows in text (e.g., "→"). Use DrawableStart icons so mirroring works automatically.
  • For captions and transcripts in multimedia, ship per-locale subtitle files and set the media player to the selected locale on load.

Robust fallback design

  • If a translation is missing, prefer showing the default language rather than an empty string. An empty label harms usability more than a foreign language snippet.
  • Build a debug overlay that shows string keys and source locale; this speeds up QA in partially translated builds.
  • Keep a values-zz pseudo-locale that exaggerates text length (e.g., surrounds strings with brackets and pads them). Turn it on in debug to reveal clipping issues early.

Performance profiling for locale switching

  • Measure locale-change latency with a stopwatch around updateContext + UI refresh. If it exceeds ~50ms on mid-tier hardware, profile allocations.
  • Avoid re-initializing heavy singletons on every locale change. Cache data that is locale-agnostic; reload only locale-dependent pieces (strings, formats, assets).
  • If you pre-render Compose previews for multiple locales in CI, parallelize builds to keep pipeline times reasonable.

Security and privacy notes

  • Do not store sensitive user text in translation files; treat them as publicly readable assets.
  • When using AI translation services, redact PII and consider on-device models for private domains.
  • If logs or crash reports include localized text, ensure they don’t leak secrets. Prefer logging keys plus parameters.

Checklist: shipping a new locale

  • Resource folder exists and keys match default.
  • Plurals cover required quantities for that language.
  • RTL verified (if applicable) with screenshots.
  • Date, number, and currency formats confirmed in unit tests.
  • Accessibility labels translated.
  • Dynamic feature packs (if any) download and load successfully.
  • Locale persists across process death and cold start.
  • Store listing assets (screenshots) updated for the new language.

What I do on day one of adding a new language

1) Clone values/strings.xml into values-/strings.xml and add translator comments for ambiguous strings.

2) Add a pseudo-locale run (values-zz) to expose width and truncation problems.

3) Add @Preview(locale = "") to key Compose screens or Paparazzi snapshots for classic views.

4) Update CI lint rules to fail on missing translations.

5) Brief QA about the fallback rules and recovery paths.

End-to-end user story: adding Spanish to the sample app

  • Goal: Add Spanish (es) with full persistence.
  • Steps:

– Create values-es/strings.xml with translations.

– Extend the locale list in MainViewModel to listOf(Locale.ENGLISH, Locale("hi"), Locale("es")).

– Persist selection via LocaleManager.saveLocale() when toggled; read in attachBaseContext.

– Add a unit test that formats currency in Spanish: assertEquals("1.999,00 €", formatter.format(1999.0)) on a euro locale.

– Run Paparazzi screenshots for es to confirm layouts still fit.

  • Result: Users can cycle English → Hindi → Spanish, and the app reopens in the last selected language after a force stop.

Troubleshooting guide

  • Symptom: UI flashes English before switching to target language on cold start.

Fix: Apply locale in attachBaseContext before super.onCreate; avoid switching after layout inflation.

  • Symptom: Some screens ignore the new language after switching.

Fix: Ensure each activity wraps context; for fragments, re-inflate or refresh strings after configuration update.

  • Symptom: Numbers still show with English separators.

Fix: Use NumberFormat with the active locale; avoid manual String.format with hard-coded patterns.

  • Symptom: Vector icons not mirrored in RTL.

Fix: Enable android:supportsRtl and avoid baked-in arrow directions inside assets; use auto-mirroring drawables.

  • Symptom: Crash Resources$NotFoundException after adding a locale.

Fix: Check for mismatched string keys or missing plural quantities; rerun lint.

Architectural patterns that scale

  • Single source of truth: Keep locale state in a shared component (e.g., LocaleRepository) and expose a Flow/StateFlow. UI layers collect and react; they do not store their own copies.
  • Dependency injection: Inject LocaleManager where needed; avoid static holders. This simplifies testing.
  • Use ViewModels for toggles: Let ViewModels own the switch logic, so orientation changes do not reset the selection.
  • Composable navigation: When using Jetpack Navigation, pass only stable identifiers; let screens read the current locale from the shared state instead of arguments.

Monitoring localization health in production

  • Track crashes by locale tag to spot language-specific issues.
  • Track funnel metrics per locale; a sudden drop may indicate a broken string or clipped CTA.
  • Add a runtime sanity check in debug builds that compares current locale tag with resources’ Configuration to catch mismatches.

Developer ergonomics tips

  • Add a quick settings tile or debug menu action that cycles locales without rebooting the device.
  • Place translator comments () above ambiguous strings; they show up in most TMS exports.
  • Use tools:targetApi="33" in previews to test per-app language behavior.
  • Keep string keys descriptive but stable; avoid renaming keys unless necessary, because renames churn translation memory.

Minimal Compose rewrite of the sample

If you prefer Compose, the same idea stays compact.

@Composable

fun LocaleScreen(viewModel: LocaleViewModel = viewModel()) {

val state by viewModel.state.collectAsState()

val context = LocalContext.current

val localeContext = remember(state.locale) {

LocaleManager(context).updateContext(context, state.locale)

}

CompositionLocalProvider(LocalContext provides localeContext) {

Column(

modifier = Modifier

.fillMaxSize()

.padding(24.dp),

verticalArrangement = Arrangement.Center,

horizontalAlignment = Alignment.CenterHorizontally

) {

Text(text = stringResource(id = R.string.custom_message), fontSize = 22.sp)

Spacer(Modifier.height(16.dp))

Button(onClick = { viewModel.toggleLocale() }) {

Text(text = stringResource(id = R.string.change_language))

}

}

}

}

This keeps the context update inside remember, ensuring Compose recomposes with the right resources without recreating heavy objects.

Recovery UX for “lost in translation” moments

  • Provide an always-visible icon (e.g., a globe) that opens a language picker using native language names ("English", "हिन्दी", "Español") so users can recognize their own language even if they switched by accident.
  • Add a one-time tooltip explaining where to find language settings. Users in multilingual households often lend phones to others; discoverability matters.

Content design for localization

  • Write source strings short and clear. Shorter English often survives expansion better when translated.
  • Avoid idioms, sarcasm, or culture-specific references in source text; they translate poorly.
  • Use placeholders with full context: "Pay %1$s on %2$s" instead of concatenated pieces; translators can reorder arguments correctly for their grammar.

Play Store considerations

  • Upload localized screenshots and descriptions; Play surfaces them based on the user’s preferred language.
  • Declare resConfigs only if you intentionally restrict locales; otherwise, you might accidentally block translations from shipping.
  • Keep your app label (app_name) translated; users browse in their own language and labels influence install rates.

Backend and API interactions

  • Send Accept-Language headers when requesting server-driven content so the backend can respond in the right language. Fall back gracefully if the server lacks that locale.
  • If your backend returns text, cache it keyed by locale tag. In offline mode, show the last successful localized response.
  • For analytics, store server-side locale separately from client locale to detect mismatches.

Final thoughts

Treat localization as infrastructure. Decide your locale strategy early, enforce it with lint and CI, and keep the user in control with a predictable switch and solid fallbacks. The small sample app above proves the mechanics; the rest of this guide gives you the practices to scale from two languages to dozens without drowning in regressions. When you invest in localization upfront, you don’t just avoid crashes—you invite more people in, on their terms.

Scroll to Top