The Complete Guide to Android App Localization

From strings.xml to Play Store metadata: localize your Android app with Kotlin, Jetpack Compose, Fastlane, and automated AI translation.

1

Set Up Your Android Project for Localization

Android uses a folder-based convention for localization. Your default strings live in res/values/strings.xml, and translated strings go in locale-specific folders like res/values-de/, res/values-ja/, etc.

Android Project Structure
// Android project structure for localization:
// res/
// ├── values/              ← Default (fallback) locale
// │   └── strings.xml
// ├── values-de/           ← German
// │   └── strings.xml
// ├── values-ja/           ← Japanese
// │   └── strings.xml
// └── values-es/           ← Spanish
//     └── strings.xml
//
// Folder naming: values-{language} or values-{language}-r{Region}
// Examples: values-pt-rBR, values-zh-rCN, values-zh-rTW
The res/values/ folder is your fallback locale. If a string is missing from a locale-specific folder, Android loads it from the default. But if a string is missing from the default — your app crashes on unsupported locales.
2

Create strings.xml

Android string resources use XML with '<string>' elements inside a '<resources>' root. Use %s for string placeholders, %d for integers, and %1$s/%2$s for positional arguments that translators can reorder.

res/values/strings.xml
<!-- res/values/strings.xml -->
<resources>
    <string name="welcome_title">Welcome to MyApp</string>
    <string name="login_button">Sign In</string>
    <string name="settings_label">Settings</string>
    <string name="greeting">Hello, %s!</string>        <!-- %s = string -->
    <string name="item_count">%d items</string>          <!-- %d = integer -->
    <string name="app_name" translatable="false">MyApp</string>
</resources>
Unescaped apostrophes crash the XML parser silently. Use \' or wrap the value in double quotes. Also: a missing string in the default values/strings.xml causes a hard crash — not a graceful fallback like iOS.
Common strings.xml Mistakes
<!-- ❌ Common mistakes in strings.xml: -->

<!-- Unescaped apostrophe — crashes XML parser silently -->
<string name="message">It's a great day</string>

<!-- Missing from default values/strings.xml — app crashes -->
<!-- (only exists in values-de/strings.xml) -->

<!-- ✅ Correct versions: -->
<string name="message">It\'s a great day</string>
<!-- Or wrap in double quotes: -->
<string name="message">"It's a great day"</string>
3

Handle Plurals

Android uses '<plurals>' elements with quantity attributes: zero, one, two, few, many, other. Each target language may need different categories — Arabic uses all 6, Russian needs few/many, Japanese only uses other.

res/values/strings.xml
<!-- res/values/strings.xml -->
<resources>
    <plurals name="items_count">
        <item quantity="zero">No items</item>
        <item quantity="one">%d item</item>
        <item quantity="other">%d items</item>
    </plurals>
</resources>

<!-- Usage in Kotlin: -->
<!-- val text = resources.getQuantityString(
    R.plurals.items_count,
    count,    // selects plural form
    count     // format argument
) -->
The quantity attribute selects the plural form based on CLDR rules for the device locale. Always include 'other' as a fallback — it's the only category guaranteed to exist in every language.
4

Use in Code: Kotlin and Jetpack Compose

Traditional Android uses getString(R.string.key) and resources.getQuantityString(). Jetpack Compose uses stringResource(R.string.key) and pluralStringResource(). Both resolve the correct translation based on the device locale at runtime.

WelcomeScreen.kt
// Traditional Android (Activity/Fragment)
val title = getString(R.string.welcome_title)
val greeting = getString(R.string.greeting, userName)
val items = resources.getQuantityString(
    R.plurals.items_count, count, count
)

// Jetpack Compose
@Composable
fun WelcomeScreen(userName: String, itemCount: Int) {
    // ✅ stringResource — Compose-aware, triggers recomposition
    Text(text = stringResource(R.string.welcome_title))

    // ✅ With format arguments
    Text(text = stringResource(R.string.greeting, userName))

    // ✅ Plurals — count passed TWICE
    Text(text = pluralStringResource(
        R.plurals.items_count,
        itemCount,    // selects plural form
        itemCount     // format argument
    ))
}
pluralStringResource(R.plurals.items, count, count) — the count parameter is passed twice. First selects the plural form, second is the format argument. Missing the second count is the #1 Compose pluralization bug.
5

String Arrays and Formatted Strings

Use '<string-array>' for ordered lists (e.g., dropdown options, onboarding steps). Use positional format args (%1$s, %2$d) in formatted strings so translators can reorder words without breaking the sentence structure.

res/values/strings.xml
<!-- res/values/strings.xml -->
<resources>
    <!-- String array for dropdown/list -->
    <string-array name="sort_options">
        <item>Most Recent</item>
        <item>Most Popular</item>
        <item>Price: Low to High</item>
        <item>Price: High to Low</item>
    </string-array>

    <!-- Positional format args for reordering -->
    <string name="welcome_message">
        Hello %1$s, you have %2$d new messages
    </string>
    <!-- Translators can reorder: -->
    <!-- %2$d neue Nachrichten für %1$s -->
</resources>
Positional args like %1$s let translators reorder parameters freely. 'Hello %1$s, you have %2$d items' can become '%2$d items for %1$s' in languages with different word order — without any code changes.
6

Localize Google Play Metadata with Fastlane

Use Fastlane's supply command to manage Play Store metadata — title, short description, full description, and changelogs — as plain text files organized by locale in your repository.

Terminal
# Install Fastlane
$ gem install fastlane

# Initialize supply for Play Store metadata
$ fastlane supply init

