Arabic Numbers and Regions
May 31, 2025 · 7 minute readcode
arabicandroid
In Android 16, I noticed that my apps that explicitly tried to show Arabic-Hindi numerals (٠, ١, ٢, ٣, …), were now showing Western numerals (0, 1, 2, 3, …) instead. This happened irrespective of the phone’s primary locale.
Android 15, api 35 Android 16, api 36
Notice how the numbers everywhere are different - on Android 16, everything is using Latin numbers, whereas on Android 15, we’re getting the Arabic-Hindi numerals that we’d expect.
Side Note: The naming of the numeral systems is really confusing to me. The numbers as we write them in English, 1, 2, 3, .. are called “Arabic Numerals,” and the numbers as we’d write them in Arabic, ١, ٢, ٣, .. are referred to as “Eastern Arabic numerals,” or “Mashriqi” numerals, at least according to Wikipedia. Android region settings uses what I’ll translate as “Arabic Hindi numerals” (for the ١, ٢, ٣ variants) and “Western numerals” (for the 1, 2, 3) variants.
Typically, you’d render these numbers by using a formatter with the Arabic locale - i.e.
val formatter = NumberFormat.getNumberInstance(Locale("ar"))
formatter.format(42)
On Android 15 (api 35) and below, this code prints ٤٢, as it should. On Android 16 (api 36), it instead prints 42.
This was curious to me for a few reasons:
- when I changed the locale of the os, the clock and numbers within the various os and Google apps (clock, calendar, etc) are correct.
- there’s a settings panel for regional settings on Android 14 (api 34) and above with a specific option for how numbers are rendered. Changing this setting didn’t make a difference to the code above.
- changing the phone’s locale still didn’t lead to the expected numbers.
I spent some time implementing App Specific Locales, thinking it would help, and, in the process, learned some interesting things.
Solution
The solution I discovered is just to pass a country parameter to the Locale
itself - i.e. instead of Locale("ar")
, make it Locale("ar", "EG")
instead. This works on previous versions of Android as well (tested it on Android 21 and it works there), so this is the best solution for apps to take now. I’ve also filed a bug about this.
val formatter = NumberFormat.getNumberInstance(Locale("ar", "EG"))
formatter.format(42)
Numbering in Different Regions
In retrospect, Android “hints” at the requirement of passing in a country “variant” for Arabic by not allowing a person to simply select “Arabic,” but always showing a secondary menu for selecting a country.
I did a simple test to see what each country would print:
val countries = mapOf(
"Algeria" to "DZ",
"Bahrain" to "BH",
"Comoros" to "KM",
"Djibouti" to "DJ",
"Egypt" to "EG",
"Iraq" to "IQ",
"Jordan" to "JO",
"Kuwait" to "KW",
"Lebanon" to "LB",
"Libya" to "LY",
"Mauritania" to "MR",
"Morocco" to "MA",
"Oman" to "OM",
"Palestine" to "PS",
"Qatar" to "QA",
"Saudi Arabia" to "SA",
"Somalia" to "SO",
"Sudan" to "SD",
"Syria" to "SY",
"Tunisia" to "TN",
"United Arab Emirates" to "AE",
"Yemen" to "YE"
)
@Composable
fun CountryNumbers(
modifier: Modifier = Modifier
) {
val number = 123_456_789
Column(modifier.fillMaxWidth().padding(16.dp)) {
countries.forEach { (country, code) ->
val formatter = NumberFormat.getNumberInstance(Locale("ar", code))
Text("$country: " + formatter.format(number))
}
}
}
With the exception of Algeria, Libya, Morocco, Tunisia, and UAE, all countries output Arabic-Hindi numerals. Notice also the other effects of the regions - notice that for the locales showing Western numbers, ‘.’ is the separator, with the exception of UAE, which uses ‘,’.

