IntlPull
Tutorial
13 min read

Android i18n with Kotlin: Complete Localization Guide for 2026

Master Android internationalization with Kotlin and Jetpack Compose. Learn strings.xml resource system, plurals, per-app language preferences, runtime locale switching, and IntlPull OTA SDK for instant updates.

IntlPull Team
IntlPull Team
Feb 12, 2026
On this page
Summary

Master Android internationalization with Kotlin and Jetpack Compose. Learn strings.xml resource system, plurals, per-app language preferences, runtime locale switching, and IntlPull OTA SDK for instant updates.

Android's resource-based internationalization system has evolved significantly since its inception, and with Kotlin, Jetpack Compose, and Android 13+'s per-app language preferences, building multilingual apps has never been more powerful or developer-friendly. The strings.xml resource system provides compile-time safety, automatic locale fallback, and seamless integration with the Android framework. This comprehensive guide covers the complete Android i18n workflow: strings.xml resource structure, plurals and quantity strings, Jetpack Compose stringResource() usage, per-app language preferences (Android 13+), runtime locale switching, testing strategies, and integration with IntlPull's OTA SDK for instant translation updates without Play Store resubmission.

Understanding Android's i18n Architecture

Android's localization system is built on three core principles:

  1. Resource Qualifiers: Locale-specific directories (values-es, values-fr) automatically selected by the system
  2. Resource IDs: Compile-time safe references (R.string.welcome) prevent missing translation errors
  3. System-Managed Fallback: Automatic fallback to default locale if translation missing

This architecture ensures type safety, prevents runtime crashes from missing strings, and leverages Android's built-in locale detection for seamless user experiences.

Basic Setup with strings.xml

Step 1: Create Default Strings

Create app/src/main/res/values/strings.xml:

XML
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
3    <string name="app_name">MyApp</string>
4
5    <string name="welcome">Welcome to MyApp</string>
6    <string name="welcome_user">Welcome, %1$s!</string>
7
8    <string name="nav_home">Home</string>
9    <string name="nav_settings">Settings</string>
10    <string name="nav_profile">Profile</string>
11
12    <string name="action_save">Save</string>
13    <string name="action_cancel">Cancel</string>
14    <string name="action_delete">Delete</string>
15
16    <string name="error_network">Network error. Please try again.</string>
17    <string name="error_generic">Something went wrong</string>
18</resources>

Step 2: Create Localized Strings

Create app/src/main/res/values-es/strings.xml:

XML
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
3    <string name="app_name">MiApp</string>
4
5    <string name="welcome">Bienvenido a MiApp</string>
6    <string name="welcome_user">¡Bienvenido, %1$s!</string>
7
8    <string name="nav_home">Inicio</string>
9    <string name="nav_settings">Configuración</string>
10    <string name="nav_profile">Perfil</string>
11
12    <string name="action_save">Guardar</string>
13    <string name="action_cancel">Cancelar</string>
14    <string name="action_delete">Eliminar</string>
15
16    <string name="error_network">Error de red. Inténtalo de nuevo.</string>
17    <string name="error_generic">Algo salió mal</string>
18</resources>

Create app/src/main/res/values-fr/strings.xml for French, values-de/strings.xml for German, etc.

Step 3: Use in Traditional Views

Kotlin
1class MainActivity : AppCompatActivity() {
2    override fun onCreate(savedInstanceState: Bundle?) {
3        super.onCreate(savedInstanceState)
4
5        // XML layout
6        val textView: TextView = findViewById(R.id.welcome)
7        textView.text = getString(R.string.welcome)
8
9        // With formatting
10        val userName = "Alice"
11        textView.text = getString(R.string.welcome_user, userName)
12    }
13}

Step 4: Use in Jetpack Compose

Kotlin
1@Composable
2fun HomeScreen() {
3    Column(
4        modifier = Modifier.padding(16.dp)
5    ) {
6        Text(text = stringResource(R.string.welcome))
7
8        val userName = "Alice"
9        Text(text = stringResource(R.string.welcome_user, userName))
10
11        Button(onClick = { /* ... */ }) {
12            Text(text = stringResource(R.string.action_save))
13        }
14    }
15}

Plurals and Quantity Strings

Define Plurals in strings.xml

Create app/src/main/res/values/strings.xml:

XML
1<resources>
2    <plurals name="item_count">
3        <item quantity="zero">No items</item>
4        <item quantity="one">1 item</item>
5        <item quantity="other">%d items</item>
6    </plurals>
7
8    <plurals name="notification_count">
9        <item quantity="zero">No notifications</item>
10        <item quantity="one">1 new notification</item>
11        <item quantity="other">%d new notifications</item>
12    </plurals>
13</resources>

Localize for Spanish (values-es/strings.xml):

