Software localization

Working with Android Per-App Language Preferences

Discover how to cater to Android users who choose a language different from the system language for a specific app and provide them with a seamless experience. 📱 🌐
Android i18n blog post featured image | Phrase

Android users have always been able to set their preferred system language, which affects the entire operating system. With Android introducing per-app language preferences, users are now also able set their preferred language for each app. If set, the app specific preferred language will override the system language for that particular application.

This means that to provide the best user experience, we need to be aware of per-app language preferences when launching custom browser tabs or using speech-to-text in our apps. In this tutorial, we will discuss best practices while doing just that.

The source app

We will first create the source app. This will be a simple app that lets user type in a String value in a textfield. It has 2 buttons –

  • The Search button opens a custom browser within the application
  • Voice to text button opens SpeechRecognizer service. The user can dictate the text which will show up in the text field.

We will learn to pass Locale as a parameter while launching both these intents.

Source app with string field | Phrase

Create a new Android Studio project and set the targetSdk version to 33 while setting up the project. In the MainActivity.kt file, add the following code in the OnCreate() method:

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.example.appone.ui.theme.AppOneTheme
// ...

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      AppOneTheme {
        // A surface container using the 'background' color from the theme
        Surface() {
          var textFieldValue by remember { mutableStateOf(getString(R.string.text_hint)) }
          SourceApp(
            textFieldValue,
            onTextValueChanged = {
              textFieldValue = it
            },
            launchSpeechToText = {
              launchSpeechToTextService(this)
            },
            searchText = {
              searchOnBrowser(textFieldValue)
            }
          )
        }
      }
    }
  }

  // ...
}Code language: Kotlin (kotlin)

The SourceApp() is a composable that consists of all the UI elements needed for this app. We pass textFieldValue which represents the text inside the text field and a few higher order functions that are triggered when either the user modifies the text field or one of the buttons is pressed.

Now create a new Composable SouceApp() in the MainActivity.kt and add the following code:

@Composable
private fun SourceApp(
  textFieldValue: String,
  onTextValueChanged: (String) -> Unit,
  launchSpeechToText: () -> Unit,
  searchText: () -> Unit
) {
  Column() {
    TextField(
      value = textFieldValue, 
      onValueChange = { newText -> onTextValueChanged(newText) },
      placeholder = { Text(text = "Enter text here") }
    )

    Button(onClick = { launchSpeechToText() }) {
      Text(text = "Voice to text")
    }

    Button(onClick = {
      searchText()
    }) {
      Text(text = "Search")
    }
  }
}Code language: Kotlin (kotlin)

Adding multiple languages support for the Source App

Currently the Source app only supports English language which is hardcoded. Now we will add support for Spanish language.

Create a file called res/xml/locales_config.xml and add the following code.

<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
    <locale android:name="en"/>
    <locale android:name="es"/>
</locale-config>Code language: HTML, XML (xml)

Now point to this file in the AndroidManifest.xml.

<application
		...
    android:localeConfig="@xml/locales_config"
    ...
</application>Code language: HTML, XML (xml)

Adding resources for supported languages

We need to create strings.xml files for the default language (English in our case) and Spanish.

🗒 Note » You can also add Strings for the supported Locales using the Translation Editor. We walk through this in our Deep Dive on Internationalizing Jetpack Compose Android Apps.

To add Strings and its associated keys for the default language, go to res/values/strings.xml and make the following changes:

//For English/Default
<resources>
    <string name="app_name">AppOne</string>
    <string name="text_hint">Enter text here.</string>
    <string name="button_voice_to_text">Voice totext</string>
    <string name="button_search">Search</string>
</resources>Code language: HTML, XML (xml)

To add Strings for Spanish, go to res/values-es/strings.xml and make the following changes:


//For Spanish
<resources>
    <string name="text_hint">introducir texto aquí.</string>
    <string name="button_voice_to_text">Voz a texto</string>
    <string name="button_search">Búsqueda</string>
</resources>Code language: HTML, XML (xml)

Now we need to reference these strings using the keys defined above in the strings.xml file. In the MainActivity.kt file, go to SourceApp() Composable and make the following changes:

@Composable
private fun SourceApp(
  textFieldValue: String,
  onTextValueChanged: (String) -> Unit,
  launchSpeechToText: () -> Unit,
  searchText: () -> Unit
) {
  Column(...) {
    TextField(value = textFieldValue,
      onValueChange = { newText -> onTextValueChanged(newText) },
      placeholder = {
        Text(
          text = stringResource(id = R.string.text_hint),
          style = MaterialTheme.typography.body1
        )
      }
    )
    Button(onClick = { launchSpeechToText() }) {
      Text(
        text = stringResource(id = R.string.button_voice_to_text),
      )
    }
    Button(onClick = { searchText() }) {
      Text(
        text = stringResource(id = R.string.button_search),
      )
    }
  }
}Code language: Kotlin (kotlin)

