How to Translate strings.xml (Plurals, Escaping, RTL and Qualifiers)
Default copy lives in res/values/strings.xml. Translations live in sibling values-<qualifiers> directories. The resource matcher selects one directory per configuration; it does not merge keys across locales. A missing key falls back toward values/. That is the entire setup. The rest of this guide is what actually breaks once that structure is in place.
Resource qualifiers and the BCP-47 form
Language folders use a two-letter ISO 639-1 code. Region is optional: a lowercase r plus a two-letter ISO 3166-1-alpha-2 code. Documented shapes:
- Language only:
values-es,values-en,values-fr. - Language plus region:
values-en-rUS,values-fr-rFR,values-fr-rCA,values-es-rMX.
Android 7.0 (API level 24) introduced BCP 47 language tags as resource qualifiers. The folder form is b+, then the language code, then any further subtags separated by + (not hyphens). Documented examples: values-b+en, values-b+en+US, values-b+es+419, values-b+zh+Hant, values-b+sr+Latn, values-b+sr+Latn+RS.
The resource configuration code maps a real BCP 47 tag by prefixing b+ and replacing - with +. zh-Hans-CN becomes b+zh+Hans+CN.
Use the old values-xx / values-xx-rYY form when you only need language, or language plus a two-letter region. Use the b+ form when the tag cannot be expressed that way: a script subtag (Latn, Hant, Hans), a three-digit region (419 for Latin America), or language+script+region together. Serbian Latin in Serbia is the canonical case: values-b+sr+Latn+RS, not a guess at values-sr-rRS. Script is not available in the r-region syntax.
Do not mix both spellings for the same locale and expect them to be aliases. They are different qualifier strings. Keep one convention per locale, and prefer b+ as soon as a script or UN M.49 region appears.
Quantity strings: grammatical number, not numeric buckets
<plurals> is an XML resource that holds different strings for pluralization. Each <item> takes a quantity attribute. Android’s quantity keywords are the CLDR categories: zero, one, two, few, many, and other. Every locale must be able to fall through to other.
<plurals name="item_count">
<item quantity="one">%d item</item>
<item quantity="other">%d items</item>
</plurals>
The trap that catches people constantly: selection is by grammatical number category for that locale, not by the numeric value you have in your head. You do not get to define “0 uses this string, 1 uses that string, 2–4 use another.” Android maps the count through CLDR plural rules, then picks the matching quantity. English has no zero category in those rules, so a count of 0 selects other, even if you shipped a zero item. A few item in English is dead code. In a language that distinguishes few and many, the same integer can land in different categories than you assumed from English.
Ship only the categories the target language actually uses, plus other. Do not copy an English two-item block into Russian or Arabic and call it translated. Do not treat two as “the number 2” in languages where 2 is other. If a translator asks for a special zero string in English, that is a product decision, not something <plurals> will select for you.
Escaping and protecting placeholders
strings.xml is XML parsed by the resource compiler. Apostrophe ' is escaped as \'. Double quotes inside a string are escaped as \". Backslash, newline, and tab round-trip as \\, \n, and \t.
Ampersand is an XML entity boundary. Write &, not a raw &, or the file will not parse. A string whose first significant character is @ is parsed as a resource reference; a leading ? is parsed as a theme attribute. If you meant a literal at-sign or question mark at the start of the value, escape it or wrap the whole value in quotes so the parser does not consume it as a reference.
Placeholders that must not be rewritten in translation are wrapped in <xliff:g>. Platform resources do it like this:
<string name="fileSizeSuffix">"<xliff:g id="NUMBER">%1$s</xliff:g> <xliff:g id="UNIT">%2$s</xliff:g>"</string>
<string name="incoming_sender_content_description">
Message from <xliff:g id="sender">%s</xliff:g>
</string>
The id names the hole for translators. SDK sample files declare the XLIFF namespace even when they do not use the tags; using <xliff:g> around format arguments is the established pattern. Do not let a translator rewrite %1$s into prose.
Positional format specifiers
A single %s is fine. Two or more arguments are not. Non-positional %s / %d bind in source order. Translation reorders sentence parts; the second argument in English is often the first in German or Japanese. The runtime still substitutes in declaration order, so you get swapped names, sizes, and units.
Use positional specifiers: %1$s, %2$s, %1$d. Platform strings with more than one argument do this, usually inside <xliff:g>. Number the arguments from the default locale and keep those numbers stable across translations. Translators move the tokens; they must not renumber them.
RTL: supportsRtl, start/end, mirroring
RTL text mirroring is supported only on Android 4.2 (API level 17) or higher. Android 4.2 added native RTL layouts, including layout mirroring. Turn it on with android:supportsRtl="true" on the application in the manifest. If minSdkVersion is below 17, pre-4.2 devices ignore both android:supportsRtl and the start/end attributes.
Replace left/right layout properties with start/end equivalents: android:gravity="end" instead of android:gravity="right", and the same for padding, margin, and alignment. Targeting API 17 or higher with both sets defined: start and end are used and override left and right.
If minSdkVersion is 17 or higher, use start/end in place of left/right. If the app must also run before Android 4.2, add start/end in addition to left/right so old devices still get a physical-direction layout. A half-finished migration (RTL attributes present, supportsRtl still false) is a known lint situation; the attribute can be left false until the layout pass is done.
Mirroring flips the view hierarchy. It does not flip icons that encode direction in the asset itself, and it does not fix hardcoded Gravity.LEFT in Java/Kotlin. Strings that embed bidi marks or concatenated numbers still need correct wrapping; RTL is not a strings.xml-only switch.
translatable="false", MissingTranslation, ExtraTranslation
Mark strings that must never be sent to translators with translatable="false": protocol tokens, symbol sets, debug labels, format skeletons that are not linguistic. Those keys stay in values/ and should not be copied into locale folders.
MissingTranslation fires when a default-locale string has no counterpart in a translated file. ExtraTranslation fires when a locale file contains a key that is not in the default set. Both are how bitrot shows up: a new English string never exported, or a deleted key left behind in values-fr.
Ignore a specific occurrence with tools:ignore="MissingTranslation" on the element. Ignore the check project-wide in lint.xml:
<issue id="MissingTranslation" severity="ignore" />
Do not silence the check to hide unfinished locales. Use translatable="false" for strings that are not user-facing, and keep locale files as strict subsets of the default file plus intended overrides.
Per-app language preferences and android:localeConfig
Automatic per-app language preferences exist for apps running on Android 13 (API level 33) or higher. compileSdkVersion must be 33 or higher. The system Settings language picker for your app is not inferred from values-* folders. You publish the supported set explicitly.
Create res/xml/locale_config.xml and point the manifest at it with android:localeConfig. That attribute is an XML resource holding the application’s android.app.LocaleConfig. Each supported locale is an IETF BCP 47 language tag in android:name.
The tags in locale_config.xml should match the locales you actually ship resources for, including script where you used values-b+sr+Latn. A tag in the config with no resource folder still appears in the picker and then falls back to default strings. A resource folder with no config entry is invisible to the per-app picker on API 33+.
Escaping rules do not survive a naive conversion to i18n JSON, and the file formats guide compares strings.xml with the iOS and Flutter equivalents. If you ship iOS too, translate Localizable.strings from the same source text in the same pass. The complete guide covers language choice and cost.
Frequently asked questions
When do I use values-b+sr+Latn instead of values-sr?
When you need a script (or a tag the r-region syntax cannot write). BCP 47 folders need API 24. values-sr is language only; Latin Serbian is values-b+sr+Latn or values-b+sr+Latn+RS.
Does android:localeConfig replace values-* folders?
No. Folders still supply strings. locale_config.xml only lists locales for the Android 13+ per-app picker. You need both, and the BCP 47 tags should agree.
Can I set android:supportsRtl="true" with minSdkVersion below 17?
Yes, but API 16 and lower ignore supportsRtl and start/end. Keep left/right alongside start/end if those devices still matter. Mirroring itself is API 17+.
Will translatable="false" silence MissingTranslation for that key in every locale?
That is the intent: the key is not a translation unit. Do not also copy it into locale files, or ExtraTranslation becomes the next failure.
Why does a French translation with two %s tokens show the wrong values?
Non-positional specifiers follow source order, not the translated order. Use %1$s and %2$s and wrap them in <xliff:g>.
What compileSdkVersion does per-app languages need?
33 or higher. The feature runs on Android 13 (API 33)+. The manifest attribute is android:localeConfig pointing at res/xml/locale_config.xml.
Ship your app in 50 languages by tonight
Upload your localization file, review side by side, download ready-to-import files for every language. One-time credits from $9 — no subscription.
No subscription. Credits never expire.