Skip to content

7. Internationalization

7.1. Example 21: A Bilingual Application

[Flutter] uses ARB files, or the JSON file from the [easy_localization] package. [Android] has its own method, built into the system: the application’s texts are RESOURCES. The [res/values/strings.xml] file contains the texts for the default language, in this case English; each translation is a file with the same name, in a folder whose suffix is the language code. The French translation, [res/values-fr/strings.xml]:

<?xml version="1.0" encoding="utf-8"?>
<!-- =====================================================================
     res/values-fr/strings.xml - the texts of the application, in FRENCH
     (the translation). The default language, English, is in
     res/values/strings.xml: Android chooses the file according to the
     language of the application (cf. example 21).
     ===================================================================== -->
<resources>
    <string name="app_name">Exemples Kotlin</string>

    <!-- example 21 -->
    <string name="ex21_title">Exemple 21 - deux langues</string>
    <!-- %1$s: the first parameter, a string -->
    <string name="ex21_hello">Bonjour %1$s !</string>
    <string name="ex21_today">Nous sommes le %1$s.</string>
    <string name="ex21_price">Une consultation coûte %1$s.</string>
    <string name="ex21_no_appointment">aucun rendez-vous</string>
    <!-- a plural text: one form per "category" of the language;
         %1$d: the first parameter, an integer -->
    <plurals name="ex21_appointments">
        <item quantity="one">un rendez-vous</item>
        <item quantity="many">%1$d rendez-vous</item>
        <item quantity="other">%1$d rendez-vous</item>
    </plurals>
    <string name="ex21_add">Ajouter un rendez-vous</string>
    <string name="ex21_remove">En retirer un</string>
    <string name="ex21_calendar">Ouvrir le calendrier</string>
    <string name="ex21_server_title">Traduire une réponse du serveur</string>
    <string name="ex21_server_explanation">Le serveur RdvMedecins ne renvoie que des clés. C\'est le client qui les traduit :</string>
    <string name="errors_login_taken">Ce login est déjà utilisé par un autre compte.</string>
    <string name="errors_too_many_attempts">Trop de tentatives infructueuses. Réessayez dans %1$s minute(s).</string>