Users can select their preferred language for an Application in two ways:

  • Access through the System settingsSettings → System → Languages & Input → App Languages → (select an app)
  • Access through Apps settingsSettings → Apps → (select an app) → Language

🔗 Resource » You can also add a language picker within the app

Select language screen | Phrase

Select Español as the application language in the above System menu and open the app.

The app now renders text in user’s preferred language.

🗒 Note » The System default app is still set to English. Any app for which a user hasn’t specifically selected a preferred language will still use the System default language.

App in Spanish selected language | Phrase

Passing a locale as a parameter while invoking the browser

When the search button is clicked, we want to open a custom browser and google the user’s entered text. To provide the best user experience, it is pertinent that we display the website (google.com in this case) in the user’s preferred language (if the website supports it) to maintain continuity.

🗒 Note » The website will be displayed in the user’s preferred language only if it supports the passed locale. If the passed locale isn’t supported, the website will be displayed in its default or fallback language. google.com supports most if not all locales, but that’s not the case of all sites.

Firstly, add the browser dependency in your app’s build.gradle file

dependencies{
		...
    implementation 'androidx.browser:browser:1.2.0'
}Code language: JavaScript (javascript)

Create a new function searchBroswer() in MainActivity.kt and add the following code:

private fun searchOnBrowser(text: String) {

	// get user's preferred Locale 
  val locale = Locale.getDefault()
  val package_name = "com.android.chrome"

	// the url we want to open in browser
  val URL = "https://www.google.com/search?q=$text"
  val builder = CustomTabsIntent.Builder()
  builder.setShowTitle(true)
  builder.setInstantAppsEnabled(true)
  val customBuilder = builder.build()

	// create a bundle which will include the "Accept-Language" tag.
  val headers = Bundle()
  headers.putString("Accept-Language", locale.toString())
	// add the above bundle through Browser.EXTRA_HEADER key. 
  customBuilder
      .intent
      .setPackage(package_name)
      .putExtra(Browser.EXTRA_HEADERS, headers)
  // launch the web url using browser
  customBuilder.launchUrl(this, Uri.parse(URL))
}Code language: Kotlin (kotlin)

Locale.getDefault() returns the 2-letter lowercase ISO code of the currently displayed language of the application. This will return the user’s preferred app specific language (if set), and the system default language otherwise. While launching the Custom Tab Intent, we need to explicitly pass a bundle to the Intent where we set the key Accept-Language key to the user’s current Locale.

Now the Chrome custom tab will automatically displays the webpage in the user’s preferred language for the app.

Chrome tab displayed content in the preferred language | Phrase

The browser shows the webpage in Spanish when the app’s preferred language is set to Spanish.

Browser shows webpage in English | Phrase

The browser shows the webpage in English when the app’s preferred language is set to English.

Passing locale as a parameter while invoking voice to text

Just as we passed Locale as a parameter above while launching browser within the app, we also need to pass it to SpeechRecognizer Intent so it can convert speech to text while taking the user’s Locale into consideration.

To add voice to text, make the following changes to MainActivity.kt:

class MainActivity : ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {

    super.onCreate(savedInstanceState)
    setContent {
      AppOneTheme {
        Surface(...) {
          var textFieldValue by remember {
            mutableStateOf(getString(R.string.text_hint))
          }

          val startLauncher = rememberLauncherForActivityResult(
            ActivityResultContracts.StartActivityForResult()
          ) { it ->
            if (it.resultCode == Activity.RESULT_OK) {
              val result =
                it.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
              val text = result?.get(0).toString()
              textFieldValue = text
            }
          }

          fun launchSpeechToTextService(context: Context) {
            val locale = Locale.getDefault()
            if (SpeechRecognizer.isRecognitionAvailable(context)) {
              val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
              intent.putExtra(
                RecognizerIntent.EXTRA_LANGUAGE_MODEL,
                RecognizerIntent.LANGUAGE_MODEL_WEB_SEARCH
              )
              intent.putExtra(
                RecognizerIntent.EXTRA_LANGUAGE,
                locale.toString()
              )
              startLauncher.launch(intent)
            }
          }

          SourceApp(
            textFieldValue,
            onTextValueChanged = {
              textFieldValue = it
            },
            launchSpeechToText = {
              launchSpeechToTextService(this)
            },
            searchText = {
              // We define this method earlier
              searchOnBrowser(textFieldValue)
            }
          )
        }
      }
    }
  }

When the Voice to Text button is pressed, we run the launchSpeechToTextService() function. We get the current Locale using Locale.getDefault() and pass it with the key RecognizerIntent.EXTRA_LANGUAGE as an extra in the Intent . Once the user dismisses the SpeechRecognizer dialog , we get the processed text in the rememberLauncherForActivityResult() function, where we set the textfield value to processed text.

