Browse Source

Get environment and select resource by qualifiers (#4018)

pull/4056/head
Konstantin 11 months ago committed by GitHub
parent
commit
27915cbc0f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/FileRes.kt
  2. 2
      components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/FontRes.kt
  3. 32
      components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/ImagesRes.kt
  4. 24
      components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/StringRes.kt
  5. 0
      components/resources/demo/shared/src/commonMain/resources/composeRes/drawable/compose.png
  6. 0
      components/resources/demo/shared/src/commonMain/resources/composeRes/drawable/droid_icon.xml
  7. 0
      components/resources/demo/shared/src/commonMain/resources/composeRes/drawable/insta_icon.xml
  8. 0
      components/resources/demo/shared/src/commonMain/resources/composeRes/drawable/land.webp
  9. 0
      components/resources/demo/shared/src/commonMain/resources/composeRes/font/font_awesome.otf
  10. 63
      components/resources/library/src/androidInstrumentedTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.android.kt
  11. 8
      components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/FontResources.android.kt
  12. 18
      components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.android.kt
  13. 14
      components/resources/library/src/blockingMain/kotlin/org/jetbrains/compose/resources/PlatformState.blocking.kt
  14. 18
      components/resources/library/src/blockingMain/kotlin/org/jetbrains/compose/resources/ResourceState.blocking.kt
  15. 4
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/FontResources.kt
  16. 55
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ImageResources.kt
  17. 50
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/Qualifier.kt
  18. 7
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/Resource.kt
  19. 73
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.kt
  20. 4
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceState.kt
  21. 67
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResources.kt
  22. 118
      components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceTest.kt
  23. 19
      components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.desktop.kt
  24. 67
      components/resources/library/src/desktopTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.desktop.kt
  25. 21
      components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.ios.kt
  26. 4
      components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ImageResources.js.kt
  27. 23
      components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.js.kt
  28. 32
      components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.macos.kt
  29. 8
      components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt
  30. 23
      components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.wasmJs.kt
  31. 9
      components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceState.web.kt
  32. 2
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/AndroidTargetConfiguration.kt
  33. 4
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/GenerateResClassTask.kt
  34. 105
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt
  35. 54
      gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/ResourcesTest.kt
  36. 99
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected/Res.kt
  37. 0
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/drawable-au-rUS/vector.xml
  38. 0
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/drawable-dark-ge/vector.xml
  39. 0
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/drawable-en/vector.xml
  40. 0
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/drawable/3-strange-name.xml
  41. 0
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/drawable/vector.xml
  42. 0
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/drawable/vector_2.xml
  43. 0
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/font/emptyFont.otf

6
components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/FileRes.kt

@ -28,7 +28,7 @@ fun FileRes(paddingValues: PaddingValues) {
) {
Text(
modifier = Modifier.padding(16.dp),
text = "File: 'composeRes/images/droid_icon.xml'",
text = "File: 'composeRes/drawable/droid_icon.xml'",
style = MaterialTheme.typography.titleLarge
)
OutlinedCard(
@ -38,7 +38,7 @@ fun FileRes(paddingValues: PaddingValues) {
) {
var bytes by remember { mutableStateOf(ByteArray(0)) }
LaunchedEffect(Unit) {
bytes = readResourceBytes("composeRes/images/droid_icon.xml")
bytes = readResourceBytes("composeRes/drawable/droid_icon.xml")
}
Text(
modifier = Modifier.padding(8.dp).height(200.dp).verticalScroll(rememberScrollState()),
@ -54,7 +54,7 @@ fun FileRes(paddingValues: PaddingValues) {
mutableStateOf(ByteArray(0))
}
LaunchedEffect(Unit) {
bytes = readBytes("composeRes/images/droid_icon.xml")
bytes = readResourceBytes("composeRes/drawable/droid_icon.xml")
}
Text(bytes.decodeToString())
""".trimIndent()

2
components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/FontRes.kt

@ -43,7 +43,7 @@ fun FontRes(paddingValues: PaddingValues) {
)
}
val fontAwesome = FontFamily(Font(Res.fonts.font_awesome))
val fontAwesome = FontFamily(Font(Res.font.font_awesome))
val symbols = arrayOf(0xf1ba, 0xf238, 0xf21a, 0xf1bb, 0xf1b8, 0xf09b, 0xf269, 0xf1d0, 0xf15a, 0xf293, 0xf1c6)
Text(
modifier = Modifier.padding(16.dp),

32
components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/ImagesRes.kt

@ -28,13 +28,13 @@ fun ImagesRes(contentPadding: PaddingValues) {
) {
Image(
modifier = Modifier.size(100.dp),
painter = painterResource(Res.images.compose),
painter = painterResource(Res.drawable.compose),
contentDescription = null
)
Text(
"""
Image(
painter = painterResource(Res.images.compose)
painter = painterResource(Res.drawable.compose)
)
""".trimIndent()
)
@ -47,13 +47,13 @@ fun ImagesRes(contentPadding: PaddingValues) {
) {
Image(
modifier = Modifier.size(100.dp),
painter = painterResource(Res.images.insta_icon),
painter = painterResource(Res.drawable.insta_icon),
contentDescription = null
)
Text(
"""
Image(
painter = painterResource(Res.images.insta_icon)
painter = painterResource(Res.drawable.insta_icon)
)
""".trimIndent()
)
@ -66,13 +66,13 @@ fun ImagesRes(contentPadding: PaddingValues) {
) {
Image(
modifier = Modifier.size(140.dp),
bitmap = imageResource(Res.images.land),
bitmap = imageResource(Res.drawable.land),
contentDescription = null
)
Text(
"""
Image(
bitmap = imageResource(Res.images.land)
bitmap = imageResource(Res.drawable.land)
)
""".trimIndent()
)
@ -85,13 +85,13 @@ fun ImagesRes(contentPadding: PaddingValues) {
) {
Image(
modifier = Modifier.size(100.dp),
imageVector = vectorResource(Res.images.droid_icon),
imageVector = vectorResource(Res.drawable.droid_icon),
contentDescription = null
)
Text(
"""
Image(
imageVector = vectorResource(Res.images.droid_icon)
imageVector = vectorResource(Res.drawable.droid_icon)
)
""".trimIndent()
)
@ -104,13 +104,13 @@ fun ImagesRes(contentPadding: PaddingValues) {
) {
Icon(
modifier = Modifier.size(100.dp),
painter = painterResource(Res.images.compose),
painter = painterResource(Res.drawable.compose),
contentDescription = null
)
Text(
"""
Icon(
painter = painterResource(Res.images.compose)
painter = painterResource(Res.drawable.compose)
)
""".trimIndent()
)
@ -123,13 +123,13 @@ fun ImagesRes(contentPadding: PaddingValues) {
) {
Icon(
modifier = Modifier.size(100.dp),
painter = painterResource(Res.images.insta_icon),
painter = painterResource(Res.drawable.insta_icon),
contentDescription = null
)
Text(
"""
Icon(
painter = painterResource(Res.images.insta_icon)
painter = painterResource(Res.drawable.insta_icon)
)
""".trimIndent()
)
@ -142,13 +142,13 @@ fun ImagesRes(contentPadding: PaddingValues) {
) {
Icon(
modifier = Modifier.size(140.dp),
bitmap = imageResource(Res.images.land),
bitmap = imageResource(Res.drawable.land),
contentDescription = null
)
Text(
"""
Icon(
bitmap = imageResource(Res.images.land)
bitmap = imageResource(Res.drawable.land)
)
""".trimIndent()
)
@ -161,13 +161,13 @@ fun ImagesRes(contentPadding: PaddingValues) {
) {
Icon(
modifier = Modifier.size(100.dp),
imageVector = vectorResource(Res.images.droid_icon),
imageVector = vectorResource(Res.drawable.droid_icon),
contentDescription = null
)
Text(
"""
Icon(
imageVector = vectorResource(Res.images.droid_icon)
imageVector = vectorResource(Res.drawable.droid_icon)
)
""".trimIndent()
)

24
components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/StringRes.kt

@ -22,8 +22,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import components.resources.demo.generated.resources.Res
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.getStringArray
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.stringArrayResource
import org.jetbrains.compose.resources.readResourceBytes
@Composable
@ -54,9 +54,9 @@ fun StringRes(paddingValues: PaddingValues) {
}
OutlinedTextField(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
value = getString(Res.strings.app_name),
value = stringResource(Res.string.app_name),
onValueChange = {},
label = { Text("Text(getString(Res.strings.app_name)") },
label = { Text("Text(stringResource(Res.string.app_name)") },
enabled = false,
colors = TextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
@ -66,9 +66,9 @@ fun StringRes(paddingValues: PaddingValues) {
)
OutlinedTextField(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
value = getString(Res.strings.hello),
value = stringResource(Res.string.hello),
onValueChange = {},
label = { Text("Text(getString(Res.strings.hello)") },
label = { Text("Text(stringResource(Res.string.hello)") },
enabled = false,
colors = TextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
@ -78,9 +78,9 @@ fun StringRes(paddingValues: PaddingValues) {
)
OutlinedTextField(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
value = getString(Res.strings.multi_line),
value = stringResource(Res.string.multi_line),
onValueChange = {},
label = { Text("Text(getString(Res.strings.multi_line)") },
label = { Text("Text(stringResource(Res.string.multi_line)") },
enabled = false,
colors = TextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
@ -90,9 +90,9 @@ fun StringRes(paddingValues: PaddingValues) {
)
OutlinedTextField(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
value = getString(Res.strings.str_template, "User_name", 100),
value = stringResource(Res.string.str_template, "User_name", 100),
onValueChange = {},
label = { Text("Text(getString(Res.strings.str_template, \"User_name\", 100)") },
label = { Text("Text(stringResource(Res.string.str_template, \"User_name\", 100)") },
enabled = false,
colors = TextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
@ -102,9 +102,9 @@ fun StringRes(paddingValues: PaddingValues) {
)
OutlinedTextField(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
value = getStringArray(Res.strings.str_arr).toString(),
value = stringArrayResource(Res.string.str_arr).toString(),
onValueChange = {},
label = { Text("Text(getStringArray(Res.strings.str_arr).toString())") },
label = { Text("Text(stringArrayResource(Res.string.str_arr).toString())") },
enabled = false,
colors = TextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,

0
components/resources/demo/shared/src/commonMain/resources/composeRes/images/compose.png → components/resources/demo/shared/src/commonMain/resources/composeRes/drawable/compose.png

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

0
components/resources/demo/shared/src/commonMain/resources/composeRes/images/droid_icon.xml → components/resources/demo/shared/src/commonMain/resources/composeRes/drawable/droid_icon.xml

0
components/resources/demo/shared/src/commonMain/resources/composeRes/images/insta_icon.xml → components/resources/demo/shared/src/commonMain/resources/composeRes/drawable/insta_icon.xml

0
components/resources/demo/shared/src/commonMain/resources/composeRes/images/land.webp → components/resources/demo/shared/src/commonMain/resources/composeRes/drawable/land.webp

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

0
components/resources/demo/shared/src/commonMain/resources/composeRes/fonts/font_awesome.otf → components/resources/demo/shared/src/commonMain/resources/composeRes/font/font_awesome.otf

63
components/resources/library/src/androidInstrumentedTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.android.kt

@ -12,6 +12,7 @@ import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@OptIn(ExperimentalResourceApi::class, ExperimentalTestApi::class)
class ComposeResourceTest {
@ -25,7 +26,7 @@ class ComposeResourceTest {
@Test
fun testCountRecompositions() = runComposeUiTest {
runBlockingTest {
val imagePathFlow = MutableStateFlow(ImageResource("1.png"))
val imagePathFlow = MutableStateFlow(DrawableResource("1.png"))
val recompositionsCounter = RecompositionsCounter()
setContent {
val res by imagePathFlow.collectAsState()
@ -35,7 +36,7 @@ class ComposeResourceTest {
}
}
awaitIdle()
imagePathFlow.emit(ImageResource("2.png"))
imagePathFlow.emit(DrawableResource("2.png"))
awaitIdle()
assertEquals(2, recompositionsCounter.count)
}
@ -45,7 +46,7 @@ class ComposeResourceTest {
fun testImageResourceCache() = runComposeUiTest {
runBlockingTest {
val testResourceReader = TestResourceReader()
val imagePathFlow = MutableStateFlow(ImageResource("1.png"))
val imagePathFlow = MutableStateFlow(DrawableResource("1.png"))
setContent {
CompositionLocalProvider(LocalResourceReader provides testResourceReader) {
val res by imagePathFlow.collectAsState()
@ -53,9 +54,9 @@ class ComposeResourceTest {
}
}
awaitIdle()
imagePathFlow.emit(ImageResource("2.png"))
imagePathFlow.emit(DrawableResource("2.png"))
awaitIdle()
imagePathFlow.emit(ImageResource("1.png"))
imagePathFlow.emit(DrawableResource("1.png"))
awaitIdle()
assertEquals(
@ -73,8 +74,8 @@ class ComposeResourceTest {
setContent {
CompositionLocalProvider(LocalResourceReader provides testResourceReader) {
val res by stringIdFlow.collectAsState()
Text(getString(res))
Text(getStringArray(TestStringResource("str_arr")).joinToString())
Text(stringResource(res))
Text(stringArrayResource(TestStringResource("str_arr")).joinToString())
}
}
awaitIdle()
@ -94,14 +95,56 @@ class ComposeResourceTest {
fun testReadStringResource() = runComposeUiTest {
runBlockingTest {
setContent {
assertEquals("Compose Resources App", getString(TestStringResource("app_name")))
assertEquals("Compose Resources App", stringResource(TestStringResource("app_name")))
assertEquals(
"Hello, test-name! You have 42 new messages.",
getString(TestStringResource("str_template"), "test-name", 42)
stringResource(TestStringResource("str_template"), "test-name", 42)
)
assertEquals(listOf("item 1", "item 2", "item 3"), getStringArray(TestStringResource("str_arr")))
assertEquals(listOf("item 1", "item 2", "item 3"), stringArrayResource(TestStringResource("str_arr")))
}
awaitIdle()
}
}
@Test
fun testLoadStringResource() = runBlockingTest {
kotlin.test.assertEquals("Compose Resources App", getString(TestStringResource("app_name")))
kotlin.test.assertEquals(
"Hello, test-name! You have 42 new messages.",
getString(TestStringResource("str_template"), "test-name", 42)
)
kotlin.test.assertEquals(listOf("item 1", "item 2", "item 3"), getStringArray(TestStringResource("str_arr")))
}
@Test
fun testMissingResource() = runBlockingTest {
assertFailsWith<MissingResourceException> {
readResourceBytes("missing.png")
}
val error = assertFailsWith<IllegalStateException> {
getString(TestStringResource("unknown_id"))
}
kotlin.test.assertEquals("String ID=`unknown_id` is not found!", error.message)
}
@Test
fun testReadFileResource() = runBlockingTest {
val bytes = readResourceBytes("strings.xml")
kotlin.test.assertEquals(
"""
<resources>
<string name="app_name">Compose Resources App</string>
<string name="hello">😊 Hello world!</string>
<string name="str_template">Hello, %1${'$'}s! You have %2${'$'}d new messages.</string>
<string-array name="str_arr">
<item>item 1</item>
<item>item 2</item>
<item>item 3</item>
</string-array>
</resources>
""".trimIndent(),
bytes.decodeToString()
)
}
}

8
components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/FontResources.android.kt

@ -1,14 +1,14 @@
package org.jetbrains.compose.resources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.*
@ExperimentalResourceApi
@Composable
actual fun Font(resource: FontResource, weight: FontWeight, style: FontStyle): Font {
val path = resource.getPathByEnvironment()
val environment = rememberEnvironment()
val path = remember(environment) { resource.getPathByEnvironment(environment) }
return Font(path, LocalContext.current.assets, weight, style)
}

18
components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.android.kt

@ -0,0 +1,18 @@
package org.jetbrains.compose.resources
import android.content.res.Configuration
import android.content.res.Resources
import java.util.*
internal actual fun getResourceEnvironment(): 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),
region = RegionQualifier(locale.country),
theme = ThemeQualifier.selectByValue(isDarkTheme),
density = DensityQualifier.selectByValue(dpi)
)
}

14
components/resources/library/src/blockingMain/kotlin/org/jetbrains/compose/resources/PlatformState.blocking.kt

@ -1,14 +0,0 @@
package org.jetbrains.compose.resources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import kotlinx.coroutines.runBlocking
@Composable
internal actual fun <T> rememberState(key: Any, getDefault: () -> T, block: suspend () -> T): State<T> = remember(key) {
mutableStateOf(
runBlocking { block() }
)
}

18
components/resources/library/src/blockingMain/kotlin/org/jetbrains/compose/resources/ResourceState.blocking.kt

@ -0,0 +1,18 @@
package org.jetbrains.compose.resources
import androidx.compose.runtime.*
import kotlinx.coroutines.runBlocking
@Composable
internal actual fun <T> rememberResourceState(
key: Any,
getDefault: () -> T,
block: suspend (ResourceEnvironment) -> T
): State<T> {
val environment = rememberEnvironment()
return remember(key, environment) {
mutableStateOf(
runBlocking { block(environment) }
)
}
}

4
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/FontResources.kt

@ -2,9 +2,7 @@ package org.jetbrains.compose.resources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.*
/**
* Represents a font resource.

55
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ImageResources.kt

@ -1,9 +1,6 @@
package org.jetbrains.compose.resources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
@ -11,46 +8,44 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jetbrains.compose.resources.vector.toImageVector
import org.jetbrains.compose.resources.vector.xmldom.Element
/**
* Represents an image resource.
* Represents a drawable resource.
*
* @param id The unique identifier of the image resource.
* @param id The unique identifier of the drawable resource.
* @param items The set of resource items associated with the image resource.
*/
@Immutable
class ImageResource(id: String, items: Set<ResourceItem>) : Resource(id, items)
class DrawableResource(id: String, items: Set<ResourceItem>) : Resource(id, items)
/**
* Creates an [ImageResource] object with the specified path.
* Creates an [DrawableResource] object with the specified path.
*
* @param path The path of the image resource.
* @return An [ImageResource] object.
* @param path The path of the drawable resource.
* @return An [DrawableResource] object.
*/
fun ImageResource(path: String): ImageResource = ImageResource(
id = "ImageResource:$path",
fun DrawableResource(path: String): DrawableResource = DrawableResource(
id = "DrawableResource:$path",
items = setOf(ResourceItem(emptySet(), path))
)
/**
* Retrieves a [Painter] using the specified image resource.
* Retrieves a [Painter] using the specified drawable resource.
* Automatically select a type of the Painter depending on the file extension.
*
* @param resource The image resource to be used.
* @param resource The drawable resource to be used.
* @return The [Painter] loaded from the resource.
*/
@ExperimentalResourceApi
@Composable
fun painterResource(resource: ImageResource): Painter {
val filePath = remember(resource) { resource.getPathByEnvironment() }
fun painterResource(resource: DrawableResource): Painter {
val environment = rememberEnvironment()
val filePath = remember(resource, environment) { resource.getPathByEnvironment(environment) }
val isXml = filePath.endsWith(".xml", true)
if (isXml) {
return rememberVectorPainter(vectorResource(resource))
@ -62,17 +57,17 @@ fun painterResource(resource: ImageResource): Painter {
private val emptyImageBitmap: ImageBitmap by lazy { ImageBitmap(1, 1) }
/**
* Retrieves an ImageBitmap using the specified image resource.
* Retrieves an ImageBitmap using the specified drawable resource.
*
* @param resource The image resource to be used.
* @param resource The drawable resource to be used.
* @return The ImageBitmap loaded from the resource.
*/
@ExperimentalResourceApi
@Composable
fun imageResource(resource: ImageResource): ImageBitmap {
fun imageResource(resource: DrawableResource): ImageBitmap {
val resourceReader = LocalResourceReader.current
val imageBitmap by rememberState(resource, { emptyImageBitmap }) {
val path = resource.getPathByEnvironment()
val imageBitmap by rememberResourceState(resource, { emptyImageBitmap }) { env ->
val path = resource.getPathByEnvironment(env)
val cached = loadImage(path, resourceReader) {
ImageCache.Bitmap(it.toImageBitmap())
} as ImageCache.Bitmap
@ -86,18 +81,18 @@ private val emptyImageVector: ImageVector by lazy {
}
/**
* Retrieves an ImageVector using the specified image resource.
* Retrieves an ImageVector using the specified drawable resource.
*
* @param resource The image resource to be used.
* @param resource The drawable resource to be used.
* @return The ImageVector loaded from the resource.
*/
@ExperimentalResourceApi
@Composable
fun vectorResource(resource: ImageResource): ImageVector {
fun vectorResource(resource: DrawableResource): ImageVector {
val resourceReader = LocalResourceReader.current
val density = LocalDensity.current
val imageVector by rememberState(resource, { emptyImageVector }) {
val path = resource.getPathByEnvironment()
val imageVector by rememberResourceState(resource, { emptyImageVector }) { env ->
val path = resource.getPathByEnvironment(env)
val cached = loadImage(path, resourceReader) {
ImageCache.Vector(it.toXmlElement().toImageVector(density))
} as ImageCache.Vector

50
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/Qualifier.kt

@ -0,0 +1,50 @@
package org.jetbrains.compose.resources
interface Qualifier
data class LanguageQualifier(
val language: String
) : Qualifier
data class RegionQualifier(
val region: String
) : Qualifier
enum class ThemeQualifier : Qualifier {
LIGHT,
DARK;
companion object {
fun selectByValue(isDark: Boolean) =
if (isDark) DARK else LIGHT
}
}
//https://developer.android.com/guide/topics/resources/providing-resources
enum class DensityQualifier(val dpi: Int) : Qualifier {
LDPI(120),
MDPI(160),
HDPI(240),
XHDPI(320),
XXHDPI(480),
XXXHDPI(640);
companion object {
fun selectByValue(dpi: Int) = when {
dpi <= LDPI.dpi -> LDPI
dpi <= MDPI.dpi -> MDPI
dpi <= HDPI.dpi -> HDPI
dpi <= XHDPI.dpi -> XHDPI
dpi <= XXHDPI.dpi -> XXHDPI
else -> XXXHDPI
}
fun selectByDensity(density: Float) = when {
density <= 0.75 -> LDPI
density <= 1.0 -> MDPI
density <= 1.33 -> HDPI
density <= 2.0 -> XHDPI
density <= 3.0 -> XXHDPI
else -> XXXHDPI
}
}
}

7
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/Resource.kt

@ -38,11 +38,6 @@ sealed class Resource(
*/
@Immutable
data class ResourceItem(
internal val qualifiers: Set<String>,
internal val qualifiers: Set<Qualifier>,
internal val path: String
)
internal fun Resource.getPathByEnvironment(): String {
//TODO
return items.first().path
}

73
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.kt

@ -0,0 +1,73 @@
package org.jetbrains.compose.resources
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.intl.Locale
internal data class ResourceEnvironment(
val language: LanguageQualifier,
val region: RegionQualifier,
val theme: ThemeQualifier,
val density: DensityQualifier
)
@Composable
internal fun rememberEnvironment(): ResourceEnvironment {
val composeLocale = Locale.current
val composeTheme = isSystemInDarkTheme()
val composeDensity = LocalDensity.current
//cache ResourceEnvironment unless compose environment is changed
return remember(composeLocale, composeTheme, composeDensity) {
ResourceEnvironment(
LanguageQualifier(composeLocale.language),
RegionQualifier(composeLocale.region),
ThemeQualifier.selectByValue(composeTheme),
DensityQualifier.selectByDensity(composeDensity.density)
)
}
}
/**
* Provides the resource environment for non-composable access to string resources.
* It is an expensive operation! Don't use it in composable functions with no cache!
*/
internal expect fun getResourceEnvironment(): ResourceEnvironment
internal fun Resource.getPathByEnvironment(environment: ResourceEnvironment): String {
//Priority of environments: https://developer.android.com/guide/topics/resources/providing-resources#table2
items.toList()
.filterBy(environment.language)
.also { if (it.size == 1) return it.first().path }
.filterBy(environment.region)
.also { if (it.size == 1) return it.first().path }
.filterBy(environment.theme)
.also { if (it.size == 1) return it.first().path }
.filterBy(environment.density)
.also { if (it.size == 1) return it.first().path }
.let { items ->
if (items.isEmpty()) {
error("Resource with ID='$id' not found")
} else {
error("Resource with ID='$id' has more than one file: ${items.joinToString { it.path }}")
}
}
}
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
//filter items with the requested qualifier
val withQualifier = filter { item ->
item.qualifiers.any { it == qualifier }
}
if (withQualifier.isNotEmpty()) return withQualifier
//items with no requested qualifier type (default)
return filter { item ->
item.qualifiers.none { it::class == qualifier::class }
}
}

4
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/PlatformState.kt → components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceState.kt

@ -9,8 +9,8 @@ import androidx.compose.runtime.State
* On the JS platform it loads the state asynchronously and uses `getDefault` as an initial state value.
*/
@Composable
internal expect fun <T> rememberState(
internal expect fun <T> rememberResourceState(
key: Any,
getDefault: () -> T,
block: suspend () -> T
block: suspend (ResourceEnvironment) -> T
): State<T>

67
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResources.kt

@ -1,12 +1,7 @@
package org.jetbrains.compose.resources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import androidx.compose.runtime.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jetbrains.compose.resources.vector.xmldom.Element
@ -76,9 +71,11 @@ private suspend fun parseStringXml(path: String, resourceReader: ResourceReader)
*/
@ExperimentalResourceApi
@Composable
fun getString(resource: StringResource): String {
fun stringResource(resource: StringResource): String {
val resourceReader = LocalResourceReader.current
val str by rememberState(resource, { "" }) { loadString(resource, resourceReader) }
val str by rememberResourceState(resource, { "" }) { env ->
loadString(resource, resourceReader, env)
}
return str
}
@ -91,10 +88,15 @@ fun getString(resource: StringResource): String {
* @throws IllegalArgumentException If the provided ID is not found in the resource file.
*/
@ExperimentalResourceApi
suspend fun loadString(resource: StringResource): String = loadString(resource, DefaultResourceReader)
private suspend fun loadString(resource: StringResource, resourceReader: ResourceReader): String {
val path = resource.getPathByEnvironment()
suspend fun getString(resource: StringResource): String =
loadString(resource, DefaultResourceReader, getResourceEnvironment())
private suspend fun loadString(
resource: StringResource,
resourceReader: ResourceReader,
environment: ResourceEnvironment
): String {
val path = resource.getPathByEnvironment(environment)
val keyToValue = getParsedStrings(path, resourceReader)
val item = keyToValue[resource.key] as? StringItem.Value
?: error("String ID=`${resource.key}` is not found!")
@ -112,10 +114,12 @@ private suspend fun loadString(resource: StringResource, resourceReader: Resourc
*/
@ExperimentalResourceApi
@Composable
fun getString(resource: StringResource, vararg formatArgs: Any): String {
fun stringResource(resource: StringResource, vararg formatArgs: Any): String {
val resourceReader = LocalResourceReader.current
val args = formatArgs.map { it.toString() }
val str by rememberState(resource, { "" }) { loadString(resource, args, resourceReader) }
val str by rememberResourceState(resource, { "" }) { env ->
loadString(resource, args, resourceReader, env)
}
return str
}
@ -129,14 +133,20 @@ fun getString(resource: StringResource, vararg formatArgs: Any): String {
* @throws IllegalArgumentException If the provided ID is not found in the resource file.
*/
@ExperimentalResourceApi
suspend fun loadString(resource: StringResource, vararg formatArgs: Any): String = loadString(
suspend fun getString(resource: StringResource, vararg formatArgs: Any): String = loadString(
resource,
formatArgs.map { it.toString() },
DefaultResourceReader
DefaultResourceReader,
getResourceEnvironment()
)
private suspend fun loadString(resource: StringResource, args: List<String>, resourceReader: ResourceReader): String {
val str = loadString(resource, resourceReader)
private suspend fun loadString(
resource: StringResource,
args: List<String>,
resourceReader: ResourceReader,
environment: ResourceEnvironment
): String {
val str = loadString(resource, resourceReader, environment)
return SimpleStringFormatRegex.replace(str) { matchResult ->
args[matchResult.groupValues[1].toInt() - 1]
}
@ -152,9 +162,11 @@ private suspend fun loadString(resource: StringResource, args: List<String>, res
*/
@ExperimentalResourceApi
@Composable
fun getStringArray(resource: StringResource): List<String> {
fun stringArrayResource(resource: StringResource): List<String> {
val resourceReader = LocalResourceReader.current
val array by rememberState(resource, { emptyList() }) { loadStringArray(resource, resourceReader) }
val array by rememberResourceState(resource, { emptyList() }) { env ->
loadStringArray(resource, resourceReader, env)
}
return array
}
@ -167,10 +179,15 @@ fun getStringArray(resource: StringResource): List<String> {
* @throws IllegalStateException if the string array with the given ID is not found.
*/
@ExperimentalResourceApi
suspend fun loadStringArray(resource: StringResource): List<String> = loadStringArray(resource, DefaultResourceReader)
private suspend fun loadStringArray(resource: StringResource, resourceReader: ResourceReader): List<String> {
val path = resource.getPathByEnvironment()
suspend fun getStringArray(resource: StringResource): List<String> =
loadStringArray(resource, DefaultResourceReader, getResourceEnvironment())
private suspend fun loadStringArray(
resource: StringResource,
resourceReader: ResourceReader,
environment: ResourceEnvironment
): List<String> {
val path = resource.getPathByEnvironment(environment)
val keyToValue = getParsedStrings(path, resourceReader)
val item = keyToValue[resource.key] as? StringItem.Array
?: error("String array ID=`${resource.key}` is not found!")

118
components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceTest.kt

@ -5,62 +5,98 @@
package org.jetbrains.compose.resources
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotEquals
import org.jetbrains.compose.resources.DensityQualifier.*
import org.jetbrains.compose.resources.ThemeQualifier.DARK
import org.jetbrains.compose.resources.ThemeQualifier.LIGHT
import kotlin.test.*
@OptIn(ExperimentalResourceApi::class)
class ResourceTest {
@Test
fun testResourceEquals() = runBlockingTest {
assertEquals(ImageResource("a"), ImageResource("a"))
assertEquals(DrawableResource("a"), DrawableResource("a"))
}
@Test
fun testResourceNotEquals() = runBlockingTest {
assertNotEquals(ImageResource("a"), ImageResource("b"))
assertNotEquals(DrawableResource("a"), DrawableResource("b"))
}
@Test
fun testMissingResource() = runBlockingTest {
assertFailsWith<MissingResourceException> {
readResourceBytes("missing.png")
}
val error = assertFailsWith<IllegalStateException> {
loadString(TestStringResource("unknown_id"))
}
assertEquals("String ID=`unknown_id` is not found!", error.message)
}
@Test
fun testReadFileResource() = runBlockingTest {
val bytes = readResourceBytes("strings.xml")
fun testGetPathByEnvironment() {
val resource = DrawableResource(
id = "ImageResource:test",
items = setOf(
ResourceItem(setOf(), "default"),
ResourceItem(setOf(LanguageQualifier("en")), "en"),
ResourceItem(setOf(LanguageQualifier("en"), RegionQualifier("US"), XHDPI), "en-rUS-xhdpi"),
ResourceItem(setOf(LanguageQualifier("fr"), LIGHT), "fr-light"),
ResourceItem(setOf(DARK), "dark"),
)
)
fun env(lang: String, reg: String, theme: ThemeQualifier, density: DensityQualifier) = ResourceEnvironment(
language = LanguageQualifier(lang),
region = RegionQualifier(reg),
theme = theme,
density = density
)
assertEquals(
"en-rUS-xhdpi",
resource.getPathByEnvironment(env("en", "US", DARK, XXHDPI))
)
assertEquals(
"en",
resource.getPathByEnvironment(env("en", "IN", LIGHT, LDPI))
)
assertEquals(
"default",
resource.getPathByEnvironment(env("ch", "", LIGHT, MDPI))
)
assertEquals(
"dark",
resource.getPathByEnvironment(env("ch", "", DARK, MDPI))
)
assertEquals(
"fr-light",
resource.getPathByEnvironment(env("fr", "", DARK, MDPI))
)
assertEquals(
"fr-light",
resource.getPathByEnvironment(env("fr", "IN", LIGHT, MDPI))
)
assertEquals(
"default",
resource.getPathByEnvironment(env("ru", "US", LIGHT, XHDPI))
)
assertEquals(
"""
<resources>
<string name="app_name">Compose Resources App</string>
<string name="hello">😊 Hello world!</string>
<string name="str_template">Hello, %1${'$'}s! You have %2${'$'}d new messages.</string>
<string-array name="str_arr">
<item>item 1</item>
<item>item 2</item>
<item>item 3</item>
</string-array>
</resources>
"dark",
resource.getPathByEnvironment(env("ru", "US", DARK, XHDPI))
)
""".trimIndent(),
bytes.decodeToString()
val resourceWithNoDefault = DrawableResource(
id = "ImageResource:test2",
items = setOf(
ResourceItem(setOf(LanguageQualifier("en")), "en"),
ResourceItem(setOf(LanguageQualifier("fr"), LIGHT), "fr-light")
)
)
}
assertFailsWith<IllegalStateException> {
resourceWithNoDefault.getPathByEnvironment(env("ru", "US", DARK, XHDPI))
}.message.let { msg ->
assertEquals("Resource with ID='ImageResource:test2' not found", msg)
}
@Test
fun testLoadStringResource() = runBlockingTest {
assertEquals("Compose Resources App", loadString(TestStringResource("app_name")))
assertEquals(
"Hello, test-name! You have 42 new messages.",
loadString(TestStringResource("str_template"), "test-name", 42)
val resourceWithFewFiles = DrawableResource(
id = "ImageResource:test3",
items = setOf(
ResourceItem(setOf(LanguageQualifier("en")), "en1"),
ResourceItem(setOf(LanguageQualifier("en")), "en2")
)
)
assertEquals(listOf("item 1", "item 2", "item 3"), loadStringArray(TestStringResource("str_arr")))
assertFailsWith<IllegalStateException> {
resourceWithFewFiles.getPathByEnvironment(env("en", "US", DARK, XHDPI))
}.message.let { msg ->
assertEquals("Resource with ID='ImageResource:test3' has more than one file: en1, en2", msg)
}
}
}

19
components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.desktop.kt

@ -0,0 +1,19 @@
package org.jetbrains.compose.resources
import org.jetbrains.skiko.SystemTheme
import org.jetbrains.skiko.currentSystemTheme
import java.awt.Toolkit
import java.util.*
internal actual fun getResourceEnvironment(): ResourceEnvironment {
val locale = Locale.getDefault()
//FIXME: don't use skiko internals
val isDarkTheme = currentSystemTheme == SystemTheme.DARK
val dpi = Toolkit.getDefaultToolkit().screenResolution
return ResourceEnvironment(
language = LanguageQualifier(locale.language),
region = RegionQualifier(locale.country),
theme = ThemeQualifier.selectByValue(isDarkTheme),
density = DensityQualifier.selectByValue(dpi)
)
}

67
components/resources/library/src/desktopTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.desktop.kt

@ -2,9 +2,7 @@ package org.jetbrains.compose.resources
import androidx.compose.foundation.Image
import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.*
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import kotlinx.coroutines.flow.MutableStateFlow
@ -12,6 +10,7 @@ import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@OptIn(ExperimentalResourceApi::class, ExperimentalTestApi::class)
class ComposeResourceTest {
@ -25,7 +24,7 @@ class ComposeResourceTest {
@Test
fun testCountRecompositions() = runComposeUiTest {
runBlockingTest {
val imagePathFlow = MutableStateFlow(ImageResource("1.png"))
val imagePathFlow = MutableStateFlow(DrawableResource("1.png"))
val recompositionsCounter = RecompositionsCounter()
setContent {
val res by imagePathFlow.collectAsState()
@ -35,7 +34,7 @@ class ComposeResourceTest {
}
}
awaitIdle()
imagePathFlow.emit(ImageResource("2.png"))
imagePathFlow.emit(DrawableResource("2.png"))
awaitIdle()
assertEquals(2, recompositionsCounter.count)
}
@ -45,7 +44,7 @@ class ComposeResourceTest {
fun testImageResourceCache() = runComposeUiTest {
runBlockingTest {
val testResourceReader = TestResourceReader()
val imagePathFlow = MutableStateFlow(ImageResource("1.png"))
val imagePathFlow = MutableStateFlow(DrawableResource("1.png"))
setContent {
CompositionLocalProvider(LocalResourceReader provides testResourceReader) {
val res by imagePathFlow.collectAsState()
@ -53,9 +52,9 @@ class ComposeResourceTest {
}
}
awaitIdle()
imagePathFlow.emit(ImageResource("2.png"))
imagePathFlow.emit(DrawableResource("2.png"))
awaitIdle()
imagePathFlow.emit(ImageResource("1.png"))
imagePathFlow.emit(DrawableResource("1.png"))
awaitIdle()
assertEquals(
@ -73,8 +72,8 @@ class ComposeResourceTest {
setContent {
CompositionLocalProvider(LocalResourceReader provides testResourceReader) {
val res by stringIdFlow.collectAsState()
Text(getString(res))
Text(getStringArray(TestStringResource("str_arr")).joinToString())
Text(stringResource(res))
Text(stringArrayResource(TestStringResource("str_arr")).joinToString())
}
}
awaitIdle()
@ -94,14 +93,56 @@ class ComposeResourceTest {
fun testReadStringResource() = runComposeUiTest {
runBlockingTest {
setContent {
assertEquals("Compose Resources App", getString(TestStringResource("app_name")))
assertEquals("Compose Resources App", stringResource(TestStringResource("app_name")))
assertEquals(
"Hello, test-name! You have 42 new messages.",
getString(TestStringResource("str_template"), "test-name", 42)
stringResource(TestStringResource("str_template"), "test-name", 42)
)
assertEquals(listOf("item 1", "item 2", "item 3"), getStringArray(TestStringResource("str_arr")))
assertEquals(listOf("item 1", "item 2", "item 3"), stringArrayResource(TestStringResource("str_arr")))
}
awaitIdle()
}
}
@Test
fun testLoadStringResource() = runBlockingTest {
kotlin.test.assertEquals("Compose Resources App", getString(TestStringResource("app_name")))
kotlin.test.assertEquals(
"Hello, test-name! You have 42 new messages.",
getString(TestStringResource("str_template"), "test-name", 42)
)
kotlin.test.assertEquals(listOf("item 1", "item 2", "item 3"), getStringArray(TestStringResource("str_arr")))
}
@Test
fun testMissingResource() = runBlockingTest {
assertFailsWith<MissingResourceException> {
readResourceBytes("missing.png")
}
val error = assertFailsWith<IllegalStateException> {
getString(TestStringResource("unknown_id"))
}
kotlin.test.assertEquals("String ID=`unknown_id` is not found!", error.message)
}
@Test
fun testReadFileResource() = runBlockingTest {
val bytes = readResourceBytes("strings.xml")
kotlin.test.assertEquals(
"""
<resources>
<string name="app_name">Compose Resources App</string>
<string name="hello">😊 Hello world!</string>
<string name="str_template">Hello, %1${'$'}s! You have %2${'$'}d new messages.</string>
<string-array name="str_arr">
<item>item 1</item>
<item>item 2</item>
<item>item 3</item>
</string-array>
</resources>
""".trimIndent(),
bytes.decodeToString()
)
}
}

21
components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.ios.kt

@ -0,0 +1,21 @@
package org.jetbrains.compose.resources
import platform.Foundation.*
import platform.UIKit.UIScreen
import platform.UIKit.UIUserInterfaceStyle
internal actual fun getResourceEnvironment(): ResourceEnvironment {
val locale = NSLocale.currentLocale()
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(locale.languageCode),
region = RegionQualifier(locale.regionCode.orEmpty()),
theme = ThemeQualifier.selectByValue(isDarkTheme),
density = DensityQualifier.selectByDensity(density)
)
}

4
components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ImageResources.js.kt

@ -1,8 +1,6 @@
package org.jetbrains.compose.resources
import org.jetbrains.compose.resources.vector.xmldom.Element
import org.jetbrains.compose.resources.vector.xmldom.ElementImpl
import org.jetbrains.compose.resources.vector.xmldom.MalformedXMLException
import org.jetbrains.compose.resources.vector.xmldom.*
import org.w3c.dom.parsing.DOMParser
internal actual fun ByteArray.toXmlElement(): Element {

23
components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.js.kt

@ -0,0 +1,23 @@
package org.jetbrains.compose.resources
import kotlinx.browser.window
private external class Intl {
class Locale(locale: String) {
val language: String
val region: String
}
}
internal actual fun getResourceEnvironment(): 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),
region = RegionQualifier(locale.region),
theme = ThemeQualifier.selectByValue(isDarkTheme),
density = DensityQualifier.selectByValue(dpi)
)
}

32
components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.macos.kt

@ -0,0 +1,32 @@
package org.jetbrains.compose.resources
import kotlinx.cinterop.*
import platform.AppKit.NSScreen
import platform.CoreGraphics.CGDisplayPixelsWide
import platform.CoreGraphics.CGDisplayScreenSize
import platform.Foundation.*
internal actual fun getResourceEnvironment(): ResourceEnvironment {
val locale = NSLocale.currentLocale()
val isDarkTheme = NSUserDefaults.standardUserDefaults.stringForKey("AppleInterfaceStyle") == "Dark"
val dpi = NSScreen.mainScreen?.let { screen ->
val backingScaleFactor = screen.backingScaleFactor
val screenNumber = interpretObjCPointer<NSNumber>(
screen.deviceDescription["NSScreenNumber"].objcPtr()
).unsignedIntValue
val displaySizePX = CGDisplayPixelsWide(screenNumber).toFloat() * backingScaleFactor
val displaySizeMM = CGDisplayScreenSize(screenNumber).useContents { width }
//1 inch = 25.4 mm
((displaySizePX / displaySizeMM) * 25.4f).toInt()
} ?: 0
return ResourceEnvironment(
language = LanguageQualifier(locale.languageCode),
region = RegionQualifier(locale.regionCode.orEmpty()),
theme = ThemeQualifier.selectByValue(isDarkTheme),
density = DensityQualifier.selectByValue(dpi)
)
}

8
components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt

@ -2,9 +2,7 @@ package org.jetbrains.compose.resources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.platform.Font
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@ -35,8 +33,8 @@ private val defaultEmptyFont by lazy { Font("org.jetbrains.compose.emptyFont", B
@Composable
actual fun Font(resource: FontResource, weight: FontWeight, style: FontStyle): Font {
val resourceReader = LocalResourceReader.current
val fontFile by rememberState(resource, { defaultEmptyFont }) {
val path = resource.getPathByEnvironment()
val fontFile by rememberResourceState(resource, { defaultEmptyFont }) { env ->
val path = resource.getPathByEnvironment(env)
val fontBytes = resourceReader.read(path)
Font(path, fontBytes, weight, style)
}

23
components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.wasmJs.kt

@ -0,0 +1,23 @@
package org.jetbrains.compose.resources
import kotlinx.browser.window
private external class Intl {
class Locale(locale: String) {
val language: String
val region: String
}
}
internal actual fun getResourceEnvironment(): 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),
region = RegionQualifier(locale.region),
theme = ThemeQualifier.selectByValue(isDarkTheme),
density = DensityQualifier.selectByValue(dpi)
)
}

9
components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/PlatformState.web.kt → components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceState.web.kt

@ -7,10 +7,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@Composable
internal actual fun <T> rememberState(key: Any, getDefault: () -> T, block: suspend () -> T): State<T> {
internal actual fun <T> rememberResourceState(
key: Any,
getDefault: () -> T,
block: suspend (ResourceEnvironment) -> T
): State<T> {
val environment = rememberEnvironment()
val state = remember(key) { mutableStateOf(getDefault()) }
LaunchedEffect(key) {
state.value = block()
state.value = block(environment)
}
return state
}

2
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/AndroidTargetConfiguration.kt

@ -25,7 +25,7 @@ internal fun Project.configureAndroidResources(
val copyFonts = registerTask<Copy>("copyFontsToAndroidAssets") {
includeEmptyDirs = false
from(commonResourcesDir)
include("**/fonts/*")
include("**/font*/*")
into(androidFontsDir)
onlyIf { onlyIfProvider.get() }
}

4
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/GenerateResClassTask.kt

@ -63,11 +63,11 @@ abstract class GenerateResClassTask : DefaultTask() {
val file = this
if (file.isDirectory) return null
val dirName = file.parentFile.name ?: return null
val typeAndQualifiers = dirName.lowercase().split("-")
val typeAndQualifiers = dirName.split("-")
if (typeAndQualifiers.isEmpty()) return null
val typeString = typeAndQualifiers.first().lowercase()
val qualifiers = typeAndQualifiers.takeLast(typeAndQualifiers.size - 1).map { it.lowercase() }.toSet()
val qualifiers = typeAndQualifiers.takeLast(typeAndQualifiers.size - 1)
val path = file.toPath().relativeTo(relativeTo)
return if (typeString == "values" && file.name.equals("strings.xml", true)) {

105
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt

@ -1,44 +1,102 @@
package org.jetbrains.compose.resources
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.CodeBlock
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.KModifier
import com.squareup.kotlinpoet.PropertySpec
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.withIndent
import com.squareup.kotlinpoet.*
import java.nio.file.Path
import kotlin.io.path.invariantSeparatorsPathString
import kotlin.io.path.pathString
internal enum class ResourceType(val typeName: String) {
IMAGE("images"),
STRING("strings"),
FONT("fonts");
DRAWABLE("drawable"),
STRING("string"),
FONT("font");
override fun toString(): String = typeName
companion object {
fun fromString(str: String) = when (str) {
"images" -> ResourceType.IMAGE
"strings" -> ResourceType.STRING
"fonts" -> ResourceType.FONT
else -> error("Unknown resource type: $str")
}
fun fromString(str: String): ResourceType =
ResourceType.values()
.firstOrNull { it.typeName.equals(str, true) }
?: error("Unknown resource type: '$str'.")
}
}
internal data class ResourceItem(
val type: ResourceType,
val qualifiers: Set<String>,
val qualifiers: List<String>,
val name: String,
val path: Path
)
private fun ResourceItem.getClassName(): ClassName = when (type) {
ResourceType.IMAGE -> ClassName("org.jetbrains.compose.resources", "ImageResource")
ResourceType.DRAWABLE -> ClassName("org.jetbrains.compose.resources", "DrawableResource")
ResourceType.STRING -> ClassName("org.jetbrains.compose.resources", "StringResource")
ResourceType.FONT -> ClassName("org.jetbrains.compose.resources", "FontResource")
}
private fun CodeBlock.Builder.addQualifiers(resourceItem: ResourceItem): CodeBlock.Builder {
val languageQualifier = ClassName("org.jetbrains.compose.resources", "LanguageQualifier")
val regionQualifier = ClassName("org.jetbrains.compose.resources", "RegionQualifier")
val themeQualifier = ClassName("org.jetbrains.compose.resources", "ThemeQualifier")
val densityQualifier = ClassName("org.jetbrains.compose.resources", "DensityQualifier")
val languageRegex = Regex("[a-z][a-z]")
val regionRegex = Regex("r[A-Z][A-Z]")
val qualifiersMap = mutableMapOf<ClassName, String>()
fun saveQualifier(className: ClassName, qualifier: String) {
qualifiersMap[className]?.let {
error("${resourceItem.path} contains repetitive qualifiers: '$it' and '$qualifier'.")
}
qualifiersMap[className] = qualifier
}
resourceItem.qualifiers.forEach { q ->
when (q) {
"light",
"dark" -> {
saveQualifier(themeQualifier, q)
}
"mdpi",
"hdpi",
"xhdpi",
"xxhdpi",
"xxxhdpi",
"ldpi" -> {
saveQualifier(densityQualifier, q)
}
else -> when {
q.matches(languageRegex) -> {
saveQualifier(languageQualifier, q)
}
q.matches(regionRegex) -> {
saveQualifier(regionQualifier, q)
}
else -> error("${resourceItem.path} contains unknown qualifier: '$q'.")
}
}
}
qualifiersMap[themeQualifier]?.let { q -> add("%T.${q.uppercase()}, ", themeQualifier) }
qualifiersMap[densityQualifier]?.let { q -> add("%T.${q.uppercase()}, ", densityQualifier) }
qualifiersMap[languageQualifier]?.let { q -> add("%T(\"$q\"), ", languageQualifier) }
qualifiersMap[regionQualifier]?.let { q ->
val lang = qualifiersMap[languageQualifier]
if (lang == null) {
error("Region qualifier must be used only with language.\nFile: ${resourceItem.path}")
}
val langAndRegion = "$lang-$q"
if(!resourceItem.path.toString().contains("-$langAndRegion")) {
error("Region qualifier must be declared after language: '$langAndRegion'.\nFile: ${resourceItem.path}")
}
add("%T(\"${q.takeLast(2)}\"), ", regionQualifier)
}
return this
}
internal fun getResFileSpec(
//type -> id -> items
resources: Map<ResourceType, Map<String, List<ResourceItem>>>,
@ -77,9 +135,12 @@ private fun TypeSpec.Builder.addResourceProperty(name: String, items: List<Resou
}
add("setOf(\n").withIndent {
items.forEach { item ->
val qualifiers = item.qualifiers.sorted().joinToString { "\"$it\"" }
//file separator should be '/' on all platforms
add("%T(setOf($qualifiers), \"${item.path.invariantSeparatorsPathString}\"),\n", resourceItemClass)
add("%T(\n", resourceItemClass).withIndent {
add("setOf(").addQualifiers(item).add("),\n")
//file separator should be '/' on all platforms
add("\"${item.path.invariantSeparatorsPathString}\"\n")
}
add("),\n")
}
}
add(")\n")

54
gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/ResourcesTest.kt

@ -17,12 +17,12 @@ class ResourcesTest : GradlePluginTestBase() {
file("expected/Res.kt")
)
check.logContains("""
Unknown resource type: ignored
Unknown resource type: 'ignored'.
""".trimIndent())
}
file("src/commonMain/resources/composeRes/images/vector_2.xml").renameTo(
file("src/commonMain/resources/composeRes/images/vector_3.xml")
file("src/commonMain/resources/composeRes/drawable/vector_2.xml").renameTo(
file("src/commonMain/resources/composeRes/drawable/vector_3.xml")
)
//check resource's accessors were regenerated
@ -33,8 +33,52 @@ class ResourcesTest : GradlePluginTestBase() {
)
}
file("src/commonMain/resources/composeRes/images/vector_3.xml").renameTo(
file("src/commonMain/resources/composeRes/images/vector_2.xml")
file("src/commonMain/resources/composeRes/drawable-en").renameTo(
file("src/commonMain/resources/composeRes/drawable-ren")
)
gradle("generateComposeResClass").checks {
check.logContains("""
contains unknown qualifier: 'ren'.
""".trimIndent())
}
file("src/commonMain/resources/composeRes/drawable-ren").renameTo(
file("src/commonMain/resources/composeRes/drawable-rUS-en")
)
gradle("generateComposeResClass").checks {
check.logContains("""
Region qualifier must be declared after language: 'en-rUS'.
""".trimIndent())
}
file("src/commonMain/resources/composeRes/drawable-rUS-en").renameTo(
file("src/commonMain/resources/composeRes/drawable-rUS")
)
gradle("generateComposeResClass").checks {
check.logContains("""
Region qualifier must be used only with language.
""".trimIndent())
}
file("src/commonMain/resources/composeRes/drawable-rUS").renameTo(
file("src/commonMain/resources/composeRes/drawable-en-fr")
)
gradle("generateComposeResClass").checks {
check.logContains("""
contains repetitive qualifiers: 'en' and 'fr'.
""".trimIndent())
}
file("src/commonMain/resources/composeRes/drawable-en-fr").renameTo(
file("src/commonMain/resources/composeRes/drawable-en")
)
file("src/commonMain/resources/composeRes/drawable/vector_3.xml").renameTo(
file("src/commonMain/resources/composeRes/drawable/vector_2.xml")
)
//TODO: check a real build after a release a new version of the resources library

99
gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected/Res.kt

@ -1,84 +1,123 @@
package app.group.generated.resources
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.FontResource
import org.jetbrains.compose.resources.ImageResource
import org.jetbrains.compose.resources.LanguageQualifier
import org.jetbrains.compose.resources.RegionQualifier
import org.jetbrains.compose.resources.ResourceItem
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.ThemeQualifier
internal object Res {
public object fonts {
public val emptyfont: FontResource = FontResource(
"FONT:emptyfont",
public object drawable {
public val _3_strange_name: DrawableResource = DrawableResource(
"drawable:_3_strange_name",
setOf(
ResourceItem(setOf(), "composeRes/fonts/emptyFont.otf"),
ResourceItem(
setOf(),
"composeRes/drawable/3-strange-name.xml"
),
)
)
}
public object images {
public val _3_strange_name: ImageResource = ImageResource(
"IMAGE:_3_strange_name",
public val vector: DrawableResource = DrawableResource(
"drawable:vector",
setOf(
ResourceItem(setOf(), "composeRes/images/3-strange-name.xml"),
ResourceItem(
setOf(LanguageQualifier("au"), RegionQualifier("US"), ),
"composeRes/drawable-au-rUS/vector.xml"
),
ResourceItem(
setOf(ThemeQualifier.DARK, LanguageQualifier("ge"), ),
"composeRes/drawable-dark-ge/vector.xml"
),
ResourceItem(
setOf(LanguageQualifier("en"), ),
"composeRes/drawable-en/vector.xml"
),
ResourceItem(
setOf(),
"composeRes/drawable/vector.xml"
),
)
)
public val vector: ImageResource = ImageResource(
"IMAGE:vector",
public val vector_2: DrawableResource = DrawableResource(
"drawable:vector_2",
setOf(
ResourceItem(setOf("q1", "q2"), "composeRes/images-q1-q2/vector.xml"),
ResourceItem(setOf("q1"), "composeRes/images-q1/vector.xml"),
ResourceItem(setOf("q2"), "composeRes/images-q2/vector.xml"),
ResourceItem(setOf(), "composeRes/images/vector.xml"),
ResourceItem(
setOf(),
"composeRes/drawable/vector_2.xml"
),
)
)
}
public val vector_2: ImageResource = ImageResource(
"IMAGE:vector_2",
public object font {
public val emptyfont: FontResource = FontResource(
"font:emptyfont",
setOf(
ResourceItem(setOf(), "composeRes/images/vector_2.xml"),
ResourceItem(
setOf(),
"composeRes/font/emptyFont.otf"
),
)
)
}
public object strings {
public object string {
public val app_name: StringResource = StringResource(
"STRING:app_name",
"string:app_name",
"app_name",
setOf(
ResourceItem(setOf(), "composeRes/values/strings.xml"),
ResourceItem(
setOf(),
"composeRes/values/strings.xml"
),
)
)
public val hello: StringResource = StringResource(
"STRING:hello",
"string:hello",
"hello",
setOf(
ResourceItem(setOf(), "composeRes/values/strings.xml"),
ResourceItem(
setOf(),
"composeRes/values/strings.xml"
),
)
)
public val multi_line: StringResource = StringResource(
"STRING:multi_line",
"string:multi_line",
"multi_line",
setOf(
ResourceItem(setOf(), "composeRes/values/strings.xml"),
ResourceItem(
setOf(),
"composeRes/values/strings.xml"
),
)
)
public val str_arr: StringResource = StringResource(
"STRING:str_arr",
"string:str_arr",
"str_arr",
setOf(
ResourceItem(setOf(), "composeRes/values/strings.xml"),
ResourceItem(
setOf(),
"composeRes/values/strings.xml"
),
)
)
public val str_template: StringResource = StringResource(
"STRING:str_template",
"string:str_template",
"str_template",
setOf(
ResourceItem(setOf(), "composeRes/values/strings.xml"),
ResourceItem(
setOf(),
"composeRes/values/strings.xml"
),
)
)
}

0
gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/images-q1-q2/vector.xml → gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/drawable-au-rUS/vector.xml

0
gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/images-q1/vector.xml → gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/drawable-dark-ge/vector.xml

0
gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/images-q2/vector.xml → gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/drawable-en/vector.xml

0
gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/images/3-strange-name.xml → gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/drawable/3-strange-name.xml

0
gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/images/vector.xml → gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/drawable/vector.xml

0
gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/images/vector_2.xml → gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/drawable/vector_2.xml

0
gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/fonts/emptyFont.otf → gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/resources/composeRes/font/emptyFont.otf

Loading…
Cancel
Save