CLDR and the Root Cause
How does the OS know to use Arabic Hindi numbers for some countries and Latin numbers for others? This data comes from CLDR, the Unicode Common Locale Data Repository.
We can look at the CLDR Repository to see the details for any given locale. For instance, looking at the ar_EG, we see:
<defaultNumberingSystem>arab</defaultNumberingSystem>
In comparison, in ar_AE, we see:
<defaultNumberingSystem>↑↑↑</defaultNumberingSystem>
This means that the AE region is taking its data from the parent, (in this case, ar.xml
), which has:
<defaultNumberingSystem>↑↑↑</defaultNumberingSystem>
<defaultNumberingSystem alt="latn">latn</defaultNumberingSystem>
In turn, ar.xml
suggests that the default numbering system inherits from the parent (root.xml) which sets Latin, but if this is the case, why did this only change in Android 16?
I did some digging and found this issue on the Unicode tracker:
Change ar to default to ASCII digits. While many Arabic-speaking users prefer native digits, all understand ASCII digits: They are in widespread usage even in countries that prefer native digits. This would maximize understanding when we don’t know a user’s country, or a user selects Arabic but declines to select a regional variant.
also, in comment 8:
Stock ICU uses “standard” variant for ar.xml (Arab); Google etc will filter the data so that ar.xml gets
<defaultNumberingSystem>Latn</…>
This happened in CLDR 33. Looking at the top of Locale.java
, we see that Android 10 and above ship with CLDR 34+.
If I write a vanilla Kotlin test:
fun main() {
val nf = NumberFormat.getNumberInstance(Locale("ar"))
println("Arabic: " + nf.format(1234567))
val nfEg = NumberFormat.getNumberInstance(Locale("ar", "EG"))
println("Arabic (Egypt): " + nfEg.format(1234567))
}
and run it on the command line, I get the expected ١٬٢٣٤٬٥٦٧ for both lines (using target bytecode for Java 22).
I suspect this means that the ordinary default is Arabic-Hindi numbers, and that Google is doing something specifically to make the default Western numbers.
Locale Extensions
Today, these methods are deprecated:
public Locale(String language) { /* .. */ }
public Locale(String locale, String country) { /* .. */ }
However, Locale
itself is not deprecated. One of the supported ways we can get a Locale
is using BCP-47 language tags:
Locale.fromLanguageTag("ar")
Locale.fromLanguageTag("ar-EG")
We can use these tags to pass in additional information. Per the documentation of Locale
:
UTS#35, “Unicode Locale Data Markup Language” defines optional attributes and keywords to override or refine the default behavior associated with a locale. A keyword is represented by a pair of key and type. For example, “nu-thai” indicates that Thai local digits (value:“thai”) should be used for formatting numbers (key:“nu”). The keywords are mapped to a BCP 47 extension value using the extension key ‘u’ (UNICODE_LOCALE_EXTENSION). The above example, “nu-thai”, becomes the extension “u-nu-thai”.
We can therefore pass in a value to the key nu
to set the numbering system to one of these two values:
nu-latn
(use Latin numbers)nu-arab
(use Arabic-Hindi numbers)
// Arabic, for Saudi Arabia, with Arabic-Hindi digits
// this is the same as just `Locale.forLanguageTag("ar-SA")`
Locale.forLanguageTag("ar-SA-u-nu-arab")
// we can use this to not specify a language and get Arabic-Hindi digits:
Locale.forLanguageTag("ar-u-nu-arab")
// we can even use it to get Arabic numbers outside of Arabic:
Locale.forLanguageTag("en-u-nu-arab")
All of the above will print out in Arabic Hindi digits. We can similarly replace nu-arab
with nu-latn
to get Latin numbers for countries that default to non-Latin numbering.
// using this locale will give Latin numbers
Locale.forLanguageTag("ar-EG-u-nu-latn")
Responding to Region Changes
Android 33 (Android 13.0, Tiramisu) and above support App Specific Languages. Android 34 (Android 14.0, Upside Down Cake) also introduced region preferences. Region settings for Arabic numbers will only appear if Arabic is a system language. In other words, if I just set Arabic as an app language for one or more of my apps without setting it as a system language, I will not see the region settings screen.
Changing the numbering preferences sends Intent.ACTION_LOCALE_CHANGED
and Intent.ACTION_CONFIGURATION_CHANGED
broadcasts. As far as I can tell, these don’t have any extra data associated with them when the region is changed (i.e. intent.data
and intent.extras
are both null).
With respect to Locale.getDefault()
, it only gets updated if:
- the app’s language is set to “System Default”
- the system default language is Arabic
Alternatively, setting the region alone will not update the default Locale
unless you also update the app specific language (in the case of updating the
region, you’ll see a second language available in the list of languages - the selected one is with the old region, the new one is with the new region).
If we wanted to handle this within our app, we could register receivers for the two broadcasts and look at the systemLocales
and act on it accordingly:
val localeManager: LocaleManager = getSystemService(Context.LOCALE_SERVICE) as LocaleManager
val systemLocales = localeManager.systemLocales
val currentDefaultLocale = Locale.getDefault()
// find all system matches for our current locale's language
val systemMatches = (0 until systemLocales.size())
.map { systemLocales.get(it) }
.filter { it.language == currentDefaultLocale.language }
// whenever we find a match that's different from our current locale, set it
// using the language tags.
val systemMatch = systemMatches.first()
if (systemMatch != currentDefaultLocale) {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(systemMatch.toLanguageTag()))
}
Because LocaleManager.systemLocales
will reflect the currently selected region, using the code above will, in practice, switch from ar-EG
to ar-EG-u-nu-latn
and vice versa, allowing Locale.getDefault()
to reflect the currently selected region.