🗒 Note » SpeechRecognizer APIs will now only process the speech using the given locale . For example when es-US (Spanish, USA) is passed, it will only process Spanish language and give error for any other languages.

App in US Spanish | Phrase

Here’s the final code for MainActivity.kt

class MainActivity : ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {

    super.onCreate(savedInstanceState)
    setContent {
      AppOneTheme {
        // A surface container using the 'background' color from the theme
        Surface(
          modifier = Modifier
            .fillMaxSize()
            .displayCutoutPadding()
            .statusBarsPadding(), color = MaterialTheme
            .colors.background
        ) {
          var textFieldValue by remember { mutableStateOf(getString(R.string.text_hint)) }

          val startLauncher = rememberLauncherForActivityResult(
            ActivityResultContracts.StartActivityForResult()
          ) { it ->
            if (it.resultCode == Activity.RESULT_OK) {
              val result =
                it.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
              val text = result?.get(0).toString()
              textFieldValue = text
            }
          }

          fun launchSpeechToTextService(context: Context) {
            val locale = Locale.getDefault()
            if (SpeechRecognizer.isRecognitionAvailable(context)) {
              val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
              intent.putExtra(
                RecognizerIntent.EXTRA_LANGUAGE_MODEL,
                RecognizerIntent.LANGUAGE_MODEL_WEB_SEARCH
              )
              intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, locale.toString())
              startLauncher.launch(intent)
            }
          }

          SourceApp(
            textFieldValue,
            onTextValueChanged = {
              textFieldValue = it
            },
            launchSpeechToText = {
              launchSpeechToTextService(this)
            },
            searchText = {
              searchOnBrowser(textFieldValue)
            }
          )
        }
      }
    }
  }

  private fun searchOnBrowser(text: String) {
    val locale = Locale.getDefault()
    val package_name = "com.android.chrome"

    val URL = "https://www.google.com/search?q=$text"
    val builder = CustomTabsIntent.Builder()
    builder.setShowTitle(true)
    builder.setInstantAppsEnabled(true)
    val customBuilder = builder.build()

    val headers = Bundle()
    headers.putString("Accept-Language", locale.toString())
    customBuilder.intent.setPackage(package_name)
      .putExtra(Browser.EXTRA_HEADERS, headers)
    // launch the web url using browser
    customBuilder.launchUrl(this, Uri.parse(URL))
  }
}


@Composable
private fun SourceApp(
  textFieldValue: String,
  onTextValueChanged: (String) -> Unit,
  launchSpeechToText: () -> Unit,
  searchText:
    () -> Unit
) {
  Column(
    modifier = Modifier
      .fillMaxSize()
      .padding(top = 30.dp), horizontalAlignment = Alignment
      .CenterHorizontally,
    verticalArrangement = Arrangement.spacedBy(20.dp)
  ) {
    TextField(value = textFieldValue, onValueChange = { newText ->
      onTextValueChanged(newText)
    }, placeholder = {
      Text(
        text = stringResource(id = R.string.text_hint),
        style = MaterialTheme.typography.body1
      )
    }, modifier = Modifier.height(300.dp))
    Button(onClick = { launchSpeechToText() }) {
      Text(
        text = stringResource(id = R.string.button_voice_to_text),
        style = MaterialTheme.typography.h6
      )
    }
    Button(onClick = {
      searchText()
    }) {
      Text(
        text = stringResource(id = R.string.button_search),
        style = MaterialTheme.typography.h6
      )
    }
  }
}Code language: Kotlin (kotlin)

You can find the final code for this application on GitHub.

Take Android localization to new heights

In this tutorial, we learned how to work with per-app locales while staying focused on Android localization. We also learned best practices for taking the user’s preferred locale into account while launching different services and intents from within our app. It ensures that the user enjoys a consistent experience while interacting with all the services and intents launched from within an Android application.

If you’re ready to level up your Android localization process, check out the Phrase Localization Suite.

With its dedicated software localization solution, Phrase Strings, you can manage translation strings for your Android app more easily and efficiently than ever.

With a robust API to automate translation workflows and integrations with GitHub, GitLab, Bitbucket, and other popular software platforms, Phrase Strings can do the heavy lifting from start to finish so you can stay focused on the code you love.

Phrase Strings comes with a full-featured strings editor where translators can pick up the content for translation you push. As soon as they’re done with translation, you can pull the translated content back into your project automatically.

As your software grows and you want to scale, our integrated suite lets you connect Phrase Strings with a cutting-edge translation management system (TMS) to fully leverage traditional CAT tools and AI-powered machine translation capabilities.

Check out all Phrase features for developers and see for yourself how they can streamline your software localization workflows from the get-go.

String Management UI visual | Phrase

Phrase Strings

Take your web or mobile app global without any hassle

Adapt your software, website, or video game for global audiences with the leanest and most realiable software localization platform.

Explore Phrase Strings