Resolve the applied locale back onto a shipped tag

The per-app-language store re-parses the applied locale through ICU and
fills in the implied script, so a zh-CN choice reads back as zh-Hans-CN and
no longer matches the tag the picker offers — the selected row lost its
mark after a restart. currentTag(supported) maps it back.
This commit is contained in:
2026-08-11 18:05:33 +02:00
parent 05c79f2afc
commit c6071239ce
3 changed files with 72 additions and 2 deletions
@@ -122,10 +122,11 @@ fun LanguagePickerRow(
leading: @Composable () -> Unit,
) {
val context = LocalContext.current
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
val supported = remember { AppLanguage.supportedTags(context, localesConfig) }
var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) }
var showDialog by remember { mutableStateOf(false) }
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, localesConfig) }
val options = remember(supported) { listOf<String?>(null) + supported }
val label: (String?) -> String = { tag -> tag?.let { AppLanguage.displayName(it) } ?: autoLabel }
GroupedRow(
@@ -57,6 +57,35 @@ object AppLanguage {
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
}
/**
* The applied app language as one of [supported], or `null` when following
* the system.
*
* Prefer this over [currentTag] whenever the result is compared against
* [supportedTags]: the platform store re-parses the applied locale through
* ICU and fills in the implied script, so a "zh-CN" choice reads back as
* "zh-Hans-CN" and no longer string-matches the tag the picker offers.
*/
fun currentTag(supported: List<String>): String? = resolveTag(currentTag(), supported)
/**
* Maps [tag] onto the entry of [supported] naming the same language, or
* returns it unchanged when nothing matches. Matching widens in steps —
* exact tag, then language plus region, then language alone — so a more
* specific entry always wins over a looser one.
*/
fun resolveTag(tag: String?, supported: List<String>): String? {
if (tag == null) return null
val locale = Locale.forLanguageTag(tag)
return supported.firstOrNull { it == tag }
?: supported.firstOrNull {
val other = Locale.forLanguageTag(it)
other.language == locale.language && other.country == locale.country
}
?: supported.firstOrNull { Locale.forLanguageTag(it).language == locale.language }
?: tag
}
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
fun apply(tag: String?) {
val locales = if (tag == null) {
@@ -17,4 +17,44 @@ class AppLanguageTest {
// them title-cased so each language reads as a proper name.
assertThat(AppLanguage.displayName("fr")).isEqualTo("Français")
}
private val supported = listOf("en", "ar", "de", "es", "fr", "it", "pl", "pt-BR", "zh-CN")
@Test
fun `a script the platform filled in still resolves to the shipped tag`() {
// The per-app-language store re-parses "zh-CN" through ICU likely
// subtags and hands it back as "zh-Hans-CN"; the picker only has
// "zh-CN" to offer, so without this the selected row loses its mark.
assertThat(AppLanguage.resolveTag("zh-Hans-CN", supported)).isEqualTo("zh-CN")
}
@Test
fun `tags that round-trip unchanged are returned as they are`() {
assertThat(AppLanguage.resolveTag("pt-BR", supported)).isEqualTo("pt-BR")
assertThat(AppLanguage.resolveTag("de", supported)).isEqualTo("de")
}
@Test
fun `a region the app does not distinguish falls back to the language`() {
assertThat(AppLanguage.resolveTag("en-US", supported)).isEqualTo("en")
}
@Test
fun `an exact match wins over a looser one`() {
// "pt" and "pt-BR" both name Portuguese; the regional entry must not
// swallow the plain one just by being listed first.
val both = listOf("pt-BR", "pt")
assertThat(AppLanguage.resolveTag("pt", both)).isEqualTo("pt")
assertThat(AppLanguage.resolveTag("pt-BR", both)).isEqualTo("pt-BR")
}
@Test
fun `an unshipped language is left alone`() {
assertThat(AppLanguage.resolveTag("ja", supported)).isEqualTo("ja")
}
@Test
fun `following the system stays null`() {
assertThat(AppLanguage.resolveTag(null, supported)).isNull()
}
}