From c6071239cee92c85617d3614ae6df71adcebc601 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 11 Aug 2026 18:05:33 +0200 Subject: [PATCH] Resolve the applied locale back onto a shipped tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../floret/components/SettingsRecipes.kt | 5 ++- .../floret/locale/AppLanguage.kt | 29 ++++++++++++++ .../floret/locale/AppLanguageTest.kt | 40 +++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt index aaebaa4..c7055ee 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt @@ -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(null) + AppLanguage.supportedTags(context, localesConfig) } + val options = remember(supported) { listOf(null) + supported } val label: (String?) -> String = { tag -> tag?.let { AppLanguage.displayName(it) } ?: autoLabel } GroupedRow( diff --git a/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/AppLanguage.kt b/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/AppLanguage.kt index 7d8247e..8b1efeb 100644 --- a/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/AppLanguage.kt +++ b/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/AppLanguage.kt @@ -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? = 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? { + 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) { diff --git a/core-locale/src/test/kotlin/de/jeanlucmakiola/floret/locale/AppLanguageTest.kt b/core-locale/src/test/kotlin/de/jeanlucmakiola/floret/locale/AppLanguageTest.kt index e4224c0..cf2817a 100644 --- a/core-locale/src/test/kotlin/de/jeanlucmakiola/floret/locale/AppLanguageTest.kt +++ b/core-locale/src/test/kotlin/de/jeanlucmakiola/floret/locale/AppLanguageTest.kt @@ -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() + } }