Software localization
Key Dos & Don’ts for Developing Multilingual Android Apps
Android has been the leading mobile OS worldwide for quite a while. In the summer of 2020, it controlled 74.6 percent of the entire mobile OS market. If you're looking for best practices for building a multilingual Android app, the following Android app development tutorial will walk you through all the dos and don'ts.
Before we get it started, let's note that the Android application framework is powered by several i18n and l10n libraries/utilities. This means you don't need to import any external i18n or l10n libraries to enable multi-language support for your Android app. We can now start digging into the details!
The Android I18n & L10n Framework
The internationalization framework is part of the wider Android application framework, in which some – if not all – components rely on internationalization. Put another way, Android already provides a set of libraries within its SDK, such as date-time formatting, number formatting, message formatting, currency, measurement units, timezone, collation rules, plural rules, gender rules, transliteration, Bidi layout support, Emoji support, etc.
Let's say you're developing an app focused on European financial markets and need to handle all date-time differences, as well as identify both the currency and numbering system of each region. Ideally, you can refer to SDK classes, which provide full, standardized support. Additionally, Android has the localization framework, responsible for localizing the OS itself, as well as all applications. It also provides mechanisms for locale awareness, resource loading, locale matching, fallback, etc.
In general terms, Android leverages the ICU library and CLDR project to provide Unicode and other internationalization support.
🔗 Resource » Get a brief overview of ICU and CLDR in The Missing Guide to the ICU Message Format.
The relation between Unicode CLDR, ICU, and Android
Implementing I18n & L10n in Your Android App
Now that we're familiar with the basics of the Android i18n & l10n framework, we'll focus on four topics crucial to the development of multilingual Android apps:
- Configuration/Preparation,
- Localizability,
- Bidirectionality,
- Formatting.
1. Configuration/Preparation
To make sure an Android app is global-ready, we need to take care of a few key configurations both in the Manifest
and Gradle
file.
In the AndroidManifest.xml
file, we need to set the android:supportsRtl
property to true
. This property declares whether your application is willing to support right-to-left (RTL) layouts. Its default value is false
.
<manifest ... > ... <application ... android:supportsRtl="true"> </application> </manifest>
🗒️ Note » In the section on bidirectionality below, you can find the complete information on RTL layout support.
In the build.gradle
file, we need to set the following configurations:
targetSdkVersion
→17 (or higher)
An integer designating the API level that the application targets, where 17 is the minimal value accepted because prior to API 17, Android APIs didn't support right-to-left layouts.
resConfigs
→list of supported locales
A list of the locales that the app supports.
android { ... defaultConfig { targetSdkVersion 17 // Or higher ... resConfigs "en", "da_DK", "de_DE", "it_IT" … } }
Why is the resConfigs
property necessary at all? Well, you need to make sure your application will work properly for the second-preferred locale setting. In other words, in case a user sets additional languages on their mobile device, your app will try to match all configurations of the device, including secondary, tertiary languages, etc.
See the scenario below:
User Settings
- nl_BE
- de_BE
App Resources
- default (en)
- da_DK
- de_DE
- it_IT
Which language content should be picked and displayed for the user considering these settings?
Since Dutch (Belgium) is the primary option, and there are no resources in this language, as expected, the secondary option, which has content for the German language, is picked.
You may wonder why de_DE is picked despite de_BE being the user locale. Let's explain the resource-resolution strategy used by Android to define the best matching:
- Check the first choice: Try nl_BE→ Fail
- Remove region and try locale only: Try nl→ Fail
- Try locale children of nl: children of nl→ Fail
- Go to the second choice: Try de_BE→ Fail
- Remove region and try locale only: Try de→ Fail
- Check locale children of de: children of de→ de_DE
Use de_DE
- The steps above are repeated for all configurations. If none of them matches, then the default locale is used.
With that, the user still has a language they understand, even though the app doesn’t support Dutch. However, it only happens due to the resConfigs
property.
In case your app imports any Android library, and you don't add the resConfigs
property, it will not work properly, and probably the default content (English) will be displayed, which wouldn't be that good from a UX perspective.
2. Localizability
Localizability is the practice of enabling software to be localized into different languages without changing its source code. At this point, you should pay attention to the following steps:
- Keep in mind the externalization of resources,
- Include a full set of default resources,
- Store default resources without language or locale qualifiers (whatever the default language used in your app, store default resource directories without language or locale qualifiers),
- Make sure a res/values/strings.xml file exists and defines every string needed.
The steps mentioned above are also applicable to other Android resources, such as layouts, sounds, graphics, and other data your app may need.
For more details on Android resources, feel free to check out the App resources overview.
Let's move on to some best-practice examples of localizability!
✋🏽 Heads up! » In the following overview, we're using the approach of do’s and don’ts. Please note that some of the don'ts below may work or even produce the same result for certain languages. Thereby, our goal isn't to point out errors but provide better solutions to localizability regardless of the locale selected.
Avoid using Java toUpperCase()/toLowerCase() for user visible strings
Do
strings.xml <string name="home">HOME</string> Java: String strHome = res.getString(R.string.home); Log.d("phrase_i18n", strHome); Output (en-US): "HOME"
Don't
strings.xml <string name="home">Home</string> Java: String strHome = res.getString(R.string.home); String curHOME = strHome.toUppercase(strHome); Log.d("phrase_i18n", curHOME); Output (en-US): "HOME"
Using those methods without specifying a concrete locale can be a hot source of bugs. It happens because those methods will use the current locale on the user's device, and even though the code appears to work correctly when you're developing the app, it will fail in some locales.
Some case mappings depend on language or locale. A good example is Turkish, where the uppercase letter 'I' maps to the dotless lowercase 'ı', and the uppercase replacement for 'i' is not 'I'.
Example: (en) QUIT ⇒ quit / (tr) QUIT => quıt
If you want the methods to just perform an ASCII replacement for converting an enum name, call toUpperCase(Locale.ENGLISH)/toLowerCase(Locale.ENGLISH)
instead. If you really want to use the current locale, call toUpperCase(Locale.getDefault())/toLowerCase(Locale.getDefault())
instead.
For further details, click here.
Avoid concatenating string resources
Do
strings.xml <string name="hello_phrase_friend">Say hello to your Phrase's friend</string> <string name="hello_phrase_colleague">Say hello to a Phrase's colleague</string> Java: String helloFriend = res.getString(R.string.hello_phrase_friend); String helloColleague = res.getString(R.string.hello_phrase_colleague); Log.d("phrase_i18n", helloFriend); Log.d("phrase_i18n", helloColleague); Output (en-US): "Say hello to your Phrase's friend" "Say hello to a Phrase's colleague"
Don't
strings.xml <string name="say_hello">Say hello to</string> <string name="phrase_friend"> your Phrase's friend</string> <string name="phrase_colleague"> a Phrase's colleague</string> Java: String sayHello = res.getString(R.string.say_hello); String friend = res.getString(R.string.phrase_friend); String colleague = res.getString(R.string.phrase_colleague); Log.d("phrase_i18n", sayHello + friend); Log.d("phrase_i18n", sayHello + colleague); Output (en-US): "Say hello to your Phrase's friend" "Say hello to a Phrase's colleague"
Different languages have different rules for word order, capitalization, gender, singular, or plural, which may result in terrible grammar mistakes.
Avoid placing non-translatable strings as translatable
Do
- Place non-translatable resources in a separated file (donottranslate.xml) or Use translatable="false" property <string name="phrase_act_name" translatable="false">PhraseActivity</string>
Don't
strings.xml <string name="phrase_act_name">PhraseActivity</string> values-fr-rFR: <string name="phrase_act_name">PhraseActivity</string>
Provide sufficient context for declared strings, e.g., by adding comments
Do
strings.xml <!-- The action for submitting a form. This text is on a button that can fit 30 chars --> <string name="login_submit_button">Sign in</string>
Don't
strings.xml <string name="login_submit_button">Sign in</string>
In general, a translation tool only shows the comment for the string currently being edited/translated. Consider providing context information that may include:
- What is this string for? When and where is it presented to the user?
- Where is this in the layout? For example, translations are less flexible in buttons than in text boxes.
Mark all message parts that should not be translated
Do
values/strings.xml <!-- Example placeholder for a name --> <string name="prod_name">Learn more at <xliff:g id="company">Phrase</xliff:g> blog</string> <!-- Example placeholder for a literal --> <string name="promo_message">Use \"<xliff:g id="promotion_code">PHRASEDISCOUNT</xliff:g>\" code to get a discount</string> <string name="countdown"><xliff:g id="time" example="5">%1$d</xliff:g> days until promotion</string> values-pt/strings.xml <string name="prod_name">Saiba mais no blog da <xliff:g id="company">Phrase</xliff:g></string> <string name="promo_message">Utilize o código \"<xliff:g id="promotion_code">PHRASEDISCOUNT</xliff:g>\" para obter um desconto</string> <string name="countdown"><xliff:g id="time" example="5">%1$d</xliff:g> dias até a promoção</string> Java: String title = res.getString(R.string.prod_name); String promoMessage = res.getString(R.string.promo_message); int days = getSharedPreferences("prefs", MODE_PRIVATE).getInt("days", 0); // 5 String countdownPromo = res.getString(R.string.countdown, days); Log.d("phrase_i18n", title); Log.d("phrase_i18n", promoMessage); Log.d("phrase_i18n", countdownPromo); Output (en-US): "Learn more at Phrase blog" "Use "PHRASEDISCOUNT" code to get a discount" "5 days until promotion" Output (pt): "Saiba mais no blog da Phrase" "Utilize o código "PHRASEDISCOUNT" para obter um desconto." "5 dias até a promoç˜ão"
Don't
values/strings.xml <!-- Example placeholder for a name --> <string name="prod_name">Learn more at Phrase blog</string> <!-- Example placeholder for a literal --> <string name="promo_message">Use \"PHRASEDISCOUNT\" code to get a discount</string> <string name="countdown">%s until holiday</string> values-pt/strings.xml <string name="prod_name">Saiba mais no blog da Frase</string> <string name="promo_message">Use o código DESCONTOFRASE para obter um desconto</string> <string name="countdown">%s dias até a promoção</string> Java: String title = res.getString(R.string.prod_name); String promoMessage = res.getString(R.string.promo_message); int days = getSharedPreferences("prefs", MODE_PRIVATE).getInt("days", 0); String countdownPromo = res.getString(R.string.countdown, days); Log.d("phrase_i18n", title); Log.d("phrase_i18n", promoMessage); Log.d("phrase_i18n", countdownPromo); Output (en-US): "Learn more at Phrase blog" "Use "PHRASEDISCOUNT" code to get a discount" "5 days until promotion" Output (pt): "Saiba mais no blog da Frase" "Use o código "DESCONTOFRASE" para obter um desconto" "5 dias até a promoção"
To mark the non-translatable content, use the <xliff:g>
tag. Common examples might be a piece of code, a placeholder for a value, a special symbol, or a name. As you prepare your strings for translation, look for, and mark text that should remain as it is, so that the translator doesn't change it.
When you declare a placeholder tag, always add an id attribute that explains what the placeholder is for. If your app replaces the placeholder value later on, be sure to provide an example attribute to clarify the expected use.
In the example above, the lack of XLIFF tags can lead to unexpected translations: Phrase > Frase, PHRASEDISCOUNT > DESCONTOFRASE, etc.
Leave extra space for translation
Do
Leave enough space for expansion (15% - 30%) or allow text wrapping and scrolling
Don't
Otherwise: “Одговори на сите” will truncate
As you can see in the example above, the English “Reply all” has a quite long counterpart in Macedonian: “Одговори на сите”.
Avoid placing newline characters
Do
Use Android Layout to organize the UI elements
Don't
/res/values/strings.xml <string name="header">Watch our Free Webinars to\nlearn all\nabout the newest Phrase features.</string> /res/values-es/strings.xml: <string name="header">Vea nuestros seminarios web gratuitos\npara aprender todo\nsobre las nuevas funciones de frase</string> Text may wraps incorrectly (error prone)
Avoid using quantity strings
Do
strings.xml <string name =”personsFound”> {count, plural, = 0 {No persons in the list.} = 1 {There is one person in the list.} = other {There are %d persons in the list.} } </string> Java: int personsCount = 18; String msg = getResources().getString(R.plurals.personsFound); String personsFound = MessageFormat.formatNamedArgs(msg, "count", personsCount); Log.d("phrase_i18n", personsFound); Output (en-US): "There are 42 persons in the list."
Don't
strings.xml <plurals name=”personsFound”> <item quantity=”zero”>No persons in the list.</item> <item quantity=”one”>There is one person in the list.</item> <item quantity=”other”>There are %d persons in the list.</item> <plurals> Java: int personsCount = 42; String msg = getResources().getQuantityString(R.plurals.personsFound, personsCount, personsCount); Output (en-US): "There are 42 persons in the list."
The recommended way is using the ICU MessageFormat class, a small library extracted from ICU.
3. Bidirectionality
When content is presented horizontally, most scripts display text/characters from left to right, but there are several scripts (such as Arabic, Persian, Hebrew, Urdu) in which the natural ordering of horizontal alignment in the display is from right to left.
The main difference between left-to-right (LTR) and right-to-left (RTL) language scripts is the direction in which content is displayed:
- Text
- LTR → Sentences are read from left to right.
- RTL → Sentences are read from right to left.
- Timeline
- LTR → An illustrated sequence of events progresses left to right.
- RTL → An illustrated sequence of events progresses right to left.
- Imagery
- LTR → An arrow pointing left to right indicates forward motion: (→)
- RTL → An arrow pointing right to left indicates forward motion: (←)
When a UI is changed from LTR to RTL (or vice versa), it’s often called "mirroring". An RTL layout is the mirror image of an LTR layout, and it affects layout, text, and graphics.
Clarifying Mirroring changes
When a UI is mirrored, the following changes occur:
- Text field icons are displayed on the opposite side of a field,
- Navigation buttons are displayed in reverse order,
- Icons that communicate direction, e.g., arrows, are mirrored,
- Text (if it is translated into an RTL language) is aligned to the right.
These items are not mirrored:
- Untranslated text (even if it’s part of a phrase),
- Icons that don't communicate direction, e.g. a camera,
- Numbers, such as time, phone numbers, URLs,
- Charts and graphs.
Android Bidi instructions
Now, let’s clarify the instructions to enable Bidi support for your app:
- Check the build and manifest files
As mentioned in the section on configuration above, the
android:supportsRtl
property needs to be set, as RTL mirroring is only supported on devices running on Android 4.2 (API level 17) or higher. - Update/standardize layout attributes
Instead of left and right, use start and end, respectively, to set the positions of UI elements in the layout resource files. It allows the FW to align app’s UI elements based on the user’s language settings.
Do
<TextView android:id="@+id/text" android:gravity="start" android:layout_marginStart android:layout_paddingEnd ...
Don't
<TextView android:id="@+id/text" android:gravity="left" android:layout_marginLeft android:layout_paddingRight ...
If you're wondering what would it be the case when all layout files of your app were implemented using left/right properties. Don't worry, Android Studio can do this for you!
Here are some further tips for other UI elements:
- ViewPager
The well-known and widely used ViewPager class does not give native support for RTL layouts. If your app has paging behavior using this class, and you want to enable native right-to-left orientation, Google released the 2nd version of ViewPager class last year, bringing several improvements, including RTL layout support. You can find more details here. Google also released a document clarifying the migration process from ViewPager to ViewPager2.
- Drawables
For instance, in case your app has drawables that need to be mirrored for an RTL layout, complete one of these steps based on the target Android version:
- Android 4.3 (API level 18) and lower
- add and define the
-ldrt
resource files.
- add and define the
- Android 4.4 (API level 19) and higher
- use
android:autoMirrored="true"
when defining drawable, which allows the system to handle RTL layout mirroring automatically
- use
- Texts
To provide specialized assets for RTL languages, you can use direction- and language-specific resources. Resources inside such directories will only be used for RTL languages or for one specific locale. See below:
res/ layout/ main.xml layout-ldrtl/ main.xml res/ layout/ main.xml layout-ar/ main.xml layout-ldrtl/ main.xml
🗒️ Note I » Language-specific resources take precedence over layout-direction-specific resources, which take precedence over the default resources.
🗒️ Note II » Although you can define specific layouts to a specific language, this is recommended only when strictly necessary. Since Android supports auto mirroring, in majority of the cases, you may be able to define a flexible layout that will work perfectly for both LTR and RTL layouts, so you will not need to maintain several layout files.
4. Formatting
To achieve world-readiness, it’s strongly recommended to use Android locale-sensitive APIs. The Android ecosystem leverages the ICU library and CLDR project to provide Unicode and other internationalization support.
Check out the following set of powerful libraries recommended to use during the development of your app:
Let’s have a look at how to best use some of the libraries mentioned above!
Don’t hard-code the Date Format
Do
Java: String datePattern = DateFormat.getBestDateTimePattern(locale, "MMMMd"); String curDate = DateFormat.format(datePattern , new Date()).toString(); Output: en-US "December 23" zh-CN "12月23日" es-US "23 de diciembre"
Don't
Java: SimpleDateFormat df = new SimpleDateFormat("MMMM d"); String curDate = df.format(new Date()); Log.d("phrase_i18n", curDate); Output: en-US "December 23" zh-CN "12月 23" es-US "diciembre 23"
On Don't output, via SimpleDateFormat
, note that the order of the content remained the same as the skeleton (MMMM d→ month day), regardless of the locale, which means the locale rule was not respected. The month and day data was localized, but the style wasn't.
While using DateFormat.getBestDateTimePattern
, we can observe the whole date, i.e., both style and content are properly localized.
With date, it's very important to pay attention to the pattern date style, which will indicate the output data. You can see the Date/Time format syntax here.
Don’t hard-code the Time format
Do
Java: String skeleton = DateFormat.is24HourFormat(context) ? "Hm" : "hm"; String timePattern = DateFormat.getBestDateTimePattern(locale, skeleton); String curtime = DateFormat.format(skeleton, new Time()); Output: en-US "10:44 PM" zh-CN "下午10:44" es-US "10:44 p. m."
Don't
Java: String curTime = DateFormat.format("hh: mm a", new Date()).toString(); Log.d("phrase_i18n", curTime); Output: en-US "10:44 PM" zh-CN "10:44 PM" es-US "10:44 PM"
Similarly to date, it's very important to keep an eye on the time style pattern, which will indicate the output data. You can see the Date/Time format syntax here.
🗒️ Note » ICU on Android does not observe the user's 24h/12h time format setting (obtained from DateFormat.is24HourFormat()). To observe the setting, either use the DateFormat or DateUtils time formatting method, or use the ICU time formatting patterns with appropriate hour pattern symbols ('h' for 12h, 'H' for 24h) for different is24HourFormat() return values.
🔗 Resource » Our dedicated tutorial takes you through localizing date and time formats in Android step-by-step.
Don’t assume that Number format is the same for each locale
Do
Java: NumberFormat nf = NumberFormat.getPercentInstance(); nf.setMaximumFractionDigits(1); String curNumber = nf.format(0.149); Log.d("phrase_i18n", curNumber); Output: en-US "14.9%" fr-FR "14,9 %" tr-TR "%14,9"
Don't
Java: String curNumber = String.format(“%.1f”, 0.149*100) + “%”; Log.d("phrase_i18n", curNumber); Output: en-US "14.9%" fr-FR "14.9%" tr-TR "14.9%"
Avoid hard-coded measurement units
Do
Java: MeasureFormat.FormatWidth fWidth = MeasureFormat.FormatWidth.SHORT; MeasureFormat mf = MeasureFormat.getInstance(Locale.getDefault(), fWidth); String size = mf.formatMeasures(new Measure(45.7, MeasureUnit.GIGABYTE)); String speedKm = mf.formatMeasures(new Measure(50, MeasureUnit.KILOMETER_PER_HOUR)); String speedMph = mf.formatMeasures(new Measure(50, MeasureUnit.MILE_PER_HOUR)); String tempF = mf.formatMeasures(new Measure(62, MeasureUnit.FAHRENHEIT)); Log.d("phrase_i18n", size); Log.d("phrase_i18n", speedKm); Log.d("phrase_i18n", speedMph); Log.d("phrase_i18n", tempF); Output: en-US "45.7 GB, 50 kph, 50 mph, 62°F"" ru-RU "45,7 ГБ, 50 км/ч, 50 миль/час, 62°F" pt-BR "45,7 GB, 50 km/h, 50 mph, 62 °F" ar-EG "٤٥٫٧ غيغابايت |٥٠ كم/س |٥٠ ميل/س |٦٢°ف"
Don't
strings.xml <string name=”gb”>GB</string> Java: String size = String.format(“%f %s”,45.7, getString(R.string.gb)); int spValue = 50; String speedKm = String.format (“%d kph”, spValue); String speedMph = String.format (“%d mph”, spValue); String tempF = String.format (“%d ºF”, 62); Log.d("phrase_i18n", size); Log.d("phrase_i18n", speedKm); Log.d("phrase_i18n", speedMph); Log.d("phrase_i18n", tempF); Output: en-US "45.7 GB, 50 kph, 50 mph, 62°F" ru-RU "45.7 GB, 50 kph, 50 mph, 62°F" pt-BR "45.7 GB, 50 kph, 50 mph, 62°F" ar-EG "45.7 GB, 50 kph, 50 mph, 62°F"
Note that the MeasureFormat
class is able to format not only the measure units according to the locale but also numbers, spaces, the occurrence of white spaces, etc.
For more information on available measure units, click here. You also can use different widths for the measure units (NARROW, SHORT, WIDE, NUMERIC).
Use BidiFormatter to format mixed (LTR and RTL text) content
Do
res/values/strings.xml <string name=”did_you_mean”>Did you mean %s?</string> res/values-iw/strings.xml <string name=”did_you_mean”>האם התכוונת ל %s?</string> String mySuggestion = "15 Bay Street, Laurel, CA"; BidiFormatter bidiFormatter = BidiFormatter.getInstance(); String content = String.format(R.string.did_you_mean, bidiFormatter.unicodeWrap(mySuggestion)); Log.d("phrase_i18n", content); Output: iw-IL "?15 Bay Street, Laurel, CA האם התכוונת ל"
Don't
res/values/strings.xml <string name=”did_you_mean”>Did you mean %s?</string> res/values-iw/strings.xml <string name=”did_you_mean”>האם התכוונת ל %s?</string> String mySuggestion = "15 Bay Street, Laurel, CA"; String.format(R.string.did_you_mean, mySuggestion); Output: iw-IL "?Bay Street, Laurel, CA 15 האם התכוונת ל"
If you take a closer look, the house number (15) appears to the right of the address, and not to the left as expected, making the house number look more like a strange postal code. The same problem may occur if you include RTL text within a message that uses the LTR text direction.
To deal with that, the unicodeWrap() method detects the direction of a string and wraps it in Unicode formatting characters that declare that direction, so the content will be displayed accordingly.
Wrapping Up Our Android App Development Tutorial
We sincerely hope this Android localization tutorial helped clarify the most pressing questions and, most importantly, made you fit for taking your Android app global. Here's a short recap:
- Never hard-code strings in your source code, and externalize all resources,
- Avoid using the concatenation and use the FormatMessage class,
- Support RTL layouts and Bidi text,
- Write code with high localizability and use the Android APIs,
- Handle plurals correctly,
- Provide contextual information in string resources,
- Test drive with pseudolocales,
- Follow the CxD guideline.
If you feel ready to start using best practices for developing multilingual Android apps, let Phrase Localization Platform do the heavy lifting and stay focused on the code you love. The world’s most powerful, connective, and customizable localization platform will save you tons of developers’ time, reduce manual errors, and increase translation quality to let you unlock the full potential of your Android app in the long run.