From 21275d21a68ef76d55b73a4d258efd04329722c3 Mon Sep 17 00:00:00 2001 From: Abhishek Keshri Date: Mon, 12 Oct 2026 16:10:42 +0530 Subject: [PATCH 1/6] Match resources against a prioritized list of locales Resource matching only looked at the first preferred language, so an app that ships Spanish but no French gave a user who prefers French, then Spanish, the unqualified values/ folder. Walk the list and take the first locale that matches, as in table 3 of Android's multilingual support guide. A locale also falls back to another region of the same language first, as in table 2, but never across scripts: a locale that names one is matched on it, and otherwise the script the region implies is compared, so neither zh-Hans nor zh-CN lands on values-zh-rTW. Fixes https://youtrack.jetbrains.com/issue/CMP-6840 --- .../jetbrains/compose/resources/Qualifier.kt | 16 + .../compose/resources/ResourceEnvironment.kt | 226 +++++++-- .../compose/resources/ResourceTest.kt | 475 +++++++++++++++++- 3 files changed, 669 insertions(+), 48 deletions(-) diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/Qualifier.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/Qualifier.kt index 07ea518afda..c1a5ad9f66c 100644 --- a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/Qualifier.kt +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/Qualifier.kt @@ -110,3 +110,19 @@ enum class DensityQualifier(val dpi: Int) : Qualifier { } } } + +/** + * One locale in a preference list used for resource matching. + */ +@InternalResourceApi +data class LocaleQualifiers( + val language: LanguageQualifier, + val script: ScriptQualifier = ScriptQualifier(""), + val region: RegionQualifier = RegionQualifier("") +) { + constructor(language: String, script: String = "", region: String = "") : this( + LanguageQualifier(language), + ScriptQualifier(script), + RegionQualifier(region) + ) +} diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.kt index c40c2d500cd..c1a542d385d 100644 --- a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.kt +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.kt @@ -4,23 +4,35 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.intl.LocaleList class ResourceEnvironment internal constructor( - internal val language: LanguageQualifier, - internal val script: ScriptQualifier, - internal val region: RegionQualifier, + internal val locales: List, internal val theme: ThemeQualifier, internal val density: DensityQualifier ) { + internal val language: LanguageQualifier get() = locales.firstOrNull()?.language ?: LanguageQualifier("") + internal val region: RegionQualifier get() = locales.firstOrNull()?.region ?: RegionQualifier("") + + internal constructor( + language: LanguageQualifier, + script: ScriptQualifier, + region: RegionQualifier, + theme: ThemeQualifier, + density: DensityQualifier + ) : this( + locales = listOf(LocaleQualifiers(language, script, region)), + theme = theme, + density = density + ) + override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || this::class != other::class) return false other as ResourceEnvironment - if (language != other.language) return false - if (script != other.script) return false - if (region != other.region) return false + if (locales != other.locales) return false if (theme != other.theme) return false if (density != other.density) return false @@ -28,9 +40,7 @@ class ResourceEnvironment internal constructor( } override fun hashCode(): Int { - var result = language.hashCode() - result = 31 * result + script.hashCode() - result = 31 * result + region.hashCode() + var result = locales.hashCode() result = 31 * result + theme.hashCode() result = 31 * result + density.hashCode() return result @@ -45,18 +55,35 @@ internal interface ComposeEnvironment { internal val DefaultComposeEnvironment = object : ComposeEnvironment { @Composable override fun rememberEnvironment(): ResourceEnvironment { - val composeLocale = Locale.current + val composeLocales = LocaleList.current val composeTheme = isSystemInDarkTheme() val composeDensity = LocalDensity.current //cache ResourceEnvironment unless compose environment is changed - return remember(composeLocale, composeTheme, composeDensity) { + return remember(composeLocales, composeTheme, composeDensity) { + val composeMapped = if (composeLocales.isEmpty()) { + val single = Locale.current + listOf( + LocaleQualifiers( + LanguageQualifier(single.language), + ScriptQualifier(single.script), + RegionQualifier(single.region) + ) + ) + } else { + composeLocales.map { + LocaleQualifiers( + LanguageQualifier(it.language), + ScriptQualifier(it.script), + RegionQualifier(it.region) + ) + } + } + val locales = selectResourceLocales(composeMapped, getPreferredLocales()) ResourceEnvironment( - LanguageQualifier(composeLocale.language), - ScriptQualifier(composeLocale.script), - RegionQualifier(composeLocale.region), - ThemeQualifier.selectByValue(composeTheme), - DensityQualifier.selectByDensity(composeDensity.density) + locales = locales, + theme = ThemeQualifier.selectByValue(composeTheme), + density = DensityQualifier.selectByDensity(composeDensity.density) ) } } @@ -78,12 +105,45 @@ fun rememberResourceEnvironment(): ResourceEnvironment { return composeEnvironment.rememberEnvironment() } +/** + * Chooses the locale list used for resource matching. + * + * [androidx.compose.ui.text.intl.LocaleList] is used as-is when it already contains a + * fallback chain, which is the case on Android API 24+ and on the web. + * + * On JVM desktop it holds a single platform-default locale even when the OS has a preference + * list, so the OS list supplies the missing fallback chain. The Compose locale stays in front + * of it: an app that calls `Locale.setDefault` to pick its language is indistinguishable here + * from a platform default, and dropping it would ignore the app. + */ +internal fun selectResourceLocales( + composeLocales: List, + systemLocales: List +): List { + if (composeLocales.isEmpty()) return systemLocales + if (composeLocales.size > 1 || systemLocales.size <= 1) return composeLocales + val compose = composeLocales.single() + return listOf(compose) + systemLocales.filterNot { it == compose } +} + +/** + * The locales the user prefers, most preferred first. + * + * Kept separate from [getSystemEnvironment] so that composition only pays for the locales: + * resolving the theme and the density is more expensive, and [DefaultComposeEnvironment] + * takes both from Compose instead. + */ +internal expect fun systemPreferredLocales(): List + internal expect fun getSystemEnvironment(): ResourceEnvironment -//the function reference will be overridden for tests +//the function references will be overridden for tests //@TestOnly internal var getResourceEnvironment = ::getSystemEnvironment +//@TestOnly +internal var getPreferredLocales = ::systemPreferredLocales + /** * Provides the resource environment for non-composable access to resources. * It is an expensive operation! Don't use it in composable functions with no cache! @@ -94,7 +154,7 @@ fun getSystemResourceEnvironment(): ResourceEnvironment = getResourceEnvironment internal fun Resource.getResourceItemByEnvironment(environment: ResourceEnvironment): ResourceItem { //Priority of environments: https://developer.android.com/guide/topics/resources/providing-resources#table2 items.toList() - .filterByLocale(environment.language, environment.script, environment.region) + .filterByLocales(environment.locales) .also { if (it.size == 1) return it.first() } .filterBy(environment.theme) .also { if (it.size == 1) return it.first() } @@ -170,50 +230,124 @@ private fun List.filterByDensity(density: DensityQualifier): List< } } -// Filter by language, script, and region together (extended from the original lang+region logic): +// Filter by prioritized list of locales. +// For each locale in the prioritized list (e.g. [fr-CA, es-US, en-US]): // 1) exact language + script + region -> use it // 2) language + script (no region) -> use it // 3) language + region (no script) -> use it // 4) language only (no script, no region) -> use it -// 5) items with NO locale qualifiers at all (default) -// When the environment script is empty (e.g. DefaultComposeEnvironment), prefer items without +// 5) same language, another region of the same script (Android parent/child locale, fr-CH -> fr-FR) +// Step 5 is skipped when the locale names a script, and otherwise compares the script the +// region implies, so neither zh-Hans nor zh-CN ever lands on values-zh-rTW. +// If none of the preferred locales match: +// 6) items with NO locale qualifiers at all (default) +// When the environment script is empty, prefer items without // a ScriptQualifier first; fall back to script-tagged items only if nothing else matches. -// issue: https://github.com/JetBrains/compose-multiplatform/issues/4571 -private fun List.filterByLocale( - language: LanguageQualifier, - script: ScriptQualifier, - region: RegionQualifier +// https://youtrack.jetbrains.com/issue/CMP-6840 +// https://github.com/JetBrains/compose-multiplatform/issues/4571 +private fun List.filterByLocales( + locales: List ): List { - // Case 5: items with NO locale qualifiers at all (default) - val noLocaleItems = filter { item -> + for (locale in locales) { + val matched = filterBySingleLocale(locale.language, locale.script, locale.region) + if (matched.isNotEmpty()) { + return matched + } + } + + // Default: items with NO locale qualifiers at all + return filter { item -> item.qualifiers.none { it is LanguageQualifier || it is ScriptQualifier || it is RegionQualifier } } +} + +private fun ResourceItem.scriptQualifier(): ScriptQualifier? = + qualifiers.filterIsInstance().firstOrNull() + +private fun ResourceItem.regionQualifier(): RegionQualifier? = + qualifiers.filterIsInstance().firstOrNull() + +private val TRADITIONAL_CHINESE_REGIONS = setOf("TW", "HK", "MO") + +// Android computes a script for a locale that does not name one and never matches across +// scripts, so values-zh-rCN and values-zh-rTW do not substitute for each other. See +// ResTable_config::computeScript and localeDataComputeScript. Chinese is the language where +// the region implies the script; null means the region constrains nothing. +private fun likelyScript(language: String, region: String): String? = when (language) { + "zh" -> if (region in TRADITIONAL_CHINESE_REGIONS) "Hant" else "Hans" + else -> null +} + +private fun List.narrowByRegion( + language: LanguageQualifier, + region: RegionQualifier, + allowOtherRegions: Boolean +): List { + if (isEmpty()) return emptyList() + val regionCode = region.region + if (regionCode.isNotEmpty()) { + val exact = filter { it.regionQualifier()?.region == regionCode } + if (exact.isNotEmpty()) return exact + } + val noRegion = filter { it.regionQualifier() == null } + if (noRegion.isNotEmpty()) return noRegion + if (!allowOtherRegions) return emptyList() + // Android falls back to another region of the same language: fr-CH uses fr-FR. It ranks the + // candidates with the CLDR region-distance table, which is not bundled here, so the lowest + // region code wins and the result is the same on every run. Every item of that region is + // kept so that theme and density filtering still runs. + val wantedScript = likelyScript(language.language, regionCode) + val candidates = filter { likelyScript(language.language, it.regionQualifier()?.region.orEmpty()) == wantedScript } + val fallbackRegion = candidates.mapNotNull { it.regionQualifier()?.region }.minOrNull() ?: return emptyList() + return filter { it.regionQualifier()?.region == fallbackRegion } +} + +private fun ResourceItem.effectiveScript(language: String): String? = + scriptQualifier()?.script?.takeIf { it.isNotEmpty() } + ?: likelyScript(language, regionQualifier()?.region.orEmpty()) +private fun List.filterBySingleLocale( + language: LanguageQualifier, + script: ScriptQualifier, + region: RegionQualifier +): List { val withLanguage = filter { item -> item.qualifiers.any { it == language } } - if (withLanguage.isEmpty()) return noLocaleItems + if (withLanguage.isEmpty()) return emptyList() - // Case 1 & 2: language + script items, narrowed by region (exact script+region or script only) - val withScript = withLanguage.filter { item -> - item.qualifiers.any { it == script } + val wantedScript = script.script.ifEmpty { likelyScript(language.language, region.region).orEmpty() } + val scriptCompatible = if (wantedScript.isNotEmpty()) { + withLanguage.filter { + val itemScript = it.effectiveScript(language.language) + itemScript == null || itemScript == wantedScript + } + } else { + withLanguage } - val byScriptAndRegion = withScript.filterBy(region) - if (byScriptAndRegion.isNotEmpty()) return byScriptAndRegion + if (scriptCompatible.isEmpty()) return emptyList() + + if (!script.isEmpty()) { + val explicitScript = scriptCompatible.filter { it.scriptQualifier() == script } + val explicitMatch = explicitScript.narrowByRegion(language, region, allowOtherRegions = false) + if (explicitMatch.isNotEmpty()) return explicitMatch - // Case 3 & 4: language items without a script qualifier, narrowed by region (exact region or language only) - val withDefaultScript = withLanguage.filter { item -> - item.qualifiers.none { it is ScriptQualifier } + val withoutScript = scriptCompatible.filter { it.scriptQualifier() == null } + return withoutScript.narrowByRegion(language, region, allowOtherRegions = false) } - val byDefaultScriptAndRegion = withDefaultScript.filterBy(region) - if (byDefaultScriptAndRegion.isNotEmpty()) return byDefaultScriptAndRegion - // Fallback: don't cross scripts when one was requested (zh-Hans must not fall back to zh-Hant) - // When the environment script is empty, fall back to script-tagged items only if nothing else matches. - if (script.isEmpty()) { - val byRegion = withLanguage.filterBy(region) - if (byRegion.isNotEmpty()) return byRegion + if (wantedScript.isNotEmpty()) { + val explicitScript = scriptCompatible.filter { it.scriptQualifier()?.script == wantedScript } + val explicitMatch = explicitScript.narrowByRegion(language, region, allowOtherRegions = true) + if (explicitMatch.isNotEmpty()) return explicitMatch + + val withoutScript = scriptCompatible.filter { it.scriptQualifier() == null } + return withoutScript.narrowByRegion(language, region, allowOtherRegions = true) } - return noLocaleItems -} \ No newline at end of file + val withDefaultScript = withLanguage.filter { it.scriptQualifier() == null } + val defaultScriptMatch = withDefaultScript.narrowByRegion(language, region, allowOtherRegions = true) + if (defaultScriptMatch.isNotEmpty()) return defaultScriptMatch + + return withLanguage.narrowByRegion(language, region, allowOtherRegions = true) +} diff --git a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceTest.kt b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceTest.kt index 0c3ff3d9237..589b35e33d5 100644 --- a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceTest.kt +++ b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceTest.kt @@ -54,12 +54,14 @@ class ResourceTest { "de-rUS", resource.getResourceItemByEnvironment(env("de", "US", LIGHT, LDPI)).path ) + // de and de-IN both fall back to the only German folder, like Android's parent/child + // locale matching: https://developer.android.com/guide/topics/resources/multilingual-support assertEquals( - "default", + "de-rUS", resource.getResourceItemByEnvironment(env("de", "", LIGHT, LDPI)).path ) assertEquals( - "default", + "de-rUS", resource.getResourceItemByEnvironment(env("de", "IN", LIGHT, LDPI)).path ) assertEquals( @@ -218,4 +220,473 @@ class ResourceTest { resource.getResourceItemByEnvironment(env("en", "", "US")).path ) } + + @Test + fun testPrioritizedLocalesExactRegionThenLanguageThenDefault() { + val resource = DrawableResource( + id = "ImageResource:multilocale_test", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("en")), "en", -1, -1), + ResourceItem(setOf(LanguageQualifier("en"), RegionQualifier("US")), "en-US", -1, -1), + ResourceItem(setOf(LanguageQualifier("fr"), RegionQualifier("FR")), "fr-FR", -1, -1), + ResourceItem(setOf(LanguageQualifier("es")), "es", -1, -1), + ResourceItem(setOf(LanguageQualifier("es"), RegionQualifier("ES")), "es-ES", -1, -1), + ResourceItem(setOf(LanguageQualifier("de")), "de", -1, -1), + ) + ) + + assertEquals( + "fr-FR", + resource.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("fr", "", "FR"), + LocaleQualifiers("es", "", "ES"), + LocaleQualifiers("en", "", "US") + ) + ).path + ) + + // Another region of the same language is Android parent/child locale matching. + assertEquals( + "fr-FR", + resource.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("fr", "", "CA"), + LocaleQualifiers("es", "", "MX"), + LocaleQualifiers("en", "", "US") + ) + ).path + ) + + assertEquals( + "de", + resource.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("it", "", "IT"), + LocaleQualifiers("pt", "", "BR"), + LocaleQualifiers("de", "", "DE") + ) + ).path + ) + + assertEquals( + "default", + resource.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("ja", "", "JP"), + LocaleQualifiers("ko", "", "KR"), + LocaleQualifiers("ru", "", "RU") + ) + ).path + ) + } + + @Test + fun testLanguageWithoutRegionBeatsLaterLocale() { + val resourceWithBaseFr = DrawableResource( + id = "ImageResource:base_fr_test", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("fr")), "fr", -1, -1), + ResourceItem(setOf(LanguageQualifier("es"), RegionQualifier("ES")), "es-ES", -1, -1) + ) + ) + assertEquals( + "fr", + resourceWithBaseFr.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("fr", "", "CA"), + LocaleQualifiers("es", "", "ES") + ) + ).path + ) + + val resourceWithEnAndEs = DrawableResource( + id = "ImageResource:en_es_test", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("en")), "en", -1, -1), + ResourceItem(setOf(LanguageQualifier("es")), "es", -1, -1), + ) + ) + assertEquals( + "en", + resourceWithEnAndEs.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("en", "", "IN"), + LocaleQualifiers("es", "", "US") + ) + ).path + ) + } + + @Test + fun testUnqualifiedValuesIsLastResortNotEnglish() { + val resourceDefaultAndEs = DrawableResource( + id = "ImageResource:default_es_test", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("es")), "es", -1, -1), + ) + ) + assertEquals( + "es", + resourceDefaultAndEs.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("en", "", "IN"), + LocaleQualifiers("es", "", "US") + ) + ).path + ) + } + + @Test + fun testScriptMismatchAdvancesToNextPreferredLocale() { + val resourceWithScript = DrawableResource( + id = "ImageResource:script_fallback_test", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("zh"), ScriptQualifier("Hant")), "zh-Hant", -1, -1), + ResourceItem(setOf(LanguageQualifier("en")), "en", -1, -1) + ) + ) + assertEquals( + "en", + resourceWithScript.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("zh", "Hans", "CN"), + LocaleQualifiers("en", "", "US") + ) + ).path + ) + } + + @Test + fun testChineseNeverCrossesScripts() { + val resourceZhRegionOnly = DrawableResource( + id = "ImageResource:zh_region_test", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("zh"), RegionQualifier("CN")), "zh-rCN", -1, -1), + ResourceItem(setOf(LanguageQualifier("zh"), RegionQualifier("TW")), "zh-rTW", -1, -1), + ResourceItem(setOf(LanguageQualifier("en")), "en", -1, -1), + ) + ) + // A requested script blocks the other-region fallback, so Hant/Hans is never guessed + // from a region-only folder; advance to English instead. + assertEquals( + "en", + resourceZhRegionOnly.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("zh", "Hant", "US"), + LocaleQualifiers("en", "", "US") + ) + ).path + ) + assertEquals( + "en", + resourceZhRegionOnly.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("zh", "Hans", ""), + LocaleQualifiers("en", "", "US") + ) + ).path + ) + assertEquals( + "zh-rTW", + resourceZhRegionOnly.getResourceItemByEnvironment( + multiLocaleEnv(LocaleQualifiers("zh", "Hant", "TW")) + ).path + ) + + val singleTaiwanResource = DrawableResource( + id = "ImageResource:single_tw_test", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("zh"), RegionQualifier("TW")), "zh-rTW", -1, -1), + ResourceItem(setOf(LanguageQualifier("en")), "en", -1, -1), + ) + ) + // Explicit Hans must not fall back to TW even when TW is the only Chinese region folder + assertEquals( + "en", + singleTaiwanResource.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("zh", "Hans", "CN"), + LocaleQualifiers("en", "", "US") + ) + ).path + ) + } + + @Test + fun testRegionFallbackStaysWithinOneScript() { + // https://github.com/JetBrains/compose-multiplatform/issues/4571 + val traditionalOnly = DrawableResource( + id = "ImageResource:zh_traditional_regions", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("zh"), RegionQualifier("HK")), "zh-rHK", -1, -1), + ResourceItem(setOf(LanguageQualifier("zh"), RegionQualifier("TW")), "zh-rTW", -1, -1), + ResourceItem(setOf(LanguageQualifier("zh"), RegionQualifier("MO")), "zh-rMO", -1, -1), + ) + ) + // zh-CN implies Hans, so none of the Hant folders may be used + assertEquals( + "default", + traditionalOnly.getResourceItemByEnvironment( + multiLocaleEnv(LocaleQualifiers("zh", "", "CN")) + ).path + ) + // zh-HK implies Hant, so a sibling Hant region is fine + assertEquals( + "zh-rHK", + traditionalOnly.getResourceItemByEnvironment( + multiLocaleEnv(LocaleQualifiers("zh", "", "SG"), LocaleQualifiers("zh", "", "HK")) + ).path + ) + + val simplifiedOnly = DrawableResource( + id = "ImageResource:zh_simplified_region", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("zh"), RegionQualifier("CN")), "zh-rCN", -1, -1), + ) + ) + // zh-SG implies Hans like zh-CN, so the fallback applies + assertEquals( + "zh-rCN", + simplifiedOnly.getResourceItemByEnvironment( + multiLocaleEnv(LocaleQualifiers("zh", "", "SG")) + ).path + ) + assertEquals( + "default", + simplifiedOnly.getResourceItemByEnvironment( + multiLocaleEnv(LocaleQualifiers("zh", "", "TW")) + ).path + ) + } + + @Test + fun testChineseScriptQualifiedFoldersIsolation() { + val scriptOnlyChinese = DrawableResource( + id = "ImageResource:zh_script_qualified", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("zh"), ScriptQualifier("Hans")), "zh-Hans", -1, -1), + ResourceItem(setOf(LanguageQualifier("zh"), ScriptQualifier("Hant")), "zh-Hant", -1, -1), + ResourceItem(setOf(LanguageQualifier("en")), "en", -1, -1), + ) + ) + + // zh-CN (no script in tag) implies Hans, so it must match zh-Hans + assertEquals( + "zh-Hans", + scriptOnlyChinese.getResourceItemByEnvironment( + multiLocaleEnv(LocaleQualifiers("zh", "", "CN")) + ).path + ) + + // zh-TW (no script in tag) implies Hant, so it must match zh-Hant + assertEquals( + "zh-Hant", + scriptOnlyChinese.getResourceItemByEnvironment( + multiLocaleEnv(LocaleQualifiers("zh", "", "TW")) + ).path + ) + + val traditionalScriptOnly = DrawableResource( + id = "ImageResource:zh_traditional_script_only", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("zh"), ScriptQualifier("Hant")), "zh-Hant", -1, -1), + ResourceItem(setOf(LanguageQualifier("en")), "en", -1, -1), + ) + ) + + // zh-CN implies Hans, so it must NOT substitute zh-Hant; it should advance to English + assertEquals( + "en", + traditionalScriptOnly.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("zh", "", "CN"), + LocaleQualifiers("en", "", "US") + ) + ).path + ) + + // zh-TW implies Hant, so it matches zh-Hant + assertEquals( + "zh-Hant", + traditionalScriptOnly.getResourceItemByEnvironment( + multiLocaleEnv(LocaleQualifiers("zh", "", "TW")) + ).path + ) + } + + @Test + fun testOtherRegionOfTheSameLanguageWins() { + val resourceRegionsOnly = DrawableResource( + id = "ImageResource:regions_only", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("fr"), RegionQualifier("FR")), "fr-FR", -1, -1), + ResourceItem(setOf(LanguageQualifier("fr"), RegionQualifier("BE")), "fr-BE", -1, -1), + ResourceItem(setOf(LanguageQualifier("es")), "es", -1, -1), + ) + ) + assertEquals( + "fr-BE", + resourceRegionsOnly.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("fr", "", "BE"), + LocaleQualifiers("es", "", "US") + ) + ).path + ) + // Several regions of the same language: the lowest region code wins, and French still + // beats Spanish because French is the more preferred language. + assertEquals( + "fr-BE", + resourceRegionsOnly.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("fr", "", "CA"), + LocaleQualifiers("es", "", "US") + ) + ).path + ) + assertEquals( + "fr-BE", + resourceRegionsOnly.getResourceItemByEnvironment( + multiLocaleEnv(LocaleQualifiers("fr", "", "CA")) + ).path + ) + + // A region-less folder is preferred over any of them, so the tie-break is only + // reached by a resource set that has none + val resourceWithBaseFr = DrawableResource( + id = "ImageResource:regions_and_base", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("fr")), "fr", -1, -1), + ResourceItem(setOf(LanguageQualifier("fr"), RegionQualifier("FR")), "fr-FR", -1, -1), + ResourceItem(setOf(LanguageQualifier("fr"), RegionQualifier("BE")), "fr-BE", -1, -1), + ) + ) + assertEquals( + "fr", + resourceWithBaseFr.getResourceItemByEnvironment( + multiLocaleEnv(LocaleQualifiers("fr", "", "CA")) + ).path + ) + + // Several densities under one fallback region must not break locale matching + val multiDensity = DrawableResource( + id = "ImageResource:multidensity", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("fr"), RegionQualifier("FR"), MDPI), "fr-FR-mdpi", -1, -1), + ResourceItem(setOf(LanguageQualifier("fr"), RegionQualifier("FR"), HDPI), "fr-FR-hdpi", -1, -1), + ResourceItem(setOf(LanguageQualifier("fr"), RegionQualifier("FR"), XHDPI), "fr-FR-xhdpi", -1, -1), + ) + ) + assertEquals( + "fr-FR-hdpi", + multiDensity.getResourceItemByEnvironment( + ResourceEnvironment( + locales = listOf(LocaleQualifiers("fr", "", "CA")), + theme = LIGHT, + density = HDPI + ) + ).path + ) + } + + @Test + fun testSelectResourceLocalesAppendsOsListBehindCompose() { + val compose = listOf(LocaleQualifiers("en", "", "US")) + val system = listOf( + LocaleQualifiers("en", "", "US"), + LocaleQualifiers("zh", "Hans", "CN"), + ) + // The platform default is already at the head of the OS list: nothing to move + assertEquals(system, selectResourceLocales(compose, system)) + } + + @Test + fun testSelectResourceLocalesKeepsOverrideThatOsListAlsoContains() { + // An app that calls Locale.setDefault(GERMAN) must get German, even though the OS + // lists German behind French + val compose = listOf(LocaleQualifiers("de", "", "DE")) + val system = listOf( + LocaleQualifiers("fr", "", "FR"), + LocaleQualifiers("en", "", "US"), + LocaleQualifiers("de", "", "DE"), + ) + assertEquals( + listOf( + LocaleQualifiers("de", "", "DE"), + LocaleQualifiers("fr", "", "FR"), + LocaleQualifiers("en", "", "US"), + ), + selectResourceLocales(compose, system) + ) + } + + @Test + fun testSelectResourceLocalesKeepsComposeWhenOsListHasOne() { + val compose = listOf(LocaleQualifiers("de", "", "DE")) + val system = listOf(LocaleQualifiers("fr", "", "FR")) + assertEquals(compose, selectResourceLocales(compose, system)) + } + + @Test + fun testSelectResourceLocalesKeepsComposeWhenItAlreadyHasFallbacks() { + val compose = listOf( + LocaleQualifiers("de", "", "DE"), + LocaleQualifiers("en", "", "US"), + ) + val system = listOf( + LocaleQualifiers("fr", "", "FR"), + LocaleQualifiers("es", "", "ES"), + ) + assertEquals(compose, selectResourceLocales(compose, system)) + } + + @Test + fun testSelectResourceLocalesPrependsComposeOverride() { + val compose = listOf(LocaleQualifiers("de", "", "DE")) + val system = listOf( + LocaleQualifiers("fr", "", "FR"), + LocaleQualifiers("es", "", "ES"), + ) + assertEquals(listOf(compose.single()) + system, selectResourceLocales(compose, system)) + } + + @Test + fun testSelectResourceLocalesPrependsComposeRegionOverride() { + val compose = listOf(LocaleQualifiers("es", "", "MX")) + val system = listOf( + LocaleQualifiers("es", "", "ES"), + LocaleQualifiers("en", "", "US"), + ) + assertEquals(listOf(compose.single()) + system, selectResourceLocales(compose, system)) + } + + @Test + fun testSelectResourceLocalesUsesSystemWhenComposeIsEmpty() { + val system = listOf( + LocaleQualifiers("hi", "", "IN"), + LocaleQualifiers("en", "", "US"), + ) + assertEquals(system, selectResourceLocales(emptyList(), system)) + } + + private fun multiLocaleEnv(vararg locales: LocaleQualifiers) = ResourceEnvironment( + locales = locales.toList(), + theme = LIGHT, + density = MDPI + ) } From f630e57578246805bbfe1a5e928f984b672d19ef Mon Sep 17 00:00:00 2001 From: Abhishek Keshri Date: Mon, 12 Oct 2026 16:18:17 +0530 Subject: [PATCH 2/6] Report the whole preferred locale list on every platform Every platform handed the matcher a list of one, so a user whose first language has no resources could not reach their second. Android reads LocaleList.getDefault(), which follows a per-app language where Resources.getSystem() does not. Apple reads NSLocale.preferredLanguages, and the web reads navigator.languages. A single locale from LocaleList.current stays in front of the list, since an app that calls Locale.setDefault to choose its language cannot be told apart from a platform default here. The new internal systemPreferredLocales() keeps composition from also resolving a theme and a screen DPI that it takes from Compose anyway. Fixes https://youtrack.jetbrains.com/issue/CMP-6840 --- .../resources/ResourceEnvironment.android.kt | 28 ++++++-- .../resources/AppleLocaleQualifiers.kt | 28 ++++++++ .../compose/resources/ResourceEnvironment.kt | 10 +-- .../resources/ResourceEnvironment.desktop.kt | 17 +++-- .../DefaultComposeEnvironmentTest.kt | 71 +++++++++++++++++++ .../resources/ResourceEnvironment.ios.kt | 13 +--- .../resources/ResourceEnvironment.js.kt | 22 ++++-- .../resources/ResourceEnvironment.macos.kt | 15 ++-- .../resources/ResourceEnvironment.wasmJs.kt | 22 ++++-- .../resources/ResourceEnvironmentTest.wasm.kt | 49 +++++++++---- 10 files changed, 218 insertions(+), 57 deletions(-) create mode 100644 components/resources/library/src/appleMain/kotlin/org/jetbrains/compose/resources/AppleLocaleQualifiers.kt create mode 100644 components/resources/library/src/desktopTest/kotlin/org/jetbrains/compose/resources/DefaultComposeEnvironmentTest.kt diff --git a/components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.android.kt b/components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.android.kt index 0b56dc4088b..ce463ca463f 100644 --- a/components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.android.kt +++ b/components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.android.kt @@ -2,18 +2,36 @@ package org.jetbrains.compose.resources import android.content.res.Configuration import android.content.res.Resources +import android.os.Build +import android.os.LocaleList import java.util.* +// LocaleList.getDefault() follows a per-app language, which Resources.getSystem() does not: +// that one stays on the device list. Compose reads the same list for LocaleList.current, so +// stringResource and getString cannot disagree. +internal actual fun systemPreferredLocales(): List { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val localeList = LocaleList.getDefault() + if (!localeList.isEmpty) { + return (0 until localeList.size()).map { i -> localeQualifiersOf(localeList.get(i)) } + } + } + return listOf(localeQualifiersOf(Locale.getDefault())) +} + internal actual fun getSystemEnvironment(): ResourceEnvironment { - val locale = Locale.getDefault() val configuration = Resources.getSystem().configuration val isDarkTheme = configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES val dpi = configuration.densityDpi return ResourceEnvironment( - language = LanguageQualifier(locale.language), - script = ScriptQualifier(locale.script), - region = RegionQualifier(locale.country), + locales = systemPreferredLocales(), theme = ThemeQualifier.selectByValue(isDarkTheme), density = DensityQualifier.selectByValue(dpi) ) -} \ No newline at end of file +} + +private fun localeQualifiersOf(locale: Locale) = LocaleQualifiers( + language = LanguageQualifier(locale.language.orEmpty()), + script = ScriptQualifier(locale.script.orEmpty()), + region = RegionQualifier(locale.country.orEmpty()) +) diff --git a/components/resources/library/src/appleMain/kotlin/org/jetbrains/compose/resources/AppleLocaleQualifiers.kt b/components/resources/library/src/appleMain/kotlin/org/jetbrains/compose/resources/AppleLocaleQualifiers.kt new file mode 100644 index 00000000000..01b5336b7b8 --- /dev/null +++ b/components/resources/library/src/appleMain/kotlin/org/jetbrains/compose/resources/AppleLocaleQualifiers.kt @@ -0,0 +1,28 @@ +package org.jetbrains.compose.resources + +import platform.Foundation.NSLocale +import platform.Foundation.NSLocaleCountryCode +import platform.Foundation.NSLocaleLanguageCode +import platform.Foundation.NSLocaleScriptCode +import platform.Foundation.currentLocale +import platform.Foundation.preferredLanguages + +// preferredLanguages is the ordered preferred-language list. Unlike +// Bundle.main.preferredLocalizations it is not filtered against Xcode .lproj bundles, +// and it keeps the region, so values-en-rIN still matches a user on en-IN. +internal actual fun systemPreferredLocales(): List { + val tags = NSLocale.preferredLanguages.mapNotNull { it as? String } + if (tags.isEmpty()) return listOf(localeQualifiersOf(NSLocale.currentLocale)) + return tags.map { localeQualifiersOf(NSLocale(it)) } +} + +private fun localeQualifiersOf(loc: NSLocale): LocaleQualifiers { + val languageCode = (loc.objectForKey(NSLocaleLanguageCode) as? String).orEmpty() + val scriptCode = (loc.objectForKey(NSLocaleScriptCode) as? String).orEmpty() + val regionCode = (loc.objectForKey(NSLocaleCountryCode) as? String).orEmpty() + return LocaleQualifiers( + language = LanguageQualifier(languageCode), + script = ScriptQualifier(scriptCode), + region = RegionQualifier(regionCode) + ) +} diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.kt index c1a542d385d..07e0fffcd5c 100644 --- a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.kt +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.kt @@ -109,12 +109,12 @@ fun rememberResourceEnvironment(): ResourceEnvironment { * Chooses the locale list used for resource matching. * * [androidx.compose.ui.text.intl.LocaleList] is used as-is when it already contains a - * fallback chain, which is the case on Android API 24+ and on the web. + * fallback chain, which is the case on Android API 24+. * - * On JVM desktop it holds a single platform-default locale even when the OS has a preference - * list, so the OS list supplies the missing fallback chain. The Compose locale stays in front - * of it: an app that calls `Locale.setDefault` to pick its language is indistinguishable here - * from a platform default, and dropping it would ignore the app. + * Where it holds a single platform-default locale, as on JVM desktop, the OS list supplies the + * missing fallback chain. The Compose locale stays in front of it: an app that calls + * `Locale.setDefault` to pick its language is indistinguishable here from a platform default, + * and dropping it would ignore the app. */ internal fun selectResourceLocales( composeLocales: List, diff --git a/components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.desktop.kt b/components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.desktop.kt index 8483b42d7ca..636dd92d668 100644 --- a/components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.desktop.kt +++ b/components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.desktop.kt @@ -6,8 +6,11 @@ import java.awt.GraphicsEnvironment import java.awt.Toolkit import java.util.* +// Locale.getDefault() holds a single locale and cannot express a fallback chain. +internal actual fun systemPreferredLocales(): List = + listOf(localeQualifiersOf(Locale.getDefault())) + internal actual fun getSystemEnvironment(): ResourceEnvironment { - val locale = Locale.getDefault() //FIXME: don't use skiko internals val isDarkTheme = currentSystemTheme == SystemTheme.DARK val dpi = if (GraphicsEnvironment.isHeadless()) { @@ -17,10 +20,14 @@ internal actual fun getSystemEnvironment(): ResourceEnvironment { Toolkit.getDefaultToolkit().screenResolution } return ResourceEnvironment( - language = LanguageQualifier(locale.language), - script = ScriptQualifier(locale.script), - region = RegionQualifier(locale.country), + locales = systemPreferredLocales(), theme = ThemeQualifier.selectByValue(isDarkTheme), density = DensityQualifier.selectByValue(dpi) ) -} \ No newline at end of file +} + +private fun localeQualifiersOf(locale: Locale) = LocaleQualifiers( + language = LanguageQualifier(locale.language), + script = ScriptQualifier(locale.script.orEmpty()), + region = RegionQualifier(locale.country.orEmpty()) +) diff --git a/components/resources/library/src/desktopTest/kotlin/org/jetbrains/compose/resources/DefaultComposeEnvironmentTest.kt b/components/resources/library/src/desktopTest/kotlin/org/jetbrains/compose/resources/DefaultComposeEnvironmentTest.kt new file mode 100644 index 00000000000..152acfd66a8 --- /dev/null +++ b/components/resources/library/src/desktopTest/kotlin/org/jetbrains/compose/resources/DefaultComposeEnvironmentTest.kt @@ -0,0 +1,71 @@ +package org.jetbrains.compose.resources + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import java.util.Locale +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +private var osLocales: List = emptyList() + +private fun osPreferredLocales(): List = osLocales + +@OptIn(ExperimentalTestApi::class) +class DefaultComposeEnvironmentTest { + private val originalLocale: Locale = Locale.getDefault() + private val originalPreferredLocales = getPreferredLocales + + @AfterTest + fun restore() { + Locale.setDefault(originalLocale) + getPreferredLocales = originalPreferredLocales + } + + private fun composedLocalesFor( + jvmDefault: Locale, + vararg osList: LocaleQualifiers + ): List { + Locale.setDefault(jvmDefault) + osLocales = osList.toList() + getPreferredLocales = ::osPreferredLocales + var locales: List? = null + runComposeUiTest { + setContent { locales = rememberResourceEnvironment().locales } + waitForIdle() + } + return checkNotNull(locales) + } + + @Test + fun theJvmDefaultLocaleStaysInFrontOfTheOsList() { + assertEquals( + listOf( + LocaleQualifiers("de", "", "DE"), + LocaleQualifiers("fr", "", "FR"), + LocaleQualifiers("en", "", "US"), + ), + composedLocalesFor( + Locale.GERMANY, + LocaleQualifiers("fr", "", "FR"), + LocaleQualifiers("en", "", "US"), + LocaleQualifiers("de", "", "DE"), + ) + ) + } + + @Test + fun theOsListSuppliesTheFallbackChain() { + assertEquals( + listOf( + LocaleQualifiers("fr", "", "FR"), + LocaleQualifiers("es", "", "ES"), + ), + composedLocalesFor( + Locale.FRANCE, + LocaleQualifiers("fr", "", "FR"), + LocaleQualifiers("es", "", "ES"), + ) + ) + } +} diff --git a/components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.ios.kt b/components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.ios.kt index 1890fc5e2ef..4c0f51261d9 100644 --- a/components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.ios.kt +++ b/components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.ios.kt @@ -1,26 +1,17 @@ package org.jetbrains.compose.resources -import platform.Foundation.* import platform.UIKit.UIScreen import platform.UIKit.UIUserInterfaceStyle internal actual fun getSystemEnvironment(): ResourceEnvironment { - val locale = NSLocale.preferredLanguages.firstOrNull() - ?.let { NSLocale(it as String) } - ?: NSLocale.currentLocale - - val languageCode = locale.languageCode - val scriptCode = locale.scriptCode - val regionCode = locale.objectForKey(NSLocaleCountryCode) as? String + val locales = systemPreferredLocales() val mainScreen = UIScreen.mainScreen val isDarkTheme = mainScreen.traitCollection().userInterfaceStyle == UIUserInterfaceStyle.UIUserInterfaceStyleDark //there is no an API to get a physical screen size and calculate a real DPI val density = mainScreen.scale.toFloat() return ResourceEnvironment( - language = LanguageQualifier(languageCode), - script = ScriptQualifier(scriptCode.orEmpty()), - region = RegionQualifier(regionCode.orEmpty()), + locales = locales, theme = ThemeQualifier.selectByValue(isDarkTheme), density = DensityQualifier.selectByDensity(density) ) diff --git a/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.js.kt b/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.js.kt index e5678e891ef..986349d9c7a 100644 --- a/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.js.kt +++ b/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.js.kt @@ -17,15 +17,29 @@ private external class Intl { } } +// navigator.languages is the preferred-language list; navigator.language is only its first entry. +internal actual fun systemPreferredLocales(): List { + val languages = window.navigator.languages + if (languages.isEmpty()) return listOf(localeQualifiersOf(window.navigator.language)) + return languages.map { localeQualifiersOf(it) } +} + +private fun localeQualifiersOf(languageTag: String): LocaleQualifiers { + val locale = Intl.Locale(languageTag) + return LocaleQualifiers( + language = LanguageQualifier(locale.language), + script = ScriptQualifier(locale.script ?: ""), + region = RegionQualifier(locale.region ?: "") + ) +} + internal actual fun getSystemEnvironment(): ResourceEnvironment { - val locale = Intl.Locale(window.navigator.language) + val locales = systemPreferredLocales() val isDarkTheme = window.matchMedia("(prefers-color-scheme: dark)").matches //96 - standard browser DPI https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio val dpi: Int = (window.devicePixelRatio * 96).toInt() return ResourceEnvironment( - language = LanguageQualifier(locale.language), - script = ScriptQualifier(locale.script ?: ""), - region = RegionQualifier(locale.region ?: ""), + locales = locales, theme = ThemeQualifier.selectByValue(isDarkTheme), density = DensityQualifier.selectByValue(dpi) ) diff --git a/components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.macos.kt b/components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.macos.kt index 1f09ed45afc..8b4332d51d0 100644 --- a/components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.macos.kt +++ b/components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.macos.kt @@ -4,16 +4,11 @@ import kotlinx.cinterop.* import platform.AppKit.NSScreen import platform.CoreGraphics.CGDisplayPixelsWide import platform.CoreGraphics.CGDisplayScreenSize -import platform.Foundation.* +import platform.Foundation.NSNumber +import platform.Foundation.NSUserDefaults internal actual fun getSystemEnvironment(): ResourceEnvironment { - val locale = NSLocale.preferredLanguages.firstOrNull() - ?.let { NSLocale(it as String) } - ?: NSLocale.currentLocale - - val languageCode = locale.languageCode - val scriptCode = locale.scriptCode - val regionCode = locale.objectForKey(NSLocaleCountryCode) as? String + val locales = systemPreferredLocales() val isDarkTheme = NSUserDefaults.standardUserDefaults.stringForKey("AppleInterfaceStyle") == "Dark" val dpi = NSScreen.mainScreen?.let { screen -> @@ -30,9 +25,7 @@ internal actual fun getSystemEnvironment(): ResourceEnvironment { } ?: 0 return ResourceEnvironment( - language = LanguageQualifier(languageCode), - script = ScriptQualifier(scriptCode.orEmpty()), - region = RegionQualifier(regionCode.orEmpty()), + locales = locales, theme = ThemeQualifier.selectByValue(isDarkTheme), density = DensityQualifier.selectByValue(dpi) ) diff --git a/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.wasmJs.kt b/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.wasmJs.kt index c737bf5a1f4..f3a8d510fdb 100644 --- a/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.wasmJs.kt +++ b/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.wasmJs.kt @@ -17,15 +17,29 @@ private external class Intl { } } +// navigator.languages is the preferred-language list; navigator.language is only its first entry. +internal actual fun systemPreferredLocales(): List { + val languages = window.navigator.languages + if (languages.length == 0) return listOf(localeQualifiersOf(window.navigator.language)) + return (0 until languages.length).map { i -> localeQualifiersOf(languages[i].toString()) } +} + +private fun localeQualifiersOf(languageTag: String): LocaleQualifiers { + val locale = Intl.Locale(languageTag) + return LocaleQualifiers( + language = LanguageQualifier(locale.language), + script = ScriptQualifier(locale.script ?: ""), + region = RegionQualifier(locale.region ?: "") + ) +} + internal actual fun getSystemEnvironment(): ResourceEnvironment { - val locale = Intl.Locale(window.navigator.language) + val locales = systemPreferredLocales() val isDarkTheme = window.matchMedia("(prefers-color-scheme: dark)").matches //96 - standard browser DPI https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio val dpi: Int = (window.devicePixelRatio * 96).toInt() return ResourceEnvironment( - language = LanguageQualifier(locale.language), - script = ScriptQualifier(locale.script ?: ""), - region = RegionQualifier(locale.region ?: ""), + locales = locales, theme = ThemeQualifier.selectByValue(isDarkTheme), density = DensityQualifier.selectByValue(dpi) ) diff --git a/components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/ResourceEnvironmentTest.wasm.kt b/components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/ResourceEnvironmentTest.wasm.kt index 7b9579b5754..2d1ba95dc96 100644 --- a/components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/ResourceEnvironmentTest.wasm.kt +++ b/components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/ResourceEnvironmentTest.wasm.kt @@ -1,6 +1,5 @@ package org.jetbrains.compose.resources -import kotlinx.browser.window import kotlin.test.Test import kotlin.test.assertEquals @@ -9,42 +8,68 @@ class ResourceEnvironmentTest { // covers https://youtrack.jetbrains.com/issue/CMP-6930 (also see the comments) @Test fun usingLocaleWithoutRegion() { - val originalLanguage = window.navigator.language - configureLanguage("en") + val originalLanguages = currentLanguages() + configureLanguages("en") try { val env = getSystemEnvironment() assertEquals("", env.region.region) assertEquals("en", env.language.language) } finally { - configureLanguage(originalLanguage) + configureLanguages(originalLanguages) } } @Test fun usingLocaleWithRegion() { - val originalLanguage = window.navigator.language - configureLanguage("en-NL") + val originalLanguages = currentLanguages() + configureLanguages("en-NL") try { val env = getSystemEnvironment() assertEquals("NL", env.region.region) assertEquals("en", env.language.language) } finally { - configureLanguage(originalLanguage) + configureLanguages(originalLanguages) } + } + + @Test + fun usingPrioritizedLanguages() { + val originalLanguages = currentLanguages() + configureLanguages("fr-CA,es-ES,en") + try { + val env = getSystemEnvironment() + assertEquals(listOf("fr", "es", "en"), env.locales.map { it.language.language }) + assertEquals(listOf("CA", "ES", ""), env.locales.map { it.region.region }) + } finally { + configureLanguages(originalLanguages) + } } } //language=js -private fun configureLanguage(language: String) { - js(""" +private fun currentLanguages(): String = js("window.navigator.languages.join(',')") + +// navigator.languages is the preferred-language list; navigator.language is its first entry. +//language=js +private fun configureLanguages(languages: String) { + js( + """ + var list = languages.length === 0 ? [] : languages.split(','); + Object.defineProperty(window.navigator, 'languages', { + get: function () { + return list; + }, + configurable: true + }); Object.defineProperty(window.navigator, 'language', { get: function () { - return language; + return list.length === 0 ? undefined : list[0]; }, configurable: true }); - """) -} \ No newline at end of file + """ + ) +} From f4e391abbf405238b128502d4c95c1d4617626b2 Mon Sep 17 00:00:00 2001 From: Abhishek Keshri Date: Mon, 12 Oct 2026 16:33:10 +0530 Subject: [PATCH 3/6] Pluralize in the language of the resource that matched Plural categories came from the first preferred locale, which is no longer the language the string is written in. A user who prefers French and then English, in an app shipping only English plurals, got the French rule for zero. Take the language and the region from the matched resource item instead. Fixes https://youtrack.jetbrains.com/issue/CMP-6840 --- .../resources/PluralStringResources.kt | 9 ++++- .../compose/resources/ComposeResourceTest.kt | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/PluralStringResources.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/PluralStringResources.kt index 487da2a3bd2..5beb39bbc55 100644 --- a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/PluralStringResources.kt +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/PluralStringResources.kt @@ -69,9 +69,14 @@ private suspend fun loadPluralString( ): String { val resourceItem = resource.getResourceItemByEnvironment(environment) val item = getStringItem(resourceItem, resourceReader) as StringItem.Plurals + val itemLanguage = resourceItem.qualifiers.filterIsInstance().firstOrNull() ?: environment.language + val matchedLocale = environment.locales.firstOrNull { it.language == itemLanguage } + val itemRegion = resourceItem.qualifiers.filterIsInstance().firstOrNull() + ?: matchedLocale?.region + ?: environment.region val pluralRuleList = PluralRuleList.getInstance( - environment.language, - environment.region, + itemLanguage, + itemRegion, ) val pluralCategory = pluralRuleList.getCategory(quantity) val str = item.items[pluralCategory] diff --git a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt index 1a694a53200..d974c64b3ba 100644 --- a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt +++ b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt @@ -323,6 +323,44 @@ class ComposeResourceTest : KmpUiTest() { assertEquals("another other", getPluralString(TestPluralStringResource("another_plurals"), 5)) } + @Test + fun testPluralRulesFollowMatchedResourceLanguage() = runTest { + // In French, 0 is ONE ("i = 0,1"). In English, 0 is OTHER ("i = 1 and v = 0"). + // When user prefers [fr, en] but only English strings exist, English plural rules must be used. + val resource = PluralStringResource( + id = "PLURALS:plural_fallback_rules", + key = "plurals", + items = setOf( + ResourceItem(setOf(LanguageQualifier("en")), "strings.cvr", 171, 39) + ) + ) + val env = ResourceEnvironment( + locales = listOf(LocaleQualifiers("fr"), LocaleQualifiers("en")), + theme = ThemeQualifier.LIGHT, + density = DensityQualifier.MDPI + ) + assertEquals("other", getPluralString(env, resource, 0)) + } + + @Test + fun testPluralRulesOfAnUnqualifiedResourceFollowTheFirstPreferredLocale() = runTest { + // values/ carries no language qualifier, so there is nothing to read the rules from + // and the first preferred locale decides, as it did before this environment held a list + val resource = PluralStringResource( + id = "PLURALS:plural_default_rules", + key = "plurals", + items = setOf( + ResourceItem(setOf(), "strings.cvr", 171, 39) + ) + ) + val env = ResourceEnvironment( + locales = listOf(LocaleQualifiers("fr"), LocaleQualifiers("en")), + theme = ThemeQualifier.LIGHT, + density = DensityQualifier.MDPI + ) + assertEquals("one", getPluralString(env, resource, 0)) + } + @Test fun testMissingResource() = runTest { assertFailsWith { From 378d273f8dffcc3cfe6a7096479e479a71b02dbf Mon Sep 17 00:00:00 2001 From: Abhishek Keshri Date: Mon, 12 Oct 2026 16:36:34 +0530 Subject: [PATCH 4/6] Read the preferred locale list on desktop JVM Desktop was the last target still reporting a single locale. Neither the JDK nor a dependency of this module exposes the list, so the Windows one is read from the registry, the macOS one through defaults, and other systems fall back to the gettext environment variables. Both subprocesses resolve once per process behind a timeout, and any failure leaves the single platform locale in place. A POSIX locale has no script field and carries it in the modifier, so the script-bearing modifiers from glibc localedata/SUPPORTED are mapped and the rest, which name a currency or a dialect, are dropped. Fixes https://youtrack.jetbrains.com/issue/CMP-6840 --- .../resources/ResourceEnvironment.desktop.kt | 155 +++++++++++++++++- .../resources/JvmPreferredLocalesTest.kt | 123 ++++++++++++++ 2 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 components/resources/library/src/desktopTest/kotlin/org/jetbrains/compose/resources/JvmPreferredLocalesTest.kt diff --git a/components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.desktop.kt b/components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.desktop.kt index 636dd92d668..cc913e2afd5 100644 --- a/components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.desktop.kt +++ b/components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.desktop.kt @@ -4,11 +4,10 @@ import org.jetbrains.skiko.SystemTheme import org.jetbrains.skiko.currentSystemTheme import java.awt.GraphicsEnvironment import java.awt.Toolkit -import java.util.* +import java.util.Locale +import java.util.concurrent.TimeUnit -// Locale.getDefault() holds a single locale and cannot express a fallback chain. -internal actual fun systemPreferredLocales(): List = - listOf(localeQualifiersOf(Locale.getDefault())) +internal actual fun systemPreferredLocales(): List = jvmPreferredLocales() internal actual fun getSystemEnvironment(): ResourceEnvironment { //FIXME: don't use skiko internals @@ -26,8 +25,152 @@ internal actual fun getSystemEnvironment(): ResourceEnvironment { ) } -private fun localeQualifiersOf(locale: Locale) = LocaleQualifiers( +/** + * The ordered list of locales the user prefers, most preferred first. + * + * [Locale.getDefault] holds a single locale, so it cannot express a fallback chain. + * Windows and Unix keep the full list outside of any JDK API, so they are read directly. + * + * The result is resolved once per process: reading it is not free, and neither the JDK nor + * the OS offers a change notification to invalidate it. Changing the list takes effect on + * the next launch. + */ +internal fun jvmPreferredLocales(): List = preferredLocales + +private val preferredLocales: List by lazy { loadPreferredLocales() } + +private fun loadPreferredLocales(): List { + val fromWindows = windowsPreferredLocales() + if (fromWindows.isNotEmpty()) return fromWindows + + val fromMac = macosPreferredLocales() + if (fromMac.isNotEmpty()) return fromMac + + val fromEnv = unixLanguageList() + if (fromEnv.isNotEmpty()) return fromEnv + + return listOf(localeQualifiersOf(Locale.getDefault())) +} + +private const val MACOS_QUERY_TIMEOUT_SECONDS = 2L + +private fun macosPreferredLocales(): List { + if (!System.getProperty("os.name").orEmpty().contains("mac", ignoreCase = true)) { + return emptyList() + } + return try { + val process = ProcessBuilder("/usr/bin/defaults", "read", "-g", "AppleLanguages") + .redirectErrorStream(true) + .start() + val output = try { + if (!process.waitFor(MACOS_QUERY_TIMEOUT_SECONDS, TimeUnit.SECONDS) || process.exitValue() != 0) { + return emptyList() + } + process.inputStream.bufferedReader().use { it.readText() } + } finally { + process.destroyForcibly() + } + parseMacosLanguagesDefaults(output) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + emptyList() + } catch (_: Exception) { + emptyList() + } +} + +internal fun parseMacosLanguagesDefaults(output: String): List { + return output.lineSequence() + .map { it.trim().trim('"', '\'', ',', '(', ')', ';') } + .filter { it.isNotEmpty() && !it.contains(' ') } + .mapNotNull { parseJvmLocaleTag(it) } + .distinct() + .toList() +} + +private fun unixLanguageList(): List { + val raw = sequenceOf("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG") + .mapNotNull { System.getenv(it)?.takeIf { value -> value.isNotBlank() } } + .firstOrNull() ?: return emptyList() + return raw.split(':', ';') + .mapNotNull { parseJvmLocaleTag(it) } + .distinct() +} + +// The list shown in Settings > Language. GetUserPreferredUILanguages is not equivalent: +// it drops languages that have no installed Windows UI pack. +private const val WINDOWS_USER_PROFILE_KEY = """HKCU\Control Panel\International\User Profile""" +private const val WINDOWS_LANGUAGES_VALUE = "Languages" +private const val WINDOWS_QUERY_TIMEOUT_SECONDS = 2L + +private fun windowsPreferredLocales(): List { + if (!System.getProperty("os.name").orEmpty().contains("win", ignoreCase = true)) { + return emptyList() + } + return try { + // No JDK or public Win32 API returns this list, so it is read from the registry. + val reg = System.getenv("SystemRoot")?.let { "$it\\System32\\reg.exe" } ?: "reg" + val process = ProcessBuilder(reg, "query", WINDOWS_USER_PROFILE_KEY, "/v", WINDOWS_LANGUAGES_VALUE) + .redirectErrorStream(true) + .start() + val output = try { + if (!process.waitFor(WINDOWS_QUERY_TIMEOUT_SECONDS, TimeUnit.SECONDS) || process.exitValue() != 0) { + return emptyList() + } + process.inputStream.bufferedReader().use { it.readText() } + } finally { + process.destroyForcibly() + } + parseWindowsLanguagesRegistry(output) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + emptyList() + } catch (_: Exception) { + emptyList() + } +} + +internal fun parseWindowsLanguagesRegistry(output: String): List { + val line = output.lineSequence() + .firstOrNull { it.contains(WINDOWS_LANGUAGES_VALUE) && it.contains("REG_") } + ?: return emptyList() + val value = line.substringAfter("REG_MULTI_SZ", missingDelimiterValue = "") + .ifEmpty { line.substringAfter("REG_SZ", missingDelimiterValue = "") } + .trim() + if (value.isEmpty()) return emptyList() + // `reg query` prints a REG_MULTI_SZ as the literal text `as\0hi\0es-ES`. + return value.replace("\\0", "\u0000") + .split('\u0000', ' ', '\t') + .mapNotNull { parseJvmLocaleTag(it) } + .distinct() +} + +// A glibc locale is language[_territory][.codeset][@modifier]. Most modifiers name a dialect +// or a currency (ca_ES@valencia, de_DE@euro) and carry no script, but these do, and +// values-b+sr+Latn needs it. Every script-bearing modifier in glibc localedata/SUPPORTED: +private val LOCALE_MODIFIER_SCRIPTS = mapOf( + "cyrillic" to "Cyrl", // uz_UZ@cyrillic + "devanagari" to "Deva", // ks_IN@devanagari, sd_IN@devanagari + "iqtelif" to "Latn", // tt_RU@iqtelif, the IQTElif Latin alphabet for Tatar + "latin" to "Latn", // sr_RS@latin, nan_TW@latin +) + +internal fun parseJvmLocaleTag(raw: String): LocaleQualifiers? { + val trimmed = raw.trim() + val modifier = trimmed.substringAfter('@', missingDelimiterValue = "") // sr_RS.UTF-8@latin + val tag = trimmed.substringBefore('@') + .substringBefore('.') // fr_CA.UTF-8 + .replace('_', '-') + if (tag.isEmpty() || tag.equals("C", ignoreCase = true) || tag.equals("POSIX", ignoreCase = true)) { + return null + } + val locale = Locale.forLanguageTag(tag) + if (locale.language.isEmpty()) return null + return localeQualifiersOf(locale, LOCALE_MODIFIER_SCRIPTS[modifier.lowercase()].orEmpty()) +} + +private fun localeQualifiersOf(locale: Locale, fallbackScript: String = "") = LocaleQualifiers( language = LanguageQualifier(locale.language), - script = ScriptQualifier(locale.script.orEmpty()), + script = ScriptQualifier(locale.script.orEmpty().ifEmpty { fallbackScript }), region = RegionQualifier(locale.country.orEmpty()) ) diff --git a/components/resources/library/src/desktopTest/kotlin/org/jetbrains/compose/resources/JvmPreferredLocalesTest.kt b/components/resources/library/src/desktopTest/kotlin/org/jetbrains/compose/resources/JvmPreferredLocalesTest.kt new file mode 100644 index 00000000000..484ceeceb0f --- /dev/null +++ b/components/resources/library/src/desktopTest/kotlin/org/jetbrains/compose/resources/JvmPreferredLocalesTest.kt @@ -0,0 +1,123 @@ +package org.jetbrains.compose.resources + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class JvmPreferredLocalesTest { + @Test + fun parseUnixLanguageTag() { + val frCa = parseJvmLocaleTag("fr_CA.UTF-8") + assertNotNull(frCa) + assertEquals("fr", frCa.language.language) + assertEquals("CA", frCa.region.region) + } + + @Test + fun parseUnixLanguageTagWithModifier() { + val srLatn = parseJvmLocaleTag("sr_RS@latin") + assertNotNull(srLatn) + assertEquals("sr", srLatn.language.language) + assertEquals("RS", srLatn.region.region) + // The modifier names the script, so values-b+sr+Latn still matches + assertEquals("Latn", srLatn.script.script) + } + + @Test + fun parseUnixLanguageTagWithCodesetAndModifier() { + val srLatn = parseJvmLocaleTag("sr_RS.UTF-8@latin") + assertNotNull(srLatn) + assertEquals("sr", srLatn.language.language) + assertEquals("RS", srLatn.region.region) + assertEquals("Latn", srLatn.script.script) + } + + @Test + fun parseUnixLanguageTagWithEveryScriptModifier() { + // The complete set from glibc localedata/SUPPORTED + assertEquals("Cyrl", parseJvmLocaleTag("uz_UZ@cyrillic")?.script?.script) + assertEquals("Deva", parseJvmLocaleTag("sd_IN@devanagari")?.script?.script) + assertEquals("Latn", parseJvmLocaleTag("tt_RU@iqtelif")?.script?.script) + assertEquals("Latn", parseJvmLocaleTag("nan_TW@latin")?.script?.script) + } + + @Test + fun parseUnixLanguageTagWithNonScriptModifier() { + val caValencia = parseJvmLocaleTag("ca_ES.UTF-8@valencia") + assertNotNull(caValencia) + assertEquals("ca", caValencia.language.language) + assertEquals("ES", caValencia.region.region) + assertEquals("", caValencia.script.script) + } + + @Test + fun parseUnixCLocaleIsIgnored() { + assertEquals(null, parseJvmLocaleTag("C")) + assertEquals(null, parseJvmLocaleTag("POSIX")) + assertEquals(null, parseJvmLocaleTag("")) + } + + @Test + fun parseWindowsRegMultiSz() { + val output = """ + HKEY_CURRENT_USER\Control Panel\International\User Profile + Languages REG_MULTI_SZ zh-Hans-CN en-US + """.trimIndent() + val locales = parseWindowsLanguagesRegistry(output) + assertEquals(listOf("zh", "en"), locales.map { it.language.language }) + assertEquals("Hans", locales.first().script.script) + assertEquals("CN", locales.first().region.region) + } + + @Test + fun parseWindowsRegMultiSzEscapedNulls() { + // The real `reg query` output separates entries with a literal `\0`. + val output = """ + HKEY_CURRENT_USER\Control Panel\International\User Profile + Languages REG_MULTI_SZ as\0hi\0es-ES\0en-US + """.trimIndent() + val locales = parseWindowsLanguagesRegistry(output) + assertEquals(listOf("as", "hi", "es", "en"), locales.map { it.language.language }) + assertEquals("ES", locales[2].region.region) + } + + @Test + fun parseWindowsRegMissingLanguagesValue() { + val output = """ + HKEY_CURRENT_USER\Control Panel\International\User Profile + """.trimIndent() + assertEquals(emptyList(), parseWindowsLanguagesRegistry(output)) + } + + @Test + fun parseWindowsRegEmptyValue() { + val output = " Languages REG_MULTI_SZ " + assertEquals(emptyList(), parseWindowsLanguagesRegistry(output)) + } + + @Test + fun parseMacosDefaultsLanguages() { + val output = """ + ( + "it-US", + "hi-US", + "es-US", + "en-US" + ) + """.trimIndent() + val locales = parseMacosLanguagesDefaults(output) + assertEquals(listOf("it", "hi", "es", "en"), locales.map { it.language.language }) + assertEquals(listOf("US", "US", "US", "US"), locales.map { it.region.region }) + } + + @Test + fun jvmPreferredLocalesIsNeverEmpty() { + val locales = jvmPreferredLocales() + assertTrue(locales.isNotEmpty()) + assertTrue( + locales.all { it.language.language.isNotEmpty() }, + "every preferred locale needs a language: $locales" + ) + } +} From e2243665694547b9dcc0f66cedcde45c91040454 Mon Sep 17 00:00:00 2001 From: Abhishek Keshri Date: Tue, 13 Oct 2026 14:37:12 +0530 Subject: [PATCH 5/6] Satisfy default language with unqualified resources in fallback chain An application shipping base development resources without language qualifiers in values/ (by convention, English) must not skip those resources when a user's prioritized locale list includes English ahead of a secondary language that has explicit overrides (e.g. values-hi/). Allow unqualified resources to provide the unregionalized base candidates for the default language when no explicit values-en/ folder is present. Fixes https://youtrack.jetbrains.com/issue/CMP-6840 --- .../resources/PluralStringResources.kt | 5 +- .../compose/resources/ResourceEnvironment.kt | 19 +++- .../compose/resources/ComposeResourceTest.kt | 45 ++++++++- .../compose/resources/ResourceTest.kt | 95 ++++++++++++++++++- 4 files changed, 153 insertions(+), 11 deletions(-) diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/PluralStringResources.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/PluralStringResources.kt index 5beb39bbc55..0fb1298c555 100644 --- a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/PluralStringResources.kt +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/PluralStringResources.kt @@ -69,11 +69,12 @@ private suspend fun loadPluralString( ): String { val resourceItem = resource.getResourceItemByEnvironment(environment) val item = getStringItem(resourceItem, resourceReader) as StringItem.Plurals - val itemLanguage = resourceItem.qualifiers.filterIsInstance().firstOrNull() ?: environment.language + val itemLanguage = resourceItem.qualifiers.filterIsInstance().firstOrNull() + ?: LanguageQualifier(DEFAULT_LANGUAGE) val matchedLocale = environment.locales.firstOrNull { it.language == itemLanguage } val itemRegion = resourceItem.qualifiers.filterIsInstance().firstOrNull() ?: matchedLocale?.region - ?: environment.region + ?: RegionQualifier("") val pluralRuleList = PluralRuleList.getInstance( itemLanguage, itemRegion, diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.kt index 07e0fffcd5c..7f1cb5de19f 100644 --- a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.kt +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.kt @@ -267,6 +267,8 @@ private fun ResourceItem.scriptQualifier(): ScriptQualifier? = private fun ResourceItem.regionQualifier(): RegionQualifier? = qualifiers.filterIsInstance().firstOrNull() +internal const val DEFAULT_LANGUAGE = "en" + private val TRADITIONAL_CHINESE_REGIONS = setOf("TW", "HK", "MO") // Android computes a script for a locale that does not name one and never matches across @@ -314,16 +316,23 @@ private fun List.filterBySingleLocale( val withLanguage = filter { item -> item.qualifiers.any { it == language } } - if (withLanguage.isEmpty()) return emptyList() + val defaultItems = if (language.language == DEFAULT_LANGUAGE && script.isEmpty() && withLanguage.none { it.regionQualifier() == null }) { + filter { item -> + item.qualifiers.none { it is LanguageQualifier || it is ScriptQualifier || it is RegionQualifier } + } + } else emptyList() + + val candidates = withLanguage + defaultItems + if (candidates.isEmpty()) return emptyList() val wantedScript = script.script.ifEmpty { likelyScript(language.language, region.region).orEmpty() } val scriptCompatible = if (wantedScript.isNotEmpty()) { - withLanguage.filter { + candidates.filter { val itemScript = it.effectiveScript(language.language) itemScript == null || itemScript == wantedScript } } else { - withLanguage + candidates } if (scriptCompatible.isEmpty()) return emptyList() @@ -345,9 +354,9 @@ private fun List.filterBySingleLocale( return withoutScript.narrowByRegion(language, region, allowOtherRegions = true) } - val withDefaultScript = withLanguage.filter { it.scriptQualifier() == null } + val withDefaultScript = candidates.filter { it.scriptQualifier() == null } val defaultScriptMatch = withDefaultScript.narrowByRegion(language, region, allowOtherRegions = true) if (defaultScriptMatch.isNotEmpty()) return defaultScriptMatch - return withLanguage.narrowByRegion(language, region, allowOtherRegions = true) + return candidates.narrowByRegion(language, region, allowOtherRegions = true) } diff --git a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt index d974c64b3ba..29cf6a31eba 100644 --- a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt +++ b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt @@ -343,9 +343,10 @@ class ComposeResourceTest : KmpUiTest() { } @Test - fun testPluralRulesOfAnUnqualifiedResourceFollowTheFirstPreferredLocale() = runTest { - // values/ carries no language qualifier, so there is nothing to read the rules from - // and the first preferred locale decides, as it did before this environment held a list + fun testPluralRulesOfAnUnqualifiedResourceFollowDefaultLanguage() = runTest { + // values/ carries no language qualifier, so it represents the base development language + // (DEFAULT_LANGUAGE = "en"). When user prefers [fr, en], English plural rules are used + // rather than the first preferred locale (French), so 0 evaluates to OTHER. val resource = PluralStringResource( id = "PLURALS:plural_default_rules", key = "plurals", @@ -358,6 +359,44 @@ class ComposeResourceTest : KmpUiTest() { theme = ThemeQualifier.LIGHT, density = DensityQualifier.MDPI ) + assertEquals("other", getPluralString(env, resource, 0)) + } + + @Test + fun testPluralRulesOfAnUnqualifiedResourceWithNonEnglishUserLocale() = runTest { + // Even when the user's preferred locales contain no English, unqualified resources + // from values/ are written in the base development language (English), so English + // plural rules apply and 0 evaluates to OTHER. + val resource = PluralStringResource( + id = "PLURALS:plural_default_rules_fr_only", + key = "plurals", + items = setOf( + ResourceItem(setOf(), "strings.cvr", 171, 39) + ) + ) + val env = ResourceEnvironment( + locales = listOf(LocaleQualifiers("fr")), + theme = ThemeQualifier.LIGHT, + density = DensityQualifier.MDPI + ) + assertEquals("other", getPluralString(env, resource, 0)) + } + + @Test + fun testPluralRulesFollowExplicitMatchedLanguage() = runTest { + // When French resources match, French plural rules must be used: 0 evaluates to ONE. + val resource = PluralStringResource( + id = "PLURALS:plural_french_rules", + key = "plurals", + items = setOf( + ResourceItem(setOf(LanguageQualifier("fr")), "strings.cvr", 171, 39) + ) + ) + val env = ResourceEnvironment( + locales = listOf(LocaleQualifiers("fr"), LocaleQualifiers("en")), + theme = ThemeQualifier.LIGHT, + density = DensityQualifier.MDPI + ) assertEquals("one", getPluralString(env, resource, 0)) } diff --git a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceTest.kt b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceTest.kt index 589b35e33d5..375276b4642 100644 --- a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceTest.kt +++ b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceTest.kt @@ -322,7 +322,7 @@ class ResourceTest { } @Test - fun testUnqualifiedValuesIsLastResortNotEnglish() { + fun testUnqualifiedValuesMatchesEnglishOrDefaultFallback() { val resourceDefaultAndEs = DrawableResource( id = "ImageResource:default_es_test", items = setOf( @@ -330,8 +330,19 @@ class ResourceTest { ResourceItem(setOf(LanguageQualifier("es")), "es", -1, -1), ) ) + // Non-English locale without match advances to next preferred locale assertEquals( "es", + resourceDefaultAndEs.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("fr", "", "CA"), + LocaleQualifiers("es", "", "US") + ) + ).path + ) + // English preferred locale matches default (unqualified) values + assertEquals( + "default", resourceDefaultAndEs.getResourceItemByEnvironment( multiLocaleEnv( LocaleQualifiers("en", "", "IN"), @@ -341,6 +352,88 @@ class ResourceTest { ) } + @Test + fun testDefaultEnglishTakesPrecedenceOverLaterPreferredLocales() { + val resourceWithDefaultHiEs = DrawableResource( + id = "ImageResource:notey_locale_test", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("hi")), "hi", -1, -1), + ResourceItem(setOf(LanguageQualifier("es")), "es", -1, -1), + ) + ) + // User with [en-US, hi-US, es-US] resolves to English (default) instead of Hindi + assertEquals( + "default", + resourceWithDefaultHiEs.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("en", "", "US"), + LocaleQualifiers("hi", "", "US"), + LocaleQualifiers("es", "", "US") + ) + ).path + ) + // But if user's first preference is Hindi [hi-IN, en-US], it resolves to Hindi + assertEquals( + "hi", + resourceWithDefaultHiEs.getResourceItemByEnvironment( + multiLocaleEnv( + LocaleQualifiers("hi", "", "IN"), + LocaleQualifiers("en", "", "US") + ) + ).path + ) + } + + @Test + fun testEnglishWithDifferentRegions() { + val resourceDefaultAndHi = DrawableResource( + id = "ImageResource:en_regions_default_test", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("hi")), "hi", -1, -1), + ) + ) + // Different English regions resolve to default when no regional folders exist + assertEquals("default", resourceDefaultAndHi.getResourceItemByEnvironment( + multiLocaleEnv(LocaleQualifiers("en", "", "GB"), LocaleQualifiers("hi", "", "IN")) + ).path) + assertEquals("default", resourceDefaultAndHi.getResourceItemByEnvironment( + multiLocaleEnv(LocaleQualifiers("en", "", "AU"), LocaleQualifiers("hi", "", "IN")) + ).path) + assertEquals("default", resourceDefaultAndHi.getResourceItemByEnvironment( + multiLocaleEnv(LocaleQualifiers("en", "", "CA"), LocaleQualifiers("hi", "", "IN")) + ).path) + + // When explicit regional English exists, it is preferred for that region + val resourceWithGbAndDefault = DrawableResource( + id = "ImageResource:en_gb_test", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("en"), RegionQualifier("GB")), "en-GB", -1, -1), + ) + ) + assertEquals("en-GB", resourceWithGbAndDefault.getResourceItemByEnvironment( + multiLocaleEnv(LocaleQualifiers("en", "", "GB")) + ).path) + // For en-US, default (generic English) is preferred over other-region en-GB + assertEquals("default", resourceWithGbAndDefault.getResourceItemByEnvironment( + multiLocaleEnv(LocaleQualifiers("en", "", "US")) + ).path) + + // Explicit LanguageQualifier("en") beats unqualified default + val resourceWithExplicitEnAndDefault = DrawableResource( + id = "ImageResource:en_explicit_test", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + ResourceItem(setOf(LanguageQualifier("en")), "en", -1, -1), + ) + ) + assertEquals("en", resourceWithExplicitEnAndDefault.getResourceItemByEnvironment( + multiLocaleEnv(LocaleQualifiers("en", "", "US")) + ).path) + } + @Test fun testScriptMismatchAdvancesToNextPreferredLocale() { val resourceWithScript = DrawableResource( From 4b0e5685add934546b3a5534c29d34456318ca83 Mon Sep 17 00:00:00 2001 From: Abhishek Keshri Date: Tue, 13 Oct 2026 14:37:24 +0530 Subject: [PATCH 6/6] Update public and KLib API dumps Sync golden API and KLib ABI files after introducing LocaleQualifiers and syncing upstream changes. --- components/resources/library/api/desktop/library.api | 4 ++++ components/resources/library/api/library.klib.api | 3 +++ 2 files changed, 7 insertions(+) diff --git a/components/resources/library/api/desktop/library.api b/components/resources/library/api/desktop/library.api index 6d90026c8d4..68105c4ed5f 100644 --- a/components/resources/library/api/desktop/library.api +++ b/components/resources/library/api/desktop/library.api @@ -117,6 +117,10 @@ public final class org/jetbrains/compose/resources/ResourceReaderKt { public static final fun getLocalResourceReader ()Landroidx/compose/runtime/ProvidableCompositionLocal; } +public final class org/jetbrains/compose/resources/ResourceState_blockingKt { + public static final fun awaitPendingResourceStateLoads (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class org/jetbrains/compose/resources/StringArrayResource : org/jetbrains/compose/resources/Resource { public static final field $stable I public final fun getKey ()Ljava/lang/String; diff --git a/components/resources/library/api/library.klib.api b/components/resources/library/api/library.klib.api index ceaa194a539..17b8cbb3788 100644 --- a/components/resources/library/api/library.klib.api +++ b/components/resources/library/api/library.klib.api @@ -80,6 +80,7 @@ final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageC final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Svg$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Svg$stableprop|#static{}org_jetbrains_compose_resources_ImageCache_Svg$stableprop[0] final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Vector$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Vector$stableprop|#static{}org_jetbrains_compose_resources_ImageCache_Vector$stableprop[0] final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_LanguageQualifier$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_LanguageQualifier$stableprop|#static{}org_jetbrains_compose_resources_LanguageQualifier$stableprop[0] +final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_LocaleQualifiers$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_LocaleQualifiers$stableprop|#static{}org_jetbrains_compose_resources_LocaleQualifiers$stableprop[0] final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_MissingResourceException$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_MissingResourceException$stableprop|#static{}org_jetbrains_compose_resources_MissingResourceException$stableprop[0] final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_PluralStringResource$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_PluralStringResource$stableprop|#static{}org_jetbrains_compose_resources_PluralStringResource$stableprop[0] final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_RegionQualifier$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_RegionQualifier$stableprop|#static{}org_jetbrains_compose_resources_RegionQualifier$stableprop[0] @@ -111,6 +112,7 @@ final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageC final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Svg$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Svg$stableprop_getter|org_jetbrains_compose_resources_ImageCache_Svg$stableprop_getter(){}[0] final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Vector$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Vector$stableprop_getter|org_jetbrains_compose_resources_ImageCache_Vector$stableprop_getter(){}[0] final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_LanguageQualifier$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_LanguageQualifier$stableprop_getter|org_jetbrains_compose_resources_LanguageQualifier$stableprop_getter(){}[0] +final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_LocaleQualifiers$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_LocaleQualifiers$stableprop_getter|org_jetbrains_compose_resources_LocaleQualifiers$stableprop_getter(){}[0] final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_MissingResourceException$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_MissingResourceException$stableprop_getter|org_jetbrains_compose_resources_MissingResourceException$stableprop_getter(){}[0] final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_PluralStringResource$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_PluralStringResource$stableprop_getter|org_jetbrains_compose_resources_PluralStringResource$stableprop_getter(){}[0] final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_RegionQualifier$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_RegionQualifier$stableprop_getter|org_jetbrains_compose_resources_RegionQualifier$stableprop_getter(){}[0] @@ -132,6 +134,7 @@ final fun org.jetbrains.compose.resources/stringArrayResource(org.jetbrains.comp final fun org.jetbrains.compose.resources/stringResource(org.jetbrains.compose.resources/StringResource, androidx.compose.runtime/Composer?, kotlin/Int): kotlin/String // org.jetbrains.compose.resources/stringResource|stringResource(org.jetbrains.compose.resources.StringResource;androidx.compose.runtime.Composer?;kotlin.Int){}[0] final fun org.jetbrains.compose.resources/stringResource(org.jetbrains.compose.resources/StringResource, kotlin/Array..., androidx.compose.runtime/Composer?, kotlin/Int): kotlin/String // org.jetbrains.compose.resources/stringResource|stringResource(org.jetbrains.compose.resources.StringResource;kotlin.Array...;androidx.compose.runtime.Composer?;kotlin.Int){}[0] final fun org.jetbrains.compose.resources/vectorResource(org.jetbrains.compose.resources/DrawableResource, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.ui.graphics.vector/ImageVector // org.jetbrains.compose.resources/vectorResource|vectorResource(org.jetbrains.compose.resources.DrawableResource;androidx.compose.runtime.Composer?;kotlin.Int){}[0] +final suspend fun org.jetbrains.compose.resources/awaitPendingResourceStateLoads() // org.jetbrains.compose.resources/awaitPendingResourceStateLoads|awaitPendingResourceStateLoads(){}[0] final suspend fun org.jetbrains.compose.resources/getDrawableResourceBytes(org.jetbrains.compose.resources/ResourceEnvironment, org.jetbrains.compose.resources/DrawableResource): kotlin/ByteArray // org.jetbrains.compose.resources/getDrawableResourceBytes|getDrawableResourceBytes(org.jetbrains.compose.resources.ResourceEnvironment;org.jetbrains.compose.resources.DrawableResource){}[0] final suspend fun org.jetbrains.compose.resources/getFontResourceBytes(org.jetbrains.compose.resources/ResourceEnvironment, org.jetbrains.compose.resources/FontResource): kotlin/ByteArray // org.jetbrains.compose.resources/getFontResourceBytes|getFontResourceBytes(org.jetbrains.compose.resources.ResourceEnvironment;org.jetbrains.compose.resources.FontResource){}[0] final suspend fun org.jetbrains.compose.resources/getPluralString(org.jetbrains.compose.resources/PluralStringResource, kotlin/Int): kotlin/String // org.jetbrains.compose.resources/getPluralString|getPluralString(org.jetbrains.compose.resources.PluralStringResource;kotlin.Int){}[0]