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:
- Resource Qualifiers: Locale-specific directories (
values-es,values-fr) automatically selected by the system - Resource IDs: Compile-time safe references (
R.string.welcome) prevent missing translation errors - 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:
XML1<?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:
XML1<?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
Kotlin1class 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
Kotlin1@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:
XML1<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):
XML1<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
Kotlin1// 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>
Kotlin1val 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>
Kotlin1val htmlText = HtmlCompat.fromHtml( 2 getString(R.string.terms), 3 HtmlCompat.FROM_HTML_MODE_LEGACY 4) 5textView.text = htmlText
String Arrays
XML1<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>
Kotlinval days = resources.getStringArray(R.array.days_of_week)
Per-App Language Preferences (Android 13+)
Enable Per-App Language Support
Update AndroidManifest.xml:
XML1<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:
XML1<?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
Kotlin1import 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
Kotlin1@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
Kotlin1@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
Kotlin1class 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:
Kotlin1dependencies { 2 implementation("com.intlpull:ota-android:1.0.0") 3 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") 4}
Initialize SDK
Kotlin1class 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
Kotlin1class 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
Kotlin1import 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
Kotlin1import 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:
KotlinText("Welcome to our app") // Hardcoded
Good:
KotlinText(stringResource(R.string.welcome))
2. Use Positional Arguments
XML1<!-- 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:
Kotlinval message = getString(R.string.hello) + " " + userName
Good:
Kotlinval message = getString(R.string.hello_user, userName)
4. Lint Checks for Missing Translations
Enable Android Lint:
Kotlin1// 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
Terminal1# 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:
XML1<!-- 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:
Kotlinmodifier = 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.xmlconfigured (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:
Kotlin1// 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.
