Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions components/resources/demo/iosApp/iosApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<resources>
<string name="hello">😊 Hallo wereld!</string>
<string name="str_template">Hallo, %1$s!\nJe hebt %2$d nieuwe berichten.</string>
<string-array name="str_arr">
<item>onderdeel \u2605</item>
<item>onderdeel \u2318</item>
<item>onderdeel \u00BD</item>
</string-array>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<resources>
<string name="hello">😊 Привет, мир!</string>
<string name="multi_line">Это русский текст.\nПеревода на нидерландский для него нет.</string>
<plurals name="new_message">
<item quantity="one">%1$d новое сообщение</item>
<item quantity="few">%1$d новых сообщения</item>
<item quantity="many">%1$d новых сообщений</item>
<item quantity="other">%1$d нового сообщения</item>
</plurals>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResourceLocale> {
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)
)
}
}

private fun getSystemResourceLocales(): List<ResourceLocale> =
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<ResourceLocale> = List(size()) { get(it).toResourceLocale() }
private fun JavaLocale.toResourceLocale() = ResourceLocale(Locale(toLanguageTag()))
Original file line number Diff line number Diff line change
Expand Up @@ -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<LanguageQualifier>().firstOrNull()
val resourceScript = resourceItem.qualifiers.filterIsInstance<ScriptQualifier>().firstOrNull()
val resourceRegion = resourceItem.qualifiers.filterIsInstance<RegionQualifier>().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()
Comment thread
terrakok marked this conversation as resolved.
} 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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,44 @@ 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<ResourceLocale>,
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

return true
}

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
Expand All @@ -45,23 +57,24 @@ 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)
)
}
}
}

@Composable
internal expect fun getSystemLocales(): List<ResourceLocale>

//ComposeEnvironment provider will be overridden for tests
internal val LocalComposeEnvironment = staticCompositionLocalOf { DefaultComposeEnvironment }

Expand Down Expand Up @@ -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() }
Expand All @@ -109,6 +122,20 @@ internal fun Resource.getResourceItemByEnvironment(environment: ResourceEnvironm
}
}

private fun List<ResourceItem>.filterByLocales(locales: List<ResourceLocale>): List<ResourceItem> {
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<ResourceItem>.filterBy(qualifier: Qualifier): List<ResourceItem> {
//Android has a slightly different algorithm,
//but it provides the same result: https://developer.android.com/guide/topics/resources/providing-resources#BestMatch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>()
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)
}
}
Loading
Loading