XML
1<resources>
2    <plurals name="item_count">
3        <item quantity="zero">Sin artículos</item>
4        <item quantity="one">1 artículo</item>
5        <item quantity="other">%d artículos</item>
6    </plurals>
7</resources>

Use Plurals in Code

Kotlin
1// Traditional Views
2val count = 5
3val text = resources.getQuantityString(R.plurals.item_count, count, count)
4
5// Jetpack Compose
6@Composable
7fun ItemCounter(count: Int) {
8    Text(
9        text = pluralStringResource(R.plurals.item_count, count, count)
10    )
11}

String Formatting and Parameters

Named Parameters

Use positional arguments for clarity:

XML
<string name="order_summary">Order #%1$d for %2$s - Total: $%3$.2f</string>
<string name="greeting">Hello, %1$s! You have %2$d messages.</string>
Kotlin
1val orderId = 12345
2val customerName = "Alice"
3val total = 99.99
4
5val summary = getString(
6    R.string.order_summary,
7    orderId,
8    customerName,
9    total
10)
11// "Order #12345 for Alice - Total: $99.99"

HTML Formatting

XML
<string name="terms"><![CDATA[I agree to the <b>Terms of Service</b> and <i>Privacy Policy</i>]]></string>
Kotlin
1val htmlText = HtmlCompat.fromHtml(
2    getString(R.string.terms),
3    HtmlCompat.FROM_HTML_MODE_LEGACY
4)
5textView.text = htmlText

String Arrays

XML
1<string-array name="days_of_week">
2    <item>Monday</item>
3    <item>Tuesday</item>
4    <item>Wednesday</item>
5    <item>Thursday</item>
6    <item>Friday</item>
7    <item>Saturday</item>
8    <item>Sunday</item>
9</string-array>
Kotlin
val days = resources.getStringArray(R.array.days_of_week)

Per-App Language Preferences (Android 13+)

Enable Per-App Language Support

Update AndroidManifest.xml:

XML
1<manifest xmlns:android="http://schemas.android.com/apk/res/android"
2    xmlns:tools="http://schemas.android.com/tools">
3
4    <application
5        android:localeConfig="@xml/locales_config"
6        tools:targetApi="33">
7        <!-- ... -->
8    </application>
9</manifest>

Create app/src/main/res/xml/locales_config.xml:

XML
1<?xml version="1.0" encoding="utf-8"?>
2<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
3    <locale android:name="en"/>
4    <locale android:name="es"/>
5    <locale android:name="fr"/>
6    <locale android:name="de"/>
7</locale-config>

Runtime Locale Switching

Kotlin
1import androidx.appcompat.app.AppCompatDelegate
2import androidx.core.os.LocaleListCompat
3
4class LanguageManager(private val context: Context) {
5
6    fun setAppLanguage(languageCode: String) {
7        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
8            // Android 13+ per-app language API
9            context.getSystemService(LocaleManager::class.java)
10                ?.applicationLocales = LocaleList(Locale.forLanguageTag(languageCode))
11        } else {
12            // Fallback for older versions
13            val localeList = LocaleListCompat.forLanguageTags(languageCode)
14            AppCompatDelegate.setApplicationLocales(localeList)
15        }
16    }
17
18    fun getCurrentLocale(): Locale {
19        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
20            context.getSystemService(LocaleManager::class.java)
21                ?.applicationLocales?.get(0) ?: Locale.getDefault()
22        } else {
23            AppCompatDelegate.getApplicationLocales()[0] ?: Locale.getDefault()
24        }
25    }
26}

Language Picker Composable

Kotlin
1@Composable
2fun LanguagePickerDialog(
3    onLanguageSelected: (String) -> Unit,
4    onDismiss: () -> Unit
5) {
6    val languages = listOf(
7        "en" to "English",
8        "es" to "Español",
9        "fr" to "Français",
10        "de" to "Deutsch"
11    )
12
13    AlertDialog(
14        onDismissRequest = onDismiss,
15        title = { Text(stringResource(R.string.select_language)) },
16        text = {
17            LazyColumn {
18                items(languages) { (code, name) ->
19                    TextButton(
20                        onClick = {
21                            onLanguageSelected(code)
22                            onDismiss()
23                        },
24                        modifier = Modifier.fillMaxWidth()
25                    ) {
26                        Text(name)
27                    }
28                }
29            }
30        },
31        confirmButton = {
32            TextButton(onClick = onDismiss) {
33                Text(stringResource(R.string.action_cancel))
34            }
35        }
36    )
37}

Context-Aware String Resources

Accessing Context in Composables