</resources>
  • Each text has a NAME; the code only recognizes this name: [R.string.ex21_hello];
  • [%1$s]: the first parameter, a string; [%1$d]: an integer;
  • [<plurals>]: a plural text, with one form per language “category” ([one], [many], [other]...) : [Android] knows the rules for each language;
  • [C\'est]: the apostrophe must be preceded by a backslash.

The default file, [res/values/strings.xml] (English): the same names, in the folder without a suffix. [Android] selects the file based on the application’s language:

<?xml version="1.0" encoding="utf-8"?>
<!-- =====================================================================
     res/values/strings.xml - the texts of the application, in ENGLISH
     (the default language). Same names as in res/values-fr/strings.xml;
     a text missing from here would be missing in every language.
     ===================================================================== -->
<resources>
    <string name="app_name">Kotlin examples</string>

    <string name="ex21_title">Example 21 - two languages</string>
    <string name="ex21_hello">Hello %1$s!</string>
    <string name="ex21_today">Today is %1$s.</string>
    <string name="ex21_price">A consultation costs %1$s.</string>
    <string name="ex21_no_appointment">no appointment</string>
    <plurals name="ex21_appointments">
        <item quantity="one">one appointment</item>
        <item quantity="other">%1$d appointments</item>
    </plurals>
    <string name="ex21_add">Add an appointment</string>
    <string name="ex21_remove">Remove one</string>
    <string name="ex21_calendar">Open the calendar</string>
    <string name="ex21_server_title">Translating a server response</string>
    <string name="ex21_server_explanation">The RdvMedecins server only sends keys. The client translates them:</string>
    <string name="errors_login_taken">This login is already used by another account.</string>
    <string name="errors_too_many_attempts">Too many failed attempts. Try again in %1$s minute(s).</string>
</resources>

The application’s languages are declared in [res/xml/locales_config.xml], which is referenced by the manifest ([android:localeConfig]):

<?xml version="1.0" encoding="utf-8"?>
<!-- =====================================================================
     res/xml/locales_config.xml - the languages of the application (example 21)
     ............................................................................
     Declared in AndroidManifest.xml (android:localeConfig). Since
     Android 13, the user can also choose the language of EACH
     application in the phone settings: Settings > System >
     Languages > App languages.
     ===================================================================== -->
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
    <locale android:name="fr" />
    <locale android:name="en" />
</locale-config>

Starting with [Android] 13, the user can then select the application language in the phone settings. The code from the example:

// =========================================================================
// ex21/Ex21Activity.kt - an application in two languages
// -------------------------------------------------------------------------
// The Android way:
//   - the TEXTS of the application are RESOURCES: a file
//     res/values/strings.xml (the default language, here English) and one
//     file per translation (res/values-fr/strings.xml). Each text has a
//     name; the code only knows this name (R.string.ex21_hello), and
//     Android picks the file according to the language of the application;
//   - stringResource(R.string.ex21_hello, "Marie"): the text, whose
//     parameters (%1$s) are replaced; pluralStringResource: a plural
//     text, whose form follows the rules of the language;
//   - the language of the application: that of the phone... or the one the
//     application chooses itself, AppCompatDelegate.setApplicationLocales.
//     Android then RECREATES the activity, in the new language: the texts,
//     the dates, the amounts, and the texts of Android itself (the
//     calendar, its buttons).
// The RdvMedecins server, for its part, only returns KEYS ("ERRORS.LOGIN_TAKEN"):
// they are associated with the resources by a table (serverKeys).
// The activity is an AppCompatActivity: the activity class that can
// apply the chosen language, even on the Android versions prior
// to 13.
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)

package fr.stahe.examples.ex21

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material3.Card
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
import fr.stahe.examples.R
import fr.stahe.examples.common.ExamplePage
import fr.stahe.examples.common.ExamplesTheme
import java.text.NumberFormat
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Currency

val languages = listOf("fr", "en")

// what the RdvMedecins server would return (cf. chapter on the black box)
data class ServerResponse(val statusCode: Int, val key: String, val params: Map<String, String>)

val serverResponses = listOf(
    ServerResponse(409, "ERRORS.LOGIN_TAKEN", emptyMap()),
    ServerResponse(429, "ERRORS.TOO_MANY_ATTEMPTS", mapOf("minutes" to "15")),
)

// the server's key -> the resource that translates it
val serverKeys = mapOf(
    "ERRORS.LOGIN_TAKEN" to R.string.errors_login_taken,
    "ERRORS.TOO_MANY_ATTEMPTS" to R.string.errors_too_many_attempts,
)

class Ex21Activity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { ExamplesTheme { TranslatedPage() } }
    }
}

// choosing the language of the application: Android stores it, and recreates the activity
fun changeLanguage(language: String) {
    AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(language))
}