# Directory structure created:
# fastlane/metadata/android/
# ├── en-US/
# │   ├── title.txt              # App name (50 chars)
# │   ├── short_description.txt  # Short desc (80 chars)
# │   ├── full_description.txt   # Full desc (4000 chars)
# │   └── changelogs/
# │       └── default.txt        # What's New
# ├── de-DE/
# │   └── ...
# └── ja-JP/
#     └── ...

# Push metadata to Play Store:
$ fastlane supply
Localizing your Play Store listing increases downloads 30%+ in non-English markets. Title, short description, and full description are indexed for search — translating them is the highest-ROI localization you can do.
7

Test Your Localization

Test with emulator locale switching, Compose previews with custom LocaleList, and pseudolocales in Developer Options. Use resConfigs in Gradle to strip unwanted locale resources from third-party libraries.

Testing Localization
// 1. Emulator: Settings > System > Language > Add language

// 2. Compose Preview with locale:
@Preview
@Composable
fun WelcomePreview() {
    val config = Configuration(resources.configuration).apply {
        setLocale(Locale("de"))
    }
    val localContext = LocalContext.current
    val localizedContext = localContext.createConfigurationContext(config)
    CompositionLocalProvider(
        LocalContext provides localizedContext
    ) {
        WelcomeScreen()
    }
}

// 3. Restrict library locales in build.gradle.kts:
android {
    defaultConfig {
        // Only include locales you actually translate
        resourceConfigurations += listOf("en", "de", "ja", "es", "fr")
    }
}

// 4. Enable pseudolocales in Developer Options:
// en-XA (accented) — detects hardcoded strings
// ar-XB (RTL) — tests layout mirroring
Test with German (text expands ~30%) and Japanese (shrinks ~50%) to catch layout issues. Enable pseudolocales (en-XA for accented, ar-XB for RTL) in Developer Options to stress-test layouts without real translations.
8

Automate Translations

Translate your strings.xml, plurals, string arrays, and Fastlane Supply metadata using AI. Automate translation of both in-app strings and Play Store metadata for a fully localized presence.

Terminal
# Translate strings.xml files
> Translate res/values/strings.xml
  to Japanese, German, and Spanish

# Translate Play Store metadata too
> Translate fastlane/metadata/android/en-US/
  to de-DE, ja-JP, es-ES

✓ 6 files translated in 3.2s
i18n Agent handles Android XML escaping, preserves translatable="false" markers, respects CLDR plural categories per target language, and keeps positional format arguments intact.
JetBrains

Android Studio Plugin Available

Translate Android XML resources directly from your IDE with the i18n Agent plugin for IntelliJ / Android Studio.

Install
+

Bonus: Smart Locale Fallback with LocaleChain

Android's resource fallback is OS-controlled. When pt-BR translations are missing, Android skips pt-PT entirely and shows English. LocaleChain intercepts string lookups and walks a configurable fallback chain — so regional users see the closest available translation.

LocaleChain for Android is an open-source Kotlin library. View on GitHub

build.gradle.kts
// build.gradle.kts (app module)
dependencies {
    implementation("com.i18nagent:locale-chain-android:0.1.0")
}
MyApp.kt / BaseActivity.kt
// 1. Application.onCreate() — configure chains once
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        LocaleChain.configure()
    }
}

// 2. BaseActivity — wrap context per Activity
open class BaseActivity : AppCompatActivity() {
    override fun attachBaseContext(newBase: Context) {
        super.attachBaseContext(LocaleChain.wrap(newBase))
    }
}

// pt-BR user with only pt-PT translations?
// → Shows Portuguese instead of falling back to English

// Custom overrides for your specific locales:
LocaleChain.configure(
    overrides = mapOf("es-MX" to listOf("es-419", "es"))
)

Common Pitfalls

Missing Default Strings Cause Crashes

Unlike iOS which shows the raw key, Android crashes with a ResourceNotFoundException if a string is missing from the default res/values/strings.xml. Always ensure every key exists in the default file.

App Bundle Language Splits Break In-App Switching

Google Play App Bundles split APKs by language — users only receive their device language's strings. If you offer in-app language switching, add bundle { language { enableSplit = false } } to your build.gradle.kts.

RTL Layouts Breaking

Using left/right instead of start/end in layouts, or missing android:supportsRtl="true" in AndroidManifest.xml. Use Android Studio's Refactor > Add RTL Support to auto-convert existing layouts.

Library Resource Contamination

Third-party libraries bundle their own values-XX/strings.xml files, making Android think your app supports languages it doesn't. Use resConfigs in build.gradle.kts to restrict included locales to only the ones you actually translate.

Recommended File Structure

Project Structure
MyApp/
├── app/
│   └── src/main/
│       ├── res/
│       │   ├── values/
│       │   │   ├── strings.xml          # Default (source) strings
│       │   │   └── plurals.xml          # Plural rules
│       │   ├── values-de/
│       │   │   └── strings.xml
│       │   ├── values-ja/
│       │   │   └── strings.xml
│       │   └── values-es/
│       │       └── strings.xml
│       ├── java/com/example/myapp/
│       └── AndroidManifest.xml
├── fastlane/
│   └── metadata/android/
│       ├── en-US/
│       │   ├── title.txt
│       │   ├── short_description.txt
│       │   ├── full_description.txt
│       │   └── changelogs/default.txt
│       ├── de-DE/
│       └── ja-JP/
├── build.gradle.kts
└── settings.gradle.kts

Try i18n Agent Now

Drop your translation file here

JSON, YAML, PO, XML, CSV, Markdown, Properties

or click to browse

Target languages

No signup requiredInstant estimate

Android Localization FAQ