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 нового сообщения + + 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..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 @@ -69,10 +69,21 @@ 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) + } ?: 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] ?: 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..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,22 +5,36 @@ 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, + 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 +42,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 +57,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 +72,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 +107,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 +122,20 @@ internal fun Resource.getResourceItemByEnvironment(environment: ResourceEnvironm } } +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 defaultItems +} + 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..7e70cfe8312 --- /dev/null +++ b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesComposeTest.kt @@ -0,0 +1,64 @@ +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", "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) // 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 new file mode 100644 index 00000000000..449a29dde87 --- /dev/null +++ b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceLocalesTest.kt @@ -0,0 +1,153 @@ +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 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 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 + 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 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")) + 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 deleted file mode 100644 index e5678e891ef..00000000000 --- a/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.js.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.jetbrains.compose.resources - -import kotlinx.browser.window - -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? - } -} - -internal actual fun getSystemEnvironment(): ResourceEnvironment { - val locale = Intl.Locale(window.navigator.language) - 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 ?: ""), - theme = ThemeQualifier.selectByValue(isDarkTheme), - density = DensityQualifier.selectByValue(dpi) - ) -} 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 deleted file mode 100644 index c737bf5a1f4..00000000000 --- a/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.wasmJs.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.jetbrains.compose.resources - -import kotlinx.browser.window - -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? - } -} - -internal actual fun getSystemEnvironment(): ResourceEnvironment { - val locale = Intl.Locale(window.navigator.language) - 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 ?: ""), - theme = ThemeQualifier.selectByValue(isDarkTheme), - density = DensityQualifier.selectByValue(dpi) - ) -} \ No newline at end of file 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..27e67297e55 --- /dev/null +++ b/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.web.kt @@ -0,0 +1,62 @@ +@file:OptIn(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.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 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