@Composable
fun TranslatedPage() {
    // rememberSaveable: the count survives the recreation of the activity
    var count by rememberSaveable { mutableIntStateOf(2) }
    var calendarOpen by rememberSaveable { mutableStateOf(false) }
    // the CURRENT language: that of the activity's configuration
    val locale = LocalConfiguration.current.locales[0]
    // formatters in the current language
    val date = LocalDate.now().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale))
    val price = NumberFormat.getCurrencyInstance(locale).apply { currency = Currency.getInstance("EUR") }.format(1234.5)

    ExamplePage(
        title = stringResource(R.string.ex21_title),
        // the languages: "segmented" buttons, in the top bar
        actions = {
            SingleChoiceSegmentedButtonRow(Modifier.padding(end = 8.dp).width(120.dp)) {
                languages.forEachIndexed { i, l ->
                    SegmentedButton(
                        selected = locale.language == l,
                        onClick = { changeLanguage(l) },
                        shape = SegmentedButtonDefaults.itemShape(i, languages.size),
                        icon = {},
                    ) { Text(l.uppercase()) }
                }
            }
        },
    ) {
        Card(Modifier.fillMaxWidth()) {
            Column(Modifier.padding(12.dp)) {
                Text(stringResource(R.string.ex21_hello, "Marie"))
                Text(stringResource(R.string.ex21_today, date))
                Text(stringResource(R.string.ex21_price, price))
                Spacer(Modifier.height(8.dp))
                // the plural: the "zero" category exists neither in French nor in
                // English (0 is "one" in French, "other" in English): 0 is
                // handled separately
                Text(
                    if (count == 0) stringResource(R.string.ex21_no_appointment) else pluralStringResource(R.plurals.ex21_appointments, count, count),
                    fontWeight = FontWeight.Bold,
                )
                FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                    TextButton(onClick = { count++ }) { Text(stringResource(R.string.ex21_add)) }
                    TextButton(onClick = { count-- }, enabled = count > 0) { Text(stringResource(R.string.ex21_remove)) }
                }
                // the Material calendar: its texts follow the language of the application
                OutlinedButton(onClick = { calendarOpen = true }) {
                    Icon(Icons.Default.DateRange, contentDescription = null)
                    Spacer(Modifier.width(8.dp))
                    Text(stringResource(R.string.ex21_calendar))
                }
            }
        }
        Card(Modifier.fillMaxWidth()) {
            Column(Modifier.padding(12.dp)) {
                Text(stringResource(R.string.ex21_server_title), style = MaterialTheme.typography.titleSmall)
                Text(stringResource(R.string.ex21_server_explanation))
                Spacer(Modifier.height(8.dp))
                for (r in serverResponses) {
                    Text("${r.statusCode} ${r.key}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
                    // the resource of the key; its parameters, in order (*: "spreads" the array)
                    val id = serverKeys[r.key]
                    Text("→ " + if (id == null) r.key else stringResource(id, *r.params.values.toTypedArray()))
                    Spacer(Modifier.height(6.dp))
                }
            }
        }
    }

    if (calendarOpen) {
        val state = rememberDatePickerState()
        DatePickerDialog(
            onDismissRequest = { calendarOpen = false },
            // the OK / Cancel texts of Android itself, in the language of the application
            confirmButton = { TextButton(onClick = { calendarOpen = false }) { Text(stringResource(android.R.string.ok)) } },
            dismissButton = { TextButton(onClick = { calendarOpen = false }) { Text(stringResource(android.R.string.cancel)) } },
        ) {
            DatePicker(state = state)
        }
    }
}
  • Lines 92–94: The RdvMedecins server returns only KEYS (["ERRORS.LOGIN_TAKEN"]); these are mapped to resources via a table;
  • line 97: a [AppCompatActivity]: the activity class that knows how to apply the selected language, even before [Android] 13;
  • lines 106–107: select the language for the APPLICATION (not the phone’s language): [Android] stores it, and RECREATES the activity in the new language. Before [Android] 13, the [AppCompat] library stores it (the [AppLocalesMetadataHolderService] service in the manifest, [autoStoreLocales]);
  • lines 116–119: the current language, which is the language specified in the activity configuration, and the date and amount formatters for that language;
  • line 122: [stringResource(R.string.ex21_title)]: the text in the current language;
  • lines 10–11: parameters and a plural form ([pluralStringResource]): since the category [zero] exists neither in French nor in English, 0 is handled separately;
  • lines 170–171: the translation of a server key, along with its parameters ([*] “flattens” an array into a list of parameters);
  • lines 180–184: the text for [Android] itself ([android.R.string.ok]) and that of the calendar also follow the application’s language.

Image

Image

Image

The number of appointments persists across language changes thanks to [rememberSaveable]: the activity was, however, destroyed and recreated.