From ab99fead56c7b5f6e2659ffeb27ac4c2686ddb2a Mon Sep 17 00:00:00 2001 From: Konstantin Date: Wed, 14 Oct 2026 12:33:24 +0200 Subject: [PATCH 1/4] Resources: support non-first device languages https://youtrack.jetbrains.com/issue/CMP-6840 --- .../resources/ResourceEnvironment.android.kt | 42 ++++++- .../resources/PluralStringResources.kt | 15 ++- .../compose/resources/ResourceEnvironment.kt | 51 +++++--- .../compose/resources/ComposeResourceTest.kt | 8 +- .../resources/ResourceLocalesComposeTest.kt | 58 ++++++++++ .../compose/resources/ResourceLocalesTest.kt | 107 +++++++++++++++++ .../compose/resources/ResourceTest.kt | 9 +- .../resources/TestComposeEnvironment.kt | 4 +- .../resources/ResourceEnvironment.desktop.kt | 19 ++- .../resources/ResourceEnvironment.ios.kt | 40 +++++-- .../resources/ResourceEnvironment.js.kt | 34 +++++- .../src/jsTest/resources/test_setup.js | 10 ++ .../resources/ResourceEnvironment.macos.kt | 36 ++++-- .../resources/ResourceEnvironment.wasmJs.kt | 43 ++++++- .../resources/ResourceEnvironment.web.kt | 3 + .../resources/ResourceEnvironmentTest.wasm.kt | 50 -------- .../resources/ResourceEnvironmentTest.web.kt | 109 ++++++++++++++++++ 17 files changed, 510 insertions(+), 128 deletions(-) create mode 100644 components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesComposeTest.kt create mode 100644 components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesTest.kt create mode 100644 components/resources/library/src/jsTest/resources/test_setup.js create mode 100644 components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.web.kt delete mode 100644 components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/ResourceEnvironmentTest.wasm.kt create mode 100644 components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/ResourceEnvironmentTest.web.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..4403003fd7d 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,48 @@ package org.jetbrains.compose.resources import android.content.res.Configuration import android.content.res.Resources -import java.util.* +import android.os.Build +import android.os.LocaleList +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.intl.Locale +import java.util.Locale as JavaLocale + +@Composable +internal actual fun getSystemLocales(): List { + val configuration = LocalConfiguration.current + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + return remember(configuration.locales) { + configuration.locales.toResourceLocales() + } + } else { + @Suppress("DEPRECATION") + return remember(configuration.locale) { + listOf(configuration.locale.toResourceLocale()) + } + } +} 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 = getSystemResourceLocales(), theme = ThemeQualifier.selectByValue(isDarkTheme), density = DensityQualifier.selectByValue(dpi) ) -} \ No newline at end of file +} + +private fun getSystemResourceLocales(): List = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + LocaleList.getAdjustedDefault().toResourceLocales() + } else { + listOf(JavaLocale.getDefault().toResourceLocale()) + } + +@RequiresApi(Build.VERSION_CODES.N) +private fun LocaleList.toResourceLocales(): List = List(size()) { get(it).toResourceLocale() } +private fun JavaLocale.toResourceLocale() = ResourceLocale(Locale(toLanguageTag())) 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..eb0eabbaa11 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,10 +69,17 @@ private suspend fun loadPluralString( ): String { val resourceItem = resource.getResourceItemByEnvironment(environment) val item = getStringItem(resourceItem, resourceReader) as StringItem.Plurals - val pluralRuleList = PluralRuleList.getInstance( - environment.language, - environment.region, - ) + // Use the preference that matches the selected resource, including its requested region. + // Unqualified resources retain the primary environment locale, as before. + val resourceLanguage = resourceItem.qualifiers.filterIsInstance().firstOrNull() + val resourceScript = resourceItem.qualifiers.filterIsInstance().firstOrNull() + val resourceRegion = resourceItem.qualifiers.filterIsInstance().firstOrNull() + val locale = environment.locales.firstOrNull { (language, script, region) -> + language == resourceLanguage && + (resourceScript == null || script.isEmpty() || script == resourceScript) && + (resourceRegion == null || region == resourceRegion) + } ?: environment.locales.first() + val pluralRuleList = PluralRuleList.getInstance(locale.language, locale.region) val pluralCategory = pluralRuleList.getCategory(quantity) val str = item.items[pluralCategory] ?: item.items[PluralCategory.OTHER] 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..d5964f08f7b 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 @@ -5,22 +5,32 @@ import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.intl.Locale +internal data class ResourceLocale( + val language: LanguageQualifier, + val script: ScriptQualifier, + val region: RegionQualifier +) { + constructor(locale: Locale) : this( + LanguageQualifier(locale.language), ScriptQualifier(locale.script), RegionQualifier(locale.region) + ) +} + 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 ) { + init { + require(locales.isNotEmpty()) + } + 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 +38,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,16 +53,14 @@ internal interface ComposeEnvironment { internal val DefaultComposeEnvironment = object : ComposeEnvironment { @Composable override fun rememberEnvironment(): ResourceEnvironment { - val composeLocale = Locale.current + val composeLocales = getSystemLocales() val composeTheme = isSystemInDarkTheme() val composeDensity = LocalDensity.current //cache ResourceEnvironment unless compose environment is changed - return remember(composeLocale, composeTheme, composeDensity) { + return remember(composeLocales, composeTheme, composeDensity) { ResourceEnvironment( - LanguageQualifier(composeLocale.language), - ScriptQualifier(composeLocale.script), - RegionQualifier(composeLocale.region), + composeLocales, ThemeQualifier.selectByValue(composeTheme), DensityQualifier.selectByDensity(composeDensity.density) ) @@ -62,6 +68,9 @@ internal val DefaultComposeEnvironment = object : ComposeEnvironment { } } +@Composable +internal expect fun getSystemLocales(): List + //ComposeEnvironment provider will be overridden for tests internal val LocalComposeEnvironment = staticCompositionLocalOf { DefaultComposeEnvironment } @@ -94,7 +103,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() } @@ -109,6 +118,18 @@ internal fun Resource.getResourceItemByEnvironment(environment: ResourceEnvironm } } +// Try each preferred locale for this resource before falling back to unqualified items. +private fun List.filterByLocales(locales: List): List { + val localizedItems = filter { item -> item.qualifiers.any { it is LanguageQualifier } } + for ((language, script, region) in locales) { + val matches = localizedItems.filterByLocale(language, script, region) + if (matches.isNotEmpty()) return matches + } + return filter { item -> + item.qualifiers.none { it is LanguageQualifier || it is ScriptQualifier || it is RegionQualifier } + } +} + private fun List.filterBy(qualifier: Qualifier): List { //Android has a slightly different algorithm, //but it provides the same result: https://developer.android.com/guide/topics/resources/providing-resources#BestMatch 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..d9a8f4ccf69 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 @@ -82,9 +82,7 @@ class ComposeResourceTest : KmpUiTest() { val mdpiEnvironment = object : ComposeEnvironment { @Composable override fun rememberEnvironment() = ResourceEnvironment( - language = LanguageQualifier("en"), - script = ScriptQualifier(""), - region = RegionQualifier("US"), + locales = listOf(ResourceLocale(LanguageQualifier("en"), ScriptQualifier(""), RegionQualifier("US"))), theme = ThemeQualifier.LIGHT, density = DensityQualifier.MDPI ) @@ -399,9 +397,7 @@ class ComposeResourceTest : KmpUiTest() { @Test fun rememberResourceStateAffectedByEnvironmentChanges() = clearResourceCachesAndRunUiTest { val env2 = ResourceEnvironment( - language = LanguageQualifier("en"), - script = ScriptQualifier(""), - region = RegionQualifier("CA"), + locales = listOf(ResourceLocale(LanguageQualifier("en"), ScriptQualifier(""), RegionQualifier("CA"))), theme = ThemeQualifier.DARK, density = DensityQualifier.MDPI ) diff --git a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesComposeTest.kt b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesComposeTest.kt new file mode 100644 index 00000000000..67adfb9713d --- /dev/null +++ b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesComposeTest.kt @@ -0,0 +1,58 @@ +package org.jetbrains.compose.resources + +import androidx.compose.runtime.* +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.text.intl.Locale +import kotlin.io.encoding.Base64 +import kotlin.test.* + +@OptIn(ExperimentalTestApi::class) +class ResourceLocalesComposeTest : KmpUiTest() { + @Test + fun secondaryLanguageChangesInvalidateCacheAndUpdatePluralRules() = clearResourceCachesAndRunUiTest { + var languages by mutableStateOf(listOf("fr", "es")) + val environment = object : ComposeEnvironment { + @Composable + override fun rememberEnvironment() = ResourceEnvironment( + languages.map { ResourceLocale(Locale(it)) }, ThemeQualifier.LIGHT, DensityQualifier.MDPI + ) + } + val readPaths = mutableListOf() + val reader = object : ResourceReader { + override suspend fun read(path: String): ByteArray { + readPaths.add(path) + fun encode(value: String) = Base64.encode(value.encodeToByteArray()) + return "plurals|plural|one:${encode("$path-one")},other:${encode("$path-other")}".encodeToByteArray() + } + override suspend fun readPart(path: String, offset: Long, size: Long) = read(path) + override fun getUri(path: String): String = error("Not used") + } + val resource = PluralStringResource("secondary_plural", "secondary_plural", setOf( + ResourceItem(emptySet(), "default", 0, 0), + ResourceItem(setOf(LanguageQualifier("es")), "es", 0, 0), + ResourceItem(setOf(LanguageQualifier("de")), "de", 0, 0), + ResourceItem(setOf(LanguageQualifier("pt")), "pt", 0, 0) + )) + var text = "" + setContent { + CompositionLocalProvider(LocalComposeEnvironment provides environment, LocalResourceReader provides reader) { + text = pluralStringResource(resource, 0) + } + } + waitResources() + assertEquals("es-other", text) // French uses "one" for zero; the selected Spanish locale uses "other". + languages = listOf("fr", "de") + waitResources() + assertEquals("de-other", text) + languages = listOf("fr", "pt-PT") + waitResources() + assertEquals("pt-other", text) + languages = listOf("fr", "pt-BR") + waitResources() + assertEquals("pt-one", text) // The generic Portuguese resource uses the requested region's rules. + languages = listOf("fr", "ja") + waitResources() + assertEquals("default-one", text) // Default resources retain the primary locale's plural rules. + assertEquals(listOf("es", "de", "pt", "default"), readPaths) + } +} diff --git a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesTest.kt b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesTest.kt new file mode 100644 index 00000000000..ea558b702ef --- /dev/null +++ b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesTest.kt @@ -0,0 +1,107 @@ +package org.jetbrains.compose.resources + +import androidx.compose.ui.text.intl.Locale +import kotlin.test.* + +class ResourceLocalesTest { + private fun environment(vararg tags: String) = ResourceEnvironment( + tags.map { ResourceLocale(Locale(it)) }, ThemeQualifier.LIGHT, DensityQualifier.MDPI + ) + + private fun resource(vararg translated: String) = StringResource( + "locale_test", "locale_test", + setOf(ResourceItem(emptySet(), "default", 0, 0)) + translated.map { tag -> + val locale = ResourceLocale(Locale(tag)) + ResourceItem(buildSet { + add(locale.language) + if (!locale.script.isEmpty()) add(locale.script) + if (locale.region.region.isNotEmpty()) add(locale.region) + }, tag, 0, 0) + } + ) + + @Test + fun skipsUnsupportedFirstLanguage() { + assertEquals("es", resource("es").getResourceItemByEnvironment(environment("fr", "es")).path) + } + + @Test + fun triesAllPreferencesInOrder() { + val resource = resource("es", "de") + assertEquals("es", resource.getResourceItemByEnvironment(environment("ca", "ru", "es", "de")).path) + assertEquals("de", resource.getResourceItemByEnvironment(environment("ca", "de", "es")).path) + } + + @Test + fun incompleteTranslationUsesSecondaryLanguageForEachResource() { + val env = environment("fr", "de") + val title = resource("fr", "de") + val description = resource("de") + assertEquals("fr", title.getResourceItemByEnvironment(env).path) + assertEquals("de", description.getResourceItemByEnvironment(env).path) + } + + @Test + fun defaultDoesNotCountAsEnglishTranslation() { + assertEquals("es", resource("es").getResourceItemByEnvironment(environment("en-US", "es")).path) + } + + @Test + fun explicitEnglishTranslationParticipatesInPreferenceOrder() { + assertEquals("en", resource("en", "es").getResourceItemByEnvironment(environment("en-US", "es")).path) + } + + @Test + fun noMatchingPreferenceUsesDefault() { + assertEquals("default", resource("es").getResourceItemByEnvironment(environment("fr", "ca")).path) + } + + @Test + fun scriptMismatchTriesNextLanguage() { + assertEquals("es", resource("zh-Hant", "es") + .getResourceItemByEnvironment(environment("zh-Hans", "es")).path) + } + + @Test + fun regionMismatchTriesNextLanguage() { + assertEquals("de", resource("fr-CA", "de") + .getResourceItemByEnvironment(environment("fr-FR", "de")).path) + } + + @Test + fun genericPrimaryLanguageWinsOverExactSecondaryLanguage() { + assertEquals("fr", resource("fr", "de-DE") + .getResourceItemByEnvironment(environment("fr-FR", "de-DE")).path) + } + + @Test + fun preservesScriptAndRegionOfSelectedPreference() { + val resource = resource("sr-Latn", "sr-Latn-RS", "sr-Cyrl") + val env = environment("fr", "sr-Latn-RS", "sr-Cyrl") + assertEquals("sr-Latn-RS", resource.getResourceItemByEnvironment(env).path) + } + + @Test + fun appliesThemeAndDensityAfterSelectingSecondaryLanguage() { + val resource = DrawableResource("localized_image", setOf( + ResourceItem(emptySet(), "default", 0, 0), + ResourceItem(setOf(LanguageQualifier("de")), "de", 0, 0), + ResourceItem(setOf(LanguageQualifier("de"), ThemeQualifier.DARK), "de-dark", 0, 0), + ResourceItem(setOf(LanguageQualifier("de"), ThemeQualifier.DARK, DensityQualifier.XHDPI), "de-dark-xhdpi", 0, 0) + )) + val env = ResourceEnvironment(environment("fr", "de").locales, ThemeQualifier.DARK, DensityQualifier.XHDPI) + assertEquals("de-dark-xhdpi", resource.getResourceItemByEnvironment(env).path) + } + + @Test + fun secondaryPreferencesAffectEnvironmentEquality() { + assertNotEquals(environment("fr", "es"), environment("fr", "de")) + assertNotEquals(environment("fr", "es").hashCode(), environment("fr", "de").hashCode()) + assertEquals(environment("fr", "es"), environment("fr", "es")) + } + + @Test + fun localeOrderAffectsEnvironmentEquality() { + assertNotEquals(environment("fr", "de"), environment("de", "fr")) + } +} 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..5a361b2e497 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 @@ -8,7 +8,6 @@ package org.jetbrains.compose.resources import org.jetbrains.compose.resources.DensityQualifier.* import org.jetbrains.compose.resources.ThemeQualifier.DARK import org.jetbrains.compose.resources.ThemeQualifier.LIGHT -import androidx.compose.ui.text.intl.Locale import kotlin.test.* class ResourceTest { @@ -36,9 +35,7 @@ class ResourceTest { ) ) fun env(lang: String, reg: String, theme: ThemeQualifier, density: DensityQualifier) = ResourceEnvironment( - language = LanguageQualifier(lang), - script = ScriptQualifier(""), - region = RegionQualifier(reg), + locales = listOf(ResourceLocale(LanguageQualifier(lang), ScriptQualifier(""), RegionQualifier(reg))), theme = theme, density = density ) @@ -130,9 +127,7 @@ class ResourceTest { ) ) fun env(lang: String, script: String, reg: String) = ResourceEnvironment( - language = LanguageQualifier(lang), - script = ScriptQualifier(script), - region = RegionQualifier(reg), + locales = listOf(ResourceLocale(LanguageQualifier(lang), ScriptQualifier(script), RegionQualifier(reg))), theme = LIGHT, density = XHDPI ) diff --git a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/TestComposeEnvironment.kt b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/TestComposeEnvironment.kt index 39d6e112e44..02e43e07dc9 100644 --- a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/TestComposeEnvironment.kt +++ b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/TestComposeEnvironment.kt @@ -3,9 +3,7 @@ package org.jetbrains.compose.resources import androidx.compose.runtime.Composable internal fun getTestEnvironment() = ResourceEnvironment( - language = LanguageQualifier("en"), - script = ScriptQualifier(""), - region = RegionQualifier("US"), + locales = listOf(ResourceLocale(LanguageQualifier("en"), ScriptQualifier(""), RegionQualifier("US"))), theme = ThemeQualifier.LIGHT, density = DensityQualifier.XHDPI ) 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..71fd32962a5 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 @@ -1,13 +1,18 @@ package org.jetbrains.compose.resources +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.intl.Locale 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 as JavaLocale + +@Composable +internal actual fun getSystemLocales(): List = getSystemResourceLocales() internal actual fun getSystemEnvironment(): ResourceEnvironment { - val locale = Locale.getDefault() + val locales = getSystemResourceLocales() //FIXME: don't use skiko internals val isDarkTheme = currentSystemTheme == SystemTheme.DARK val dpi = if (GraphicsEnvironment.isHeadless()) { @@ -17,10 +22,12 @@ internal actual fun getSystemEnvironment(): ResourceEnvironment { Toolkit.getDefaultToolkit().screenResolution } return ResourceEnvironment( - language = LanguageQualifier(locale.language), - script = ScriptQualifier(locale.script), - region = RegionQualifier(locale.country), + locales = locales, theme = ThemeQualifier.selectByValue(isDarkTheme), density = DensityQualifier.selectByValue(dpi) ) -} \ No newline at end of file +} + +private fun getSystemResourceLocales(): List = listOf( + ResourceLocale(Locale(JavaLocale.getDefault().toLanguageTag())) +) 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..8b22f008953 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,27 +1,45 @@ package org.jetbrains.compose.resources -import platform.Foundation.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import platform.Foundation.NSLocale +import platform.Foundation.NSLocaleCountryCode +import platform.Foundation.currentLocale +import platform.Foundation.languageCode +import platform.Foundation.preferredLanguages +import platform.Foundation.scriptCode 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 +@Composable +internal actual fun getSystemLocales(): List = getSystemResourceLocales() - val languageCode = locale.languageCode - val scriptCode = locale.scriptCode - val regionCode = locale.objectForKey(NSLocaleCountryCode) as? String +internal actual fun getSystemEnvironment(): ResourceEnvironment { + val locales = getSystemResourceLocales() 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) ) +} + +private fun getSystemResourceLocales(): List = + resourceLocalesForLanguages(NSLocale.preferredLanguages.map { it as String }) + +private fun resourceLocalesForLanguages(languages: List): List { + val locales = languages + .map { NSLocale(it) } + .ifEmpty { listOf(NSLocale.currentLocale) } + return locales.map { locale -> + ResourceLocale( + LanguageQualifier(locale.languageCode), + ScriptQualifier(locale.scriptCode.orEmpty()), + RegionQualifier((locale.objectForKey(NSLocaleCountryCode) as? String).orEmpty()) + ) + } } \ No newline at end of file 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..a2b6292a358 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 @@ -1,6 +1,13 @@ package org.jetbrains.compose.resources +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import kotlinx.browser.window +import org.w3c.dom.events.Event private external class Intl { class Locale(locale: String) { @@ -17,16 +24,35 @@ private external class Intl { } } +@Composable +internal actual fun getSystemLocales(): List { + var locales by remember { mutableStateOf(getSystemResourceLocales()) } + DisposableEffect(Unit) { + val listener: (Event) -> Unit = { locales = getSystemResourceLocales() } + window.addEventListener("languagechange", listener) + onDispose { window.removeEventListener("languagechange", listener) } + } + return locales +} + internal actual fun getSystemEnvironment(): ResourceEnvironment { - val locale = Intl.Locale(window.navigator.language) + val locales = getSystemResourceLocales() 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) ) } + +internal actual fun getSystemResourceLocales(): List = + window.navigator.languages.asList().ifEmpty { listOf(window.navigator.language) }.map { tag -> + val locale = Intl.Locale(tag) + ResourceLocale( + LanguageQualifier(locale.language), + ScriptQualifier(locale.script.orEmpty()), + RegionQualifier(locale.region.orEmpty()) + ) + } diff --git a/components/resources/library/src/jsTest/resources/test_setup.js b/components/resources/library/src/jsTest/resources/test_setup.js new file mode 100644 index 00000000000..c6fd727aec5 --- /dev/null +++ b/components/resources/library/src/jsTest/resources/test_setup.js @@ -0,0 +1,10 @@ +// Kotlin/JS accesses Skiko through global symbols. Wait for the runtime before starting tests. +(function () { + const loaded = window.__karma__.loaded; + window.__karma__.loaded = function () { + import('/js-reexport-symbols.mjs') + .then(runtime => runtime.api.awaitSkiko) + .then(() => loaded.call(window.__karma__)) + .catch(error => window.__karma__.error(error.stack || String(error))); + }; +})(); 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..73d0c07b2f6 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 @@ -1,19 +1,21 @@ package org.jetbrains.compose.resources +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import kotlinx.cinterop.* import platform.AppKit.NSScreen import platform.CoreGraphics.CGDisplayPixelsWide import platform.CoreGraphics.CGDisplayScreenSize import platform.Foundation.* -internal actual fun getSystemEnvironment(): ResourceEnvironment { - val locale = NSLocale.preferredLanguages.firstOrNull() - ?.let { NSLocale(it as String) } - ?: NSLocale.currentLocale +@Composable +internal actual fun getSystemLocales(): List { + val locales = getSystemResourceLocales() + return remember(locales) { locales } +} - val languageCode = locale.languageCode - val scriptCode = locale.scriptCode - val regionCode = locale.objectForKey(NSLocaleCountryCode) as? String +internal actual fun getSystemEnvironment(): ResourceEnvironment { + val locales = getSystemResourceLocales() val isDarkTheme = NSUserDefaults.standardUserDefaults.stringForKey("AppleInterfaceStyle") == "Dark" val dpi = NSScreen.mainScreen?.let { screen -> @@ -30,10 +32,24 @@ 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) ) +} + +private fun getSystemResourceLocales(): List = + resourceLocalesForLanguages(NSLocale.preferredLanguages.map { it as String }) + +private fun resourceLocalesForLanguages(languages: List): List { + val locales = languages + .map { NSLocale(it) } + .ifEmpty { listOf(NSLocale.currentLocale) } + return locales.map { locale -> + ResourceLocale( + LanguageQualifier(locale.languageCode), + ScriptQualifier(locale.scriptCode.orEmpty()), + RegionQualifier((locale.objectForKey(NSLocaleCountryCode) as? String).orEmpty()) + ) + } } \ No newline at end of file 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..a88f2360716 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 @@ -1,9 +1,21 @@ +@file:OptIn(kotlin.js.ExperimentalWasmJsInterop::class) + package org.jetbrains.compose.resources +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import kotlinx.browser.window +import org.w3c.dom.events.Event +import kotlin.js.JsString +import kotlin.js.toList +import kotlin.js.toJsString private external class Intl { - class Locale(locale: String) { + class Locale(locale: JsString) { val language: String // Intl.Locale.script can be undefined. @@ -17,16 +29,35 @@ private external class Intl { } } +@Composable +internal actual fun getSystemLocales(): List { + var locales by remember { mutableStateOf(getSystemResourceLocales()) } + DisposableEffect(Unit) { + val listener: (Event) -> Unit = { locales = getSystemResourceLocales() } + window.addEventListener("languagechange", listener) + onDispose { window.removeEventListener("languagechange", listener) } + } + return locales +} + internal actual fun getSystemEnvironment(): ResourceEnvironment { - val locale = Intl.Locale(window.navigator.language) + val locales = getSystemResourceLocales() 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) ) -} \ No newline at end of file +} + +internal actual fun getSystemResourceLocales(): List = + window.navigator.languages.toList().ifEmpty { listOf(window.navigator.language.toJsString()) }.map { tag -> + val locale = Intl.Locale(tag) + ResourceLocale( + LanguageQualifier(locale.language), + ScriptQualifier(locale.script.orEmpty()), + RegionQualifier(locale.region.orEmpty()) + ) + } diff --git a/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.web.kt b/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.web.kt new file mode 100644 index 00000000000..7ebefbe9951 --- /dev/null +++ b/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.web.kt @@ -0,0 +1,3 @@ +package org.jetbrains.compose.resources + +internal expect fun getSystemResourceLocales(): List \ No newline at end of file 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 deleted file mode 100644 index 7b9579b5754..00000000000 --- a/components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/ResourceEnvironmentTest.wasm.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.jetbrains.compose.resources - -import kotlinx.browser.window -import kotlin.test.Test -import kotlin.test.assertEquals - -class ResourceEnvironmentTest { - - // covers https://youtrack.jetbrains.com/issue/CMP-6930 (also see the comments) - @Test - fun usingLocaleWithoutRegion() { - val originalLanguage = window.navigator.language - configureLanguage("en") - - try { - val env = getSystemEnvironment() - assertEquals("", env.region.region) - assertEquals("en", env.language.language) - } finally { - configureLanguage(originalLanguage) - } - } - - @Test - fun usingLocaleWithRegion() { - val originalLanguage = window.navigator.language - configureLanguage("en-NL") - - try { - val env = getSystemEnvironment() - assertEquals("NL", env.region.region) - assertEquals("en", env.language.language) - } finally { - configureLanguage(originalLanguage) - } - - } -} - -//language=js -private fun configureLanguage(language: String) { - js(""" - Object.defineProperty(window.navigator, 'language', { - get: function () { - return language; - }, - configurable: true - }); - """) -} \ No newline at end of file diff --git a/components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/ResourceEnvironmentTest.web.kt b/components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/ResourceEnvironmentTest.web.kt new file mode 100644 index 00000000000..35429b22748 --- /dev/null +++ b/components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/ResourceEnvironmentTest.web.kt @@ -0,0 +1,109 @@ +package org.jetbrains.compose.resources + +import kotlinx.browser.window +import kotlin.test.Test +import kotlin.test.assertEquals +import androidx.compose.ui.test.ExperimentalTestApi +import org.w3c.dom.events.Event + +class ResourceEnvironmentTest { + + // covers https://youtrack.jetbrains.com/issue/CMP-6930 (also see the comments) + @Test + fun usingLocaleWithoutRegion() { + val originalLanguages = getSystemResourceLocales().map { locale -> + listOf(locale.language.language, locale.script.script, locale.region.region) + .filter { it.isNotEmpty() }.joinToString("-") + } + configureLanguage("en") + + try { + val locale = getSystemEnvironment().locales.single() + assertEquals("", locale.region.region) + assertEquals("en", locale.language.language) + } finally { + configureLanguages(originalLanguages.joinToString(",")) + } + } + + @Test + fun usingLocaleWithRegion() { + val originalLanguages = getSystemResourceLocales().map { locale -> + listOf(locale.language.language, locale.script.script, locale.region.region) + .filter { it.isNotEmpty() }.joinToString("-") + } + configureLanguage("en-NL") + + try { + val locale = getSystemEnvironment().locales.single() + assertEquals("NL", locale.region.region) + assertEquals("en", locale.language.language) + } finally { + configureLanguages(originalLanguages.joinToString(",")) + } + + } + + @Test + fun allPreferredLanguagesAreAvailableInOrder() { + val original = getSystemResourceLocales() + try { + configureLanguages("ca-ES,es-ES,de-DE") + assertEquals(listOf("ca", "es", "de"), getSystemEnvironment().locales.map { it.language.language }) + val resource = StringResource("secondary", "secondary", setOf( + ResourceItem(emptySet(), "default", 0, 0), + ResourceItem(setOf(LanguageQualifier("es")), "es", 0, 0) + )) + assertEquals("es", resource.getResourceItemByEnvironment(getSystemEnvironment()).path) + } finally { + configureLanguages(original.joinToString(",") { locale -> + listOf(locale.language.language, locale.script.script, locale.region.region) + .filter { it.isNotEmpty() }.joinToString("-") + }) + } + } + + + @Test + @OptIn(ExperimentalTestApi::class) + fun languageChangeUpdatesSecondaryPreferences() = clearResourceCachesAndRunUiTest { + val original = getSystemResourceLocales() + fun languageTags(locales: List) = locales.joinToString(",") { locale -> + listOf(locale.language.language, locale.script.script, locale.region.region) + .filter { it.isNotEmpty() }.joinToString("-") + } + try { + configureLanguages("fr,es") + var environment: ResourceEnvironment? = null + setContent { environment = rememberResourceEnvironment() } + waitResources() + assertEquals(listOf("fr", "es"), environment!!.locales.map { it.language.language }) + configureLanguages("fr,de") + window.dispatchEvent(Event("languagechange")) + waitResources() + assertEquals(listOf("fr", "de"), environment!!.locales.map { it.language.language }) + } finally { + configureLanguages(languageTags(original)) + window.dispatchEvent(Event("languagechange")) + } + } + +} + +//language=js +private fun configureLanguage(language: String) = configureLanguages(language) + +private fun configureLanguages(languages: String) { + js(""" + Object.defineProperty(window.navigator, 'languages', { + get: function () { return languages ? languages.split(',') : []; }, + configurable: true + }); + Object.defineProperty(window.navigator, 'language', { + get: function () { + return languages.split(',')[0]; + }, + configurable: true + }); + """) +} \ No newline at end of file From 535964d46a04fda12c65a547d1f6183112e1e5f6 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Wed, 14 Oct 2026 14:35:11 +0200 Subject: [PATCH 2/4] Resources: prefer defaults for implicit English locale --- .../resources/PluralStringResources.kt | 6 ++- .../compose/resources/ResourceEnvironment.kt | 14 +++-- .../resources/ResourceLocalesComposeTest.kt | 8 ++- .../compose/resources/ResourceLocalesTest.kt | 54 +++++++++++++++++-- 4 files changed, 72 insertions(+), 10 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 eb0eabbaa11..97e15201b84 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 @@ -78,7 +78,11 @@ private suspend fun loadPluralString( language == resourceLanguage && (resourceScript == null || script.isEmpty() || script == resourceScript) && (resourceRegion == null || region == resourceRegion) - } ?: environment.locales.first() + } ?: if (resourceLanguage == null && resource.items.none { ENGLISH in it.qualifiers }) { + environment.locales.firstOrNull { it.language == ENGLISH } ?: environment.locales.first() + } else { + environment.locales.first() + } val pluralRuleList = PluralRuleList.getInstance(locale.language, locale.region) val pluralCategory = pluralRuleList.getCategory(quantity) val str = item.items[pluralCategory] 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 d5964f08f7b..aa4c48e9cd6 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 @@ -5,6 +5,10 @@ import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.intl.Locale +// Without an explicit English variant, unqualified items participate as English when the user explicitly requests it; +// otherwise they remain the final fallback. +internal val ENGLISH = LanguageQualifier("en") + internal data class ResourceLocale( val language: LanguageQualifier, val script: ScriptQualifier, @@ -118,16 +122,18 @@ internal fun Resource.getResourceItemByEnvironment(environment: ResourceEnvironm } } -// Try each preferred locale for this resource before falling back to unqualified items. private fun List.filterByLocales(locales: List): List { val localizedItems = filter { item -> item.qualifiers.any { it is LanguageQualifier } } + val defaultItems = filter { item -> + item.qualifiers.none { it is LanguageQualifier || it is ScriptQualifier || it is RegionQualifier } + } + val hasEnglishItems = localizedItems.any { ENGLISH in it.qualifiers } for ((language, script, region) in locales) { val matches = localizedItems.filterByLocale(language, script, region) if (matches.isNotEmpty()) return matches + if (language == ENGLISH && !hasEnglishItems && defaultItems.isNotEmpty()) return defaultItems } - return filter { item -> - item.qualifiers.none { it is LanguageQualifier || it is ScriptQualifier || it is RegionQualifier } - } + return defaultItems } private fun List.filterBy(qualifier: Qualifier): List { diff --git a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesComposeTest.kt b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesComposeTest.kt index 67adfb9713d..7e70cfe8312 100644 --- a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesComposeTest.kt +++ b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesComposeTest.kt @@ -50,9 +50,15 @@ class ResourceLocalesComposeTest : KmpUiTest() { languages = listOf("fr", "pt-BR") waitResources() assertEquals("pt-one", text) // The generic Portuguese resource uses the requested region's rules. + languages = listOf("fr", "en", "es") + waitResources() + assertEquals("default-other", text) // Implicit English wins over Spanish and uses English plural rules. + languages = listOf("en", "es") + waitResources() + assertEquals("default-other", text) languages = listOf("fr", "ja") waitResources() - assertEquals("default-one", text) // Default resources retain the primary locale's plural rules. + assertEquals("default-one", text) // Without an English preference, defaults retain the primary locale's rules. assertEquals(listOf("es", "de", "pt", "default"), readPaths) } } diff --git a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesTest.kt b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesTest.kt index ea558b702ef..449a29dde87 100644 --- a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesTest.kt +++ b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesTest.kt @@ -42,13 +42,47 @@ class ResourceLocalesTest { } @Test - fun defaultDoesNotCountAsEnglishTranslation() { - assertEquals("es", resource("es").getResourceItemByEnvironment(environment("en-US", "es")).path) + fun defaultCountsAsEnglishWhenEnglishIsRequested() { + val resource = resource("ru", "nl") + assertEquals("default", resource.getResourceItemByEnvironment(environment("en", "ru", "nl")).path) + assertEquals("default", resource.getResourceItemByEnvironment(environment("en-US", "ru", "nl")).path) + assertEquals("default", resource.getResourceItemByEnvironment(environment("en-GB", "nl", "ru")).path) } @Test - fun explicitEnglishTranslationParticipatesInPreferenceOrder() { - assertEquals("en", resource("en", "es").getResourceItemByEnvironment(environment("en-US", "es")).path) + fun implicitEnglishParticipatesInPreferenceOrder() { + val resource = resource("ru", "nl") + assertEquals("default", resource.getResourceItemByEnvironment(environment("fr", "en", "ru")).path) + assertEquals("ru", resource.getResourceItemByEnvironment(environment("ru", "en", "nl")).path) + assertEquals("nl", resource.getResourceItemByEnvironment(environment("fr", "nl", "en")).path) + } + + @Test + fun explicitEnglishVariantPreventsImplicitEnglishOnRegionMismatch() { + assertEquals("ru", resource("en-GB", "ru") + .getResourceItemByEnvironment(environment("en-US", "ru")).path) + } + + @Test + fun missingDefaultDoesNotPreventTryingSecondaryLanguage() { + val resource = StringResource("no_default", "no_default", setOf( + ResourceItem(setOf(LanguageQualifier("ru")), "ru", 0, 0) + )) + assertEquals("ru", resource.getResourceItemByEnvironment(environment("en", "ru")).path) + } + + @Test + fun explicitEnglishWinsOverDefaultWhenBothArePresent() { + val resource = StringResource("explicit_english", "explicit_english", setOf( + ResourceItem(emptySet(), "default", 0, 0), + ResourceItem(setOf(LanguageQualifier("en")), "en", 0, 0), + ResourceItem(setOf(LanguageQualifier("ru")), "ru", 0, 0) + )) + assertEquals("en", resource.getResourceItemByEnvironment(environment("en", "ru")).path) + assertEquals("en", resource.getResourceItemByEnvironment(environment("en-US", "ru")).path) + assertEquals("en", resource.getResourceItemByEnvironment(environment("fr", "en", "ru")).path) + assertEquals("ru", resource.getResourceItemByEnvironment(environment("ru", "en")).path) + assertEquals("default", resource.getResourceItemByEnvironment(environment("fr", "nl")).path) } @Test @@ -93,6 +127,18 @@ class ResourceLocalesTest { assertEquals("de-dark-xhdpi", resource.getResourceItemByEnvironment(env).path) } + @Test + fun appliesThemeAndDensityAfterSelectingImplicitEnglish() { + val resource = DrawableResource("implicit_english_image", setOf( + ResourceItem(emptySet(), "default", 0, 0), + ResourceItem(setOf(ThemeQualifier.DARK), "default-dark", 0, 0), + ResourceItem(setOf(ThemeQualifier.DARK, DensityQualifier.XHDPI), "default-dark-xhdpi", 0, 0), + ResourceItem(setOf(LanguageQualifier("ru")), "ru", 0, 0) + )) + val env = ResourceEnvironment(environment("fr", "en", "ru").locales, ThemeQualifier.DARK, DensityQualifier.XHDPI) + assertEquals("default-dark-xhdpi", resource.getResourceItemByEnvironment(env).path) + } + @Test fun secondaryPreferencesAffectEnvironmentEquality() { assertNotEquals(environment("fr", "es"), environment("fr", "de")) From a0e7ec55a7d44d4a136d4f3ecfaf353a98a95ba2 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Wed, 14 Oct 2026 14:38:44 +0200 Subject: [PATCH 3/4] Resources: update demo --- .../demo/iosApp/iosApp.xcodeproj/project.pbxproj | 8 ++++---- components/resources/demo/iosApp/iosApp/Info.plist | 2 ++ .../commonMain/composeResources/values-nl/strings.xml | 9 +++++++++ .../commonMain/composeResources/values-ru/strings.xml | 10 ++++++++++ 4 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 components/resources/demo/shared/src/commonMain/composeResources/values-nl/strings.xml create mode 100644 components/resources/demo/shared/src/commonMain/composeResources/values-ru/strings.xml diff --git a/components/resources/demo/iosApp/iosApp.xcodeproj/project.pbxproj b/components/resources/demo/iosApp/iosApp.xcodeproj/project.pbxproj index f7d234b66dd..10c8c63154f 100644 --- a/components/resources/demo/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/components/resources/demo/iosApp/iosApp.xcodeproj/project.pbxproj @@ -300,11 +300,11 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = 6KS4K8Z64D; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; INFOPLIST_FILE = iosApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -329,11 +329,11 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = 6KS4K8Z64D; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n"; INFOPLIST_FILE = iosApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/components/resources/demo/iosApp/iosApp/Info.plist b/components/resources/demo/iosApp/iosApp/Info.plist index 9a269f5eaac..aaf5e182d29 100644 --- a/components/resources/demo/iosApp/iosApp/Info.plist +++ b/components/resources/demo/iosApp/iosApp/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable diff --git a/components/resources/demo/shared/src/commonMain/composeResources/values-nl/strings.xml b/components/resources/demo/shared/src/commonMain/composeResources/values-nl/strings.xml new file mode 100644 index 00000000000..894e6fe5cf5 --- /dev/null +++ b/components/resources/demo/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -0,0 +1,9 @@ + + 😊 Hallo wereld! + Hallo, %1$s!\nJe hebt %2$d nieuwe berichten. + + onderdeel \u2605 + onderdeel \u2318 + onderdeel \u00BD + + diff --git a/components/resources/demo/shared/src/commonMain/composeResources/values-ru/strings.xml b/components/resources/demo/shared/src/commonMain/composeResources/values-ru/strings.xml new file mode 100644 index 00000000000..8c61d256820 --- /dev/null +++ b/components/resources/demo/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -0,0 +1,10 @@ + + 😊 Привет, мир! + Это русский текст.\nПеревода на нидерландский для него нет. + + %1$d новое сообщение + %1$d новых сообщения + %1$d новых сообщений + %1$d нового сообщения + + From 542b5186c23d24660e27175e325855ec7d8e596b Mon Sep 17 00:00:00 2001 From: Konstantin Date: Fri, 16 Oct 2026 10:36:14 +0200 Subject: [PATCH 4/4] Review fix --- .../resources/ResourceEnvironment.js.kt | 58 ----------------- .../resources/ResourceEnvironment.wasmJs.kt | 63 ------------------- .../resources/ResourceEnvironment.web.kt | 61 +++++++++++++++++- 3 files changed, 60 insertions(+), 122 deletions(-) delete mode 100644 components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.js.kt delete mode 100644 components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.wasmJs.kt 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 deleted file mode 100644 index a2b6292a358..00000000000 --- a/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.js.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.jetbrains.compose.resources - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import kotlinx.browser.window -import org.w3c.dom.events.Event - -private external class Intl { - class Locale(locale: String) { - val language: String - - // Intl.Locale.script can be undefined. - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/script - val script: String? - - // Intl.Locale.region can be undefined. - // For example, new Int.Locale('en') instead of new Int.Locale('en-NL'). - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/region - val region: String? - } -} - -@Composable -internal actual fun getSystemLocales(): List { - var locales by remember { mutableStateOf(getSystemResourceLocales()) } - DisposableEffect(Unit) { - val listener: (Event) -> Unit = { locales = getSystemResourceLocales() } - window.addEventListener("languagechange", listener) - onDispose { window.removeEventListener("languagechange", listener) } - } - return locales -} - -internal actual fun getSystemEnvironment(): ResourceEnvironment { - val locales = getSystemResourceLocales() - 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( - locales = locales, - theme = ThemeQualifier.selectByValue(isDarkTheme), - density = DensityQualifier.selectByValue(dpi) - ) -} - -internal actual fun getSystemResourceLocales(): List = - window.navigator.languages.asList().ifEmpty { listOf(window.navigator.language) }.map { tag -> - val locale = Intl.Locale(tag) - ResourceLocale( - LanguageQualifier(locale.language), - ScriptQualifier(locale.script.orEmpty()), - RegionQualifier(locale.region.orEmpty()) - ) - } 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 deleted file mode 100644 index a88f2360716..00000000000 --- a/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.wasmJs.kt +++ /dev/null @@ -1,63 +0,0 @@ -@file:OptIn(kotlin.js.ExperimentalWasmJsInterop::class) - -package org.jetbrains.compose.resources - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import kotlinx.browser.window -import org.w3c.dom.events.Event -import kotlin.js.JsString -import kotlin.js.toList -import kotlin.js.toJsString - -private external class Intl { - class Locale(locale: JsString) { - val language: String - - // Intl.Locale.script can be undefined. - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/script - val script: String? - - // Intl.Locale.region can be undefined. - // For example, new Int.Locale('en') instead of new Int.Locale('en-NL'). - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/region - val region: String? - } -} - -@Composable -internal actual fun getSystemLocales(): List { - var locales by remember { mutableStateOf(getSystemResourceLocales()) } - DisposableEffect(Unit) { - val listener: (Event) -> Unit = { locales = getSystemResourceLocales() } - window.addEventListener("languagechange", listener) - onDispose { window.removeEventListener("languagechange", listener) } - } - return locales -} - -internal actual fun getSystemEnvironment(): ResourceEnvironment { - val locales = getSystemResourceLocales() - 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( - locales = locales, - theme = ThemeQualifier.selectByValue(isDarkTheme), - density = DensityQualifier.selectByValue(dpi) - ) -} - -internal actual fun getSystemResourceLocales(): List = - window.navigator.languages.toList().ifEmpty { listOf(window.navigator.language.toJsString()) }.map { tag -> - val locale = Intl.Locale(tag) - ResourceLocale( - LanguageQualifier(locale.language), - ScriptQualifier(locale.script.orEmpty()), - RegionQualifier(locale.region.orEmpty()) - ) - } diff --git a/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.web.kt b/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.web.kt index 7ebefbe9951..27e67297e55 100644 --- a/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.web.kt +++ b/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.web.kt @@ -1,3 +1,62 @@ +@file:OptIn(ExperimentalWasmJsInterop::class) + package org.jetbrains.compose.resources -internal expect fun getSystemResourceLocales(): List \ No newline at end of file +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import kotlinx.browser.window +import org.w3c.dom.events.Event +import kotlin.collections.ifEmpty +import kotlin.collections.map + +private external class Intl { + class Locale(locale: JsString) { + val language: String + + // Intl.Locale.script can be undefined. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/script + val script: String? + + // Intl.Locale.region can be undefined. + // For example, new Int.Locale('en') instead of new Int.Locale('en-NL'). + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/region + val region: String? + } +} + +@Composable +internal actual fun getSystemLocales(): List { + var locales by remember { mutableStateOf(getSystemResourceLocales()) } + DisposableEffect(Unit) { + val listener: (Event) -> Unit = { locales = getSystemResourceLocales() } + window.addEventListener("languagechange", listener) + onDispose { window.removeEventListener("languagechange", listener) } + } + return locales +} + +internal actual fun getSystemEnvironment(): ResourceEnvironment { + val locales = getSystemResourceLocales() + 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( + locales = locales, + theme = ThemeQualifier.selectByValue(isDarkTheme), + density = DensityQualifier.selectByValue(dpi) + ) +} + +private fun getSystemResourceLocales(): List = + window.navigator.languages.toList().ifEmpty { listOf(window.navigator.language.toJsString()) }.map { tag -> + val locale = Intl.Locale(tag) + ResourceLocale( + LanguageQualifier(locale.language), + ScriptQualifier(locale.script.orEmpty()), + RegionQualifier(locale.region.orEmpty()) + ) + } \ No newline at end of file