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] 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/PluralStringResources.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/PluralStringResources.kt index 487da2a3bd2..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,9 +69,15 @@ private suspend fun loadPluralString( ): String { val resourceItem = resource.getResourceItemByEnvironment(environment) val item = getStringItem(resourceItem, resourceReader) as StringItem.Plurals + 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 + ?: RegionQualifier("") 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/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..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 @@ -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+. + * + * 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, + 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,133 @@ 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() + +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 +// 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 + 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() - // 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()) { + candidates.filter { + val itemScript = it.effectiveScript(language.language) + itemScript == null || itemScript == wantedScript + } + } else { + candidates } - 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 = candidates.filter { it.scriptQualifier() == null } + val defaultScriptMatch = withDefaultScript.narrowByRegion(language, region, allowOtherRegions = true) + if (defaultScriptMatch.isNotEmpty()) return defaultScriptMatch + + 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 1a694a53200..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 @@ -323,6 +323,83 @@ 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 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", + items = setOf( + ResourceItem(setOf(), "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 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)) + } + @Test fun testMissingResource() = runTest { assertFailsWith { 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..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 @@ -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,566 @@ 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 testUnqualifiedValuesMatchesEnglishOrDefaultFallback() { + val resourceDefaultAndEs = DrawableResource( + id = "ImageResource:default_es_test", + items = setOf( + ResourceItem(setOf(), "default", -1, -1), + 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"), + LocaleQualifiers("es", "", "US") + ) + ).path + ) + } + + @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( + 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 + ) } 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..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,10 +4,12 @@ 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 + +internal actual fun systemPreferredLocales(): List = jvmPreferredLocales() 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 +19,158 @@ 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 +} + +/** + * 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().ifEmpty { fallbackScript }), + 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/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" + ) + } +} 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 + """ + ) +}