Kotlin
1@Composable
2fun DisplayErrorMessage(errorCode: Int) {
3    val context = LocalContext.current
4
5    val errorMessage = remember(errorCode) {
6        when (errorCode) {
7            404 -> context.getString(R.string.error_not_found)
8            500 -> context.getString(R.string.error_server)
9            else -> context.getString(R.string.error_generic)
10        }
11    }
12
13    Text(errorMessage)
14}

ViewModel with Resources

Kotlin
1class HomeViewModel(application: Application) : AndroidViewModel(application) {
2
3    private val context: Context get() = getApplication()
4
5    private val _uiState = MutableStateFlow(HomeUiState())
6    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
7
8    fun loadData() {
9        viewModelScope.launch {
10            _uiState.value = _uiState.value.copy(
11                loadingMessage = context.getString(R.string.loading)
12            )
13
14            try {
15                // Fetch data...
16            } catch (e: Exception) {
17                _uiState.value = _uiState.value.copy(
18                    errorMessage = context.getString(R.string.error_network)
19                )
20            }
21        }
22    }
23}

IntlPull OTA SDK Integration

Add Dependency

Update app/build.gradle.kts:

Kotlin
1dependencies {
2    implementation("com.intlpull:ota-android:1.0.0")
3    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
4}

Initialize SDK

Kotlin
1class MyApplication : Application() {
2
3    lateinit var intlPullOTA: IntlPullOTA
4
5    override fun onCreate() {
6        super.onCreate()
7
8        intlPullOTA = IntlPullOTA.Builder(this)
9            .projectId("your-project-id")
10            .apiKey(BuildConfig.INTLPULL_API_KEY)
11            .environment(if (BuildConfig.DEBUG) "staging" else "production")
12            .cacheEnabled(true)
13            .build()
14
15        lifecycleScope.launch {
16            intlPullOTA.initialize()
17        }
18    }
19}

Use OTA Translations

Kotlin
1class MainActivity : ComponentActivity() {
2
3    private val otaTranslations = MutableStateFlow<Map<String, String>>(emptyMap())
4
5    override fun onCreate(savedInstanceState: Bundle?) {
6        super.onCreate(savedInstanceState)
7
8        val app = application as MyApplication
9
10        lifecycleScope.launch {
11            app.intlPullOTA.translations.collect { translations ->
12                otaTranslations.value = translations
13            }
14        }
15
16        setContent {
17            MyAppTheme {
18                HomeScreen(otaTranslations.collectAsState().value)
19            }
20        }
21    }
22}
23
24@Composable
25fun HomeScreen(otaTranslations: Map<String, String>) {
26    val welcomeMessage = otaTranslations["welcome"]
27        ?: stringResource(R.string.welcome) // Fallback to local
28
29    Text(welcomeMessage)
30}

Publish OTA Updates

Terminal
# From IntlPull CLI
intlpull publish --version 1.2.0 --message "Fixed typos in checkout flow"

App fetches updates on launch. Critical translation fixes deploy in minutes without Play Store submission.

Testing Localization

Unit Tests

Kotlin
1import android.content.Context
2import androidx.test.core.app.ApplicationProvider
3import org.junit.Test
4import org.junit.runner.RunWith
5import org.robolectric.RobolectricTestRunner
6import org.robolectric.annotation.Config
7
8@RunWith(RobolectricTestRunner::class)
9@Config(qualifiers = "es")
10class StringResourceTest {
11
12    private val context: Context = ApplicationProvider.getApplicationContext()
13
14    @Test
15    fun `Spanish translations exist`() {
16        val welcome = context.getString(R.string.welcome)
17        assert(welcome == "Bienvenido a MiApp")
18    }
19
20    @Test
21    fun `Plurals work correctly`() {
22        val zero = context.resources.getQuantityString(R.plurals.item_count, 0, 0)
23        val one = context.resources.getQuantityString(R.plurals.item_count, 1, 1)
24        val many = context.resources.getQuantityString(R.plurals.item_count, 5, 5)
25
26        assert(zero == "Sin artículos")
27        assert(one == "1 artículo")
28        assert(many == "5 artículos")
29    }
30}

Instrumented Tests

Kotlin
1import androidx.compose.ui.test.*
2import androidx.compose.ui.test.junit4.createComposeRule
3import org.junit.Rule
4import org.junit.Test
5
6class HomeScreenTest {
7
8    @get:Rule
9    val composeTestRule = createComposeRule()
10
11    @Test
12    fun homeScreen_displaysWelcomeMessage() {
13        composeTestRule.setContent {
14            HomeScreen()
15        }
16
17        composeTestRule
18            .onNodeWithText("Welcome to MyApp")
19            .assertIsDisplayed()
20    }
21
22    @Test
23    fun languageSwitch_updatesUI() {
24        composeTestRule.setContent {
25            var locale by remember { mutableStateOf("en") }
26            CompositionLocalProvider(LocalConfiguration provides Configuration().apply {
27                setLocale(Locale(locale))
28            }) {
29                HomeScreen()
30            }
31        }
32
33        // Switch to Spanish
34        composeTestRule.runOnIdle {
35            locale = "es"
36        }
37
38        composeTestRule
39            .onNodeWithText("Bienvenido a MiApp")
40            .assertIsDisplayed()
41    }
42}

Best Practices

1. Always Use String Resources

Bad:

Kotlin
Text("Welcome to our app") // Hardcoded

Good:

Kotlin
Text(stringResource(R.string.welcome))

2. Use Positional Arguments

XML
1<!-- Bad: ambiguous order -->
2<string name="summary">%s has %s messages</string>
3
4<!-- Good: explicit positions -->
5<string name="summary">%1$s has %2$d messages</string>

3. Avoid String Concatenation

Bad:

Kotlin
val message = getString(R.string.hello) + " " + userName

Good:

Kotlin
val message = getString(R.string.hello_user, userName)

4. Lint Checks for Missing Translations

Enable Android Lint:

Kotlin
1// build.gradle.kts
2android {
3    lint {
4        checkReleaseBuilds = true
5        abortOnError = true
6        warningsAsErrors = true
7    }
8}

Run lint:

Terminal
./gradlew lintDebug

5. Use IntlPull CLI for Validation

Terminal
1# Check for missing translations
2intlpull validate --missing
3
4# Check for unused strings
5intlpull validate --unused

Common Pitfalls

Issue: Missing Translation Falls Back to Default

Cause: Translation missing in locale-specific strings.xml.

Solution: Ensure all keys exist in all locale files. Use IntlPull CLI validation.

Issue: Plurals Not Working

Cause: Incorrect quantity values.

Solution: Use zero, one, other (not two, few, many unless language supports it).

Issue: String Formatting Crashes

Cause: Mismatched format specifiers.

Solution:

XML
1<!-- English -->
2<string name="greeting">Hello, %1$s!</string>
3
4<!-- Spanish (must have same specifiers) -->
5<string name="greeting">¡Hola, %1$s!</string>

Issue: RTL Layout Issues

Solution: Use start/end instead of left/right:

Kotlin
modifier = Modifier.padding(start = 16.dp, end = 8.dp)

Test with Arabic locale (values-ar).

Production Deployment Checklist

  • All strings in strings.xml (no hardcoded text)
  • Plurals defined for all languages
  • locales_config.xml configured (Android 13+)
  • IntlPull OTA SDK integrated
  • Lint checks passing (no missing translations)
  • RTL layouts tested (Arabic, Hebrew)
  • Per-app language picker implemented
  • OTA fallback to local strings.xml
  • Play Store listing translated
  • CI/CD validates translations

Frequently Asked Questions

How do I handle region-specific locales (e.g., en-US vs en-GB)?

Create values-en-rUS and values-en-rGB directories:

res/
├── values-en-rUS/
│   └── strings.xml
└── values-en-rGB/
    └── strings.xml

Can I use Kotlin string templates?

Not directly. Use format specifiers:

Kotlin
1// Don't do this
2val message = "${userName} has ${count} messages"
3
4// Do this
5val message = getString(R.string.user_messages, userName, count)

How do I translate app name?

Define in strings.xml:

XML
<string name="app_name">MyApp</string>

Reference in AndroidManifest.xml:

XML
<application
    android:label="@string/app_name">

What's the performance impact of OTA?

Minimal. Translations cached locally, fetched asynchronously on launch.

How do I test different locales in emulator?

Settings → System → Languages & input → Languages → Add a language

Does IntlPull support Android?

Yes, IntlPull CLI can export to strings.xml format and provides native Android OTA SDK.

Conclusion

Android's resource-based i18n system provides a robust foundation for building multilingual apps that scale globally. With Kotlin, Jetpack Compose, and Android 13+'s per-app language preferences, the developer experience has never been better. Integration with IntlPull's OTA SDK eliminates the painful Play Store submission cycle for translation fixes, enabling instant updates and A/B testing without user friction.

Start with the basics (strings.xml + resource qualifiers), adopt per-app language preferences for Android 13+, and integrate IntlPull OTA SDK for instant updates. Your international users will appreciate the localized experience, and your team will appreciate the time saved on translation management.

Ready to ship your Android app globally? Try IntlPull free with 500 keys and 3 languages, or explore our Android documentation for advanced patterns and OTA SDK integration.

Tags
android
kotlin
i18n
localization
jetpack-compose
strings-xml
mobile
IntlPull Team
IntlPull Team
Engineering

Building tools to help teams ship products globally. Follow us for more insights on localization and i18n.