Browse Source

XML resource optimizations (#4559)

Users noticed if an app has big a `string.xml` file it affects the app
startup time:
https://github.com/JetBrains/compose-multiplatform/issues/4537

The problem is slow XML parsing.

Possible ways for optimization:
 1) inject text resources direct to the source code
 2) convert XMLs to an optimized format to read it faster

We selected the second way because texts injected to source code have
several problems:
 - strict limitations on text size
 - increase compilation and analysation time
 - affects a class loader and GC

> Note: android resources do the same and converts xml values to own
`resources.arsc` file

Things was done in the PR:
 1) added support any XML files in the `values` directory
2) **[BREAKING CHANGE]** added `Res.array` accessor for string-array
resources
3) in a final app there won't be original `values*/*.xml` files. There
will be converted `values*/*.cvr` files.
 4) generated code points on string resources as file -> offset+size
5) string resource cache is by item now (it was by the full xml file
before)
 6) implemented random access to read CVR files
7) tasks for syncing ios resources to a final app were seriously
refactored to support generated resources (CVR files)
 8) restriction for 3-party resources plugin were deleted
9) Gradle property `compose.resources.always.generate.accessors` was
deleted. It was for internal needs only.

Fixes https://github.com/JetBrains/compose-multiplatform/issues/4537
pull/4577/head v1.6.10-dev1559
Konstantin 8 months ago committed by GitHub
parent
commit
5d9dfde149
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      components/resources/demo/shared/build.gradle.kts
  2. 1
      components/resources/demo/shared/gradle.properties
  3. 25
      components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/StringRes.kt
  4. 1
      components/resources/library/build.gradle.kts
  5. 2
      components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/FontResources.android.kt
  6. 1
      components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.android.kt
  7. 39
      components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ResourceReader.android.kt
  8. 4
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/FontResources.kt
  9. 10
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ImageResources.kt
  10. 121
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/PluralStringResources.kt
  11. 6
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/Resource.kt
  12. 13
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.kt
  13. 10
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceReader.kt
  14. 63
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringArrayResources.kt
  15. 273
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResources.kt
  16. 76
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResourcesUtils.kt
  17. 60
      components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt
  18. 46
      components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ResourceTest.kt
  19. 1
      components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/TestComposeEnvironment.kt
  20. 5
      components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/TestResourceReader.kt
  21. 28
      components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/TestUtils.kt
  22. 9
      components/resources/library/src/commonTest/resources/strings.cvr
  23. 23
      components/resources/library/src/commonTest/resources/strings.xml
  24. 1
      components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.desktop.kt
  25. 31
      components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceReader.desktop.kt
  26. 1
      components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.ios.kt
  27. 41
      components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/ResourceReader.ios.kt
  28. 1
      components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.js.kt
  29. 35
      components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.js.kt
  30. 1
      components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.macos.kt
  31. 51
      components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/ResourceReader.macos.kt
  32. 2
      components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt
  33. 1
      components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceEnvironment.wasmJs.kt
  34. 72
      components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt
  35. 14
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/ComposePlugin.kt
  36. 7
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/ComposeProjectProperties.kt
  37. 26
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/experimental/uikit/internal/utils/cocoapodsDslHelpers.kt
  38. 22
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/experimental/uikit/internal/utils/kotlinNativeTargetUtils.kt
  39. 53
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/experimental/uikit/tasks/AbstractComposeIosTask.kt
  40. 100
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/AndroidResources.kt
  41. 110
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ComposeResources.kt
  42. 61
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/GenerateResClassTask.kt
  43. 38
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/GeneratedResClassSpec.kt
  44. 132
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/IosResources.kt
  45. 148
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/IosResourcesTasks.kt
  46. 123
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/KmpResources.kt
  47. 235
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/PrepareComposeResources.kt
  48. 65
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResClassGeneration.kt
  49. 18
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesDSL.kt
  50. 381
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesGenerator.kt
  51. 56
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ios/IosTargetResources.kt
  52. 108
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ios/SyncComposeResourcesForIosTask.kt
  53. 265
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ios/configureSyncIosResources.kt
  54. 38
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ios/determineIosKonanTargetsFromEnv.kt
  55. 22
      gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/GradlePluginTest.kt
  56. 32
      gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/ResourcesTest.kt
  57. 16
      gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/unit/TestEscapedResourceSymbols.kt
  58. 63
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected-open-res/Drawable0.kt
  59. 10
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected-open-res/Font0.kt
  60. 2
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected-open-res/Plurals0.kt
  61. 2
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected-open-res/Res.kt
  62. 87
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected-open-res/String0.kt
  63. 63
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected/Drawable0.kt
  64. 4
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected/Font0.kt
  65. 2
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected/Plurals0.kt
  66. 2
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected/Res.kt
  67. 87
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected/String0.kt
  68. 5
      gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/composeResources/values/strings.xml
  69. 2
      gradle-plugins/compose/src/test/test-projects/misc/emptyResources/expected/Res.kt
  70. 53
      gradle-plugins/compose/src/test/test-projects/misc/iosMokoResources/build.gradle
  71. 1
      gradle-plugins/compose/src/test/test-projects/misc/iosMokoResources/gradle.properties
  72. 27
      gradle-plugins/compose/src/test/test-projects/misc/iosMokoResources/settings.gradle
  73. 10
      gradle-plugins/compose/src/test/test-projects/misc/iosMokoResources/src/commonMain/kotlin/App.kt
  74. 4
      gradle-plugins/compose/src/test/test-projects/misc/jvmOnlyResources/expected/Drawable0.kt
  75. 2
      gradle-plugins/compose/src/test/test-projects/misc/jvmOnlyResources/expected/Res.kt

5
components/resources/demo/shared/build.gradle.kts

@ -83,3 +83,8 @@ android {
compose.experimental {
web.application {}
}
//because the dependency on the compose library is a project dependency
compose.resources {
generateResClass = always
}

1
components/resources/demo/shared/gradle.properties

@ -1 +0,0 @@
compose.resources.always.generate.accessors=true

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

@ -18,27 +18,6 @@ fun StringRes(paddingValues: PaddingValues) {
Column(
modifier = Modifier.padding(paddingValues).verticalScroll(rememberScrollState())
) {
Text(
modifier = Modifier.padding(16.dp),
text = "values/strings.xml",
style = MaterialTheme.typography.titleLarge
)
OutlinedCard(
modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
shape = RoundedCornerShape(4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
var bytes by remember { mutableStateOf(ByteArray(0)) }
LaunchedEffect(Unit) {
bytes = Res.readBytes("values/strings.xml")
}
Text(
modifier = Modifier.padding(8.dp),
text = bytes.decodeToString(),
color = MaterialTheme.colorScheme.onPrimaryContainer,
softWrap = false
)
}
OutlinedTextField(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
value = stringResource(Res.string.app_name),
@ -89,9 +68,9 @@ fun StringRes(paddingValues: PaddingValues) {
)
OutlinedTextField(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
value = stringArrayResource(Res.string.str_arr).toString(),
value = stringArrayResource(Res.array.str_arr).toString(),
onValueChange = {},
label = { Text("Text(stringArrayResource(Res.string.str_arr).toString())") },
label = { Text("Text(stringArrayResource(Res.array.str_arr).toString())") },
enabled = false,
colors = TextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,

1
components/resources/library/build.gradle.kts

@ -52,6 +52,7 @@ kotlin {
optIn("kotlin.RequiresOptIn")
optIn("kotlinx.cinterop.ExperimentalForeignApi")
optIn("kotlin.experimental.ExperimentalNativeApi")
optIn("org.jetbrains.compose.resources.InternalResourceApi")
}
}

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

@ -9,6 +9,6 @@ import androidx.compose.ui.text.font.*
@Composable
actual fun Font(resource: FontResource, weight: FontWeight, style: FontStyle): Font {
val environment = LocalComposeEnvironment.current.rememberEnvironment()
val path = remember(environment) { resource.getPathByEnvironment(environment) }
val path = remember(environment) { resource.getResourceItemByEnvironment(environment).path }
return Font(path, LocalContext.current.assets, weight, style)
}

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

@ -4,7 +4,6 @@ import android.content.res.Configuration
import android.content.res.Resources
import java.util.*
@OptIn(InternalResourceApi::class)
internal actual fun getSystemEnvironment(): ResourceEnvironment {
val locale = Locale.getDefault()
val configuration = Resources.getSystem().configuration

39
components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ResourceReader.android.kt

@ -1,18 +1,33 @@
package org.jetbrains.compose.resources
import java.io.File
import java.io.InputStream
private object AndroidResourceReader
internal actual fun getPlatformResourceReader(): ResourceReader = object : ResourceReader {
override suspend fun read(path: String): ByteArray {
val resource = getResourceAsStream(path)
return resource.readBytes()
}
@OptIn(ExperimentalResourceApi::class)
@InternalResourceApi
actual suspend fun readResourceBytes(path: String): ByteArray {
val classLoader = Thread.currentThread().contextClassLoader ?: AndroidResourceReader.javaClass.classLoader
val resource = classLoader.getResourceAsStream(path) ?: run {
//try to find a font in the android assets
if (File(path).parentFile?.name.orEmpty().startsWith("font")) {
classLoader.getResourceAsStream("assets/$path")
} else null
} ?: throw MissingResourceException(path)
return resource.readBytes()
override suspend fun readPart(path: String, offset: Long, size: Long): ByteArray {
val resource = getResourceAsStream(path)
val result = ByteArray(size.toInt())
resource.use { input ->
input.skip(offset)
input.read(result, 0, size.toInt())
}
return result
}
@OptIn(ExperimentalResourceApi::class)
private fun getResourceAsStream(path: String): InputStream {
val classLoader = Thread.currentThread().contextClassLoader ?: this.javaClass.classLoader
val resource = classLoader.getResourceAsStream(path) ?: run {
//try to find a font in the android assets
if (File(path).parentFile?.name.orEmpty().startsWith("font")) {
classLoader.getResourceAsStream("assets/$path")
} else null
} ?: throw MissingResourceException(path)
return resource
}
}

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

@ -12,7 +12,6 @@ import androidx.compose.ui.text.font.*
*
* @see Resource
*/
@OptIn(InternalResourceApi::class)
@ExperimentalResourceApi
@Immutable
class FontResource
@ -24,11 +23,10 @@ class FontResource
* @param path The path to the font resource file.
* @return A new [FontResource] object.
*/
@OptIn(InternalResourceApi::class)
@ExperimentalResourceApi
fun FontResource(path: String): FontResource = FontResource(
id = "FontResource:$path",
items = setOf(ResourceItem(emptySet(), path))
items = setOf(ResourceItem(emptySet(), path, -1, -1))
)
/**

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

@ -20,7 +20,6 @@ import org.jetbrains.compose.resources.vector.xmldom.Element
* @param id The unique identifier of the drawable resource.
* @param items The set of resource items associated with the image resource.
*/
@OptIn(InternalResourceApi::class)
@ExperimentalResourceApi
@Immutable
class DrawableResource
@ -32,11 +31,10 @@ class DrawableResource
* @param path The path of the drawable resource.
* @return An [DrawableResource] object.
*/
@OptIn(InternalResourceApi::class)
@ExperimentalResourceApi
fun DrawableResource(path: String): DrawableResource = DrawableResource(
id = "DrawableResource:$path",
items = setOf(ResourceItem(emptySet(), path))
items = setOf(ResourceItem(emptySet(), path, -1, -1))
)
/**
@ -50,7 +48,7 @@ fun DrawableResource(path: String): DrawableResource = DrawableResource(
@Composable
fun painterResource(resource: DrawableResource): Painter {
val environment = LocalComposeEnvironment.current.rememberEnvironment()
val filePath = remember(resource, environment) { resource.getPathByEnvironment(environment) }
val filePath = remember(resource, environment) { resource.getResourceItemByEnvironment(environment).path }
val isXml = filePath.endsWith(".xml", true)
if (isXml) {
return rememberVectorPainter(vectorResource(resource))
@ -72,7 +70,7 @@ private val emptyImageBitmap: ImageBitmap by lazy { ImageBitmap(1, 1) }
fun imageResource(resource: DrawableResource): ImageBitmap {
val resourceReader = LocalResourceReader.current
val imageBitmap by rememberResourceState(resource, { emptyImageBitmap }) { env ->
val path = resource.getPathByEnvironment(env)
val path = resource.getResourceItemByEnvironment(env).path
val cached = loadImage(path, resourceReader) {
ImageCache.Bitmap(it.toImageBitmap())
} as ImageCache.Bitmap
@ -97,7 +95,7 @@ fun vectorResource(resource: DrawableResource): ImageVector {
val resourceReader = LocalResourceReader.current
val density = LocalDensity.current
val imageVector by rememberResourceState(resource, { emptyImageVector }) { env ->
val path = resource.getPathByEnvironment(env)
val path = resource.getResourceItemByEnvironment(env).path
val cached = loadImage(path, resourceReader) {
ImageCache.Vector(it.toXmlElement().toImageVector(density))
} as ImageCache.Vector

121
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/PluralStringResources.kt

@ -0,0 +1,121 @@
package org.jetbrains.compose.resources
import androidx.compose.runtime.*
import org.jetbrains.compose.resources.plural.PluralCategory
import org.jetbrains.compose.resources.plural.PluralRuleList
/**
* Represents a quantity string resource in the application.
*
* @param id The unique identifier of the resource.
* @param key The key used to retrieve the string resource.
* @param items The set of resource items associated with the string resource.
*/
@ExperimentalResourceApi
@Immutable
class PluralStringResource
@InternalResourceApi constructor(id: String, val key: String, items: Set<ResourceItem>) : Resource(id, items)
/**
* Retrieves the string for the pluralization for the given quantity using the specified quantity string resource.
*
* @param resource The quantity string resource to be used.
* @param quantity The quantity of the pluralization to use.
* @return The retrieved string resource.
*
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file.
*/
@ExperimentalResourceApi
@Composable
fun pluralStringResource(resource: PluralStringResource, quantity: Int): String {
val resourceReader = LocalResourceReader.current
val pluralStr by rememberResourceState(resource, quantity, { "" }) { env ->
loadPluralString(resource, quantity, resourceReader, env)
}
return pluralStr
}
/**
* Loads a string using the specified string resource.
*
* @param resource The string resource to be used.
* @param quantity The quantity of the pluralization to use.
* @return The loaded string resource.
*
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file.
*/
@ExperimentalResourceApi
suspend fun getPluralString(resource: PluralStringResource, quantity: Int): String =
loadPluralString(resource, quantity, DefaultResourceReader, getResourceEnvironment())
@OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
private suspend fun loadPluralString(
resource: PluralStringResource,
quantity: Int,
resourceReader: ResourceReader,
environment: ResourceEnvironment
): String {
val resourceItem = resource.getResourceItemByEnvironment(environment)
val item = getStringItem(resourceItem, resourceReader) as StringItem.Plurals
val pluralRuleList = PluralRuleList.getInstance(
environment.language,
environment.region,
)
val pluralCategory = pluralRuleList.getCategory(quantity)
val str = item.items[pluralCategory]
?: item.items[PluralCategory.OTHER]
?: error("Quantity string ID=`${resource.key}` does not have the pluralization $pluralCategory for quantity $quantity!")
return str
}
/**
* Retrieves the string for the pluralization for the given quantity using the specified quantity string resource.
*
* @param resource The quantity string resource to be used.
* @param quantity The quantity of the pluralization to use.
* @param formatArgs The arguments to be inserted into the formatted string.
* @return The retrieved string resource.
*
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file.
*/
@ExperimentalResourceApi
@Composable
fun pluralStringResource(resource: PluralStringResource, quantity: Int, vararg formatArgs: Any): String {
val resourceReader = LocalResourceReader.current
val args = formatArgs.map { it.toString() }
val pluralStr by rememberResourceState(resource, quantity, args, { "" }) { env ->
loadPluralString(resource, quantity, args, resourceReader, env)
}
return pluralStr
}
/**
* Loads a string using the specified string resource.
*
* @param resource The string resource to be used.
* @param quantity The quantity of the pluralization to use.
* @param formatArgs The arguments to be inserted into the formatted string.
* @return The loaded string resource.
*
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file.
*/
@ExperimentalResourceApi
suspend fun getPluralString(resource: PluralStringResource, quantity: Int, vararg formatArgs: Any): String =
loadPluralString(
resource, quantity,
formatArgs.map { it.toString() },
DefaultResourceReader,
getResourceEnvironment(),
)
@OptIn(ExperimentalResourceApi::class)
private suspend fun loadPluralString(
resource: PluralStringResource,
quantity: Int,
args: List<String>,
resourceReader: ResourceReader,
environment: ResourceEnvironment
): String {
val str = loadPluralString(resource, quantity, resourceReader, environment)
return str.replaceWithArgs(args)
}

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

@ -40,10 +40,14 @@ sealed class Resource
*
* @property qualifiers The qualifiers of the resource item.
* @property path The path of the resource item.
* @property offset The offset in bytes of the resource in the file. '-1' means the resource is whole file
* @property size The size in bytes of the resource in the file. '-1' means the resource is whole file
*/
@InternalResourceApi
@Immutable
data class ResourceItem(
internal val qualifiers: Set<Qualifier>,
internal val path: String
internal val path: String,
internal val offset: Long,
internal val size: Long,
)

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

@ -5,7 +5,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.intl.Locale
@OptIn(InternalResourceApi::class)
internal data class ResourceEnvironment(
val language: LanguageQualifier,
val region: RegionQualifier,
@ -18,7 +17,6 @@ internal interface ComposeEnvironment {
fun rememberEnvironment(): ResourceEnvironment
}
@OptIn(InternalResourceApi::class)
internal val DefaultComposeEnvironment = object : ComposeEnvironment {
@Composable
override fun rememberEnvironment(): ResourceEnvironment {
@ -51,17 +49,17 @@ internal expect fun getSystemEnvironment(): ResourceEnvironment
internal var getResourceEnvironment = ::getSystemEnvironment
@OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
internal fun Resource.getPathByEnvironment(environment: ResourceEnvironment): String {
internal fun Resource.getResourceItemByEnvironment(environment: ResourceEnvironment): ResourceItem {
//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 }
.also { if (it.size == 1) return it.first() }
.filterBy(environment.region)
.also { if (it.size == 1) return it.first().path }
.also { if (it.size == 1) return it.first() }
.filterBy(environment.theme)
.also { if (it.size == 1) return it.first().path }
.also { if (it.size == 1) return it.first() }
.filterBy(environment.density)
.also { if (it.size == 1) return it.first().path }
.also { if (it.size == 1) return it.first() }
.let { items ->
if (items.isEmpty()) {
error("Resource with ID='$id' not found")
@ -71,7 +69,6 @@ internal fun Resource.getPathByEnvironment(environment: ResourceEnvironment): St
}
}
@OptIn(InternalResourceApi::class)
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

10
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceReader.kt

@ -12,16 +12,16 @@ class MissingResourceException(path: String) : Exception("Missing resource with
* @return The content of the file as a byte array.
*/
@InternalResourceApi
expect suspend fun readResourceBytes(path: String): ByteArray
suspend fun readResourceBytes(path: String): ByteArray = DefaultResourceReader.read(path)
internal interface ResourceReader {
suspend fun read(path: String): ByteArray
suspend fun readPart(path: String, offset: Long, size: Long): ByteArray
}
internal val DefaultResourceReader: ResourceReader = object : ResourceReader {
@OptIn(InternalResourceApi::class)
override suspend fun read(path: String): ByteArray = readResourceBytes(path)
}
internal expect fun getPlatformResourceReader(): ResourceReader
internal val DefaultResourceReader = getPlatformResourceReader()
//ResourceReader provider will be overridden for tests
internal val LocalResourceReader = staticCompositionLocalOf { DefaultResourceReader }

63
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringArrayResources.kt

@ -0,0 +1,63 @@
package org.jetbrains.compose.resources
import androidx.compose.runtime.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jetbrains.compose.resources.plural.PluralCategory
import org.jetbrains.compose.resources.plural.PluralRuleList
import org.jetbrains.compose.resources.vector.xmldom.Element
import org.jetbrains.compose.resources.vector.xmldom.NodeList
/**
* Represents a string array resource in the application.
*
* @param id The unique identifier of the resource.
* @param key The key used to retrieve the string array resource.
* @param items The set of resource items associated with the string array resource.
*/
@ExperimentalResourceApi
@Immutable
class StringArrayResource
@InternalResourceApi constructor(id: String, val key: String, items: Set<ResourceItem>) : Resource(id, items)
/**
* Retrieves a list of strings using the specified string array resource.
*
* @param resource The string array resource to be used.
* @return A list of strings representing the items in the string array.
*
* @throws IllegalStateException if the string array with the given ID is not found.
*/
@ExperimentalResourceApi
@Composable
fun stringArrayResource(resource: StringArrayResource): List<String> {
val resourceReader = LocalResourceReader.current
val array by rememberResourceState(resource, { emptyList() }) { env ->
loadStringArray(resource, resourceReader, env)
}
return array
}
/**
* Loads a list of strings using the specified string array resource.
*
* @param resource The string array resource to be used.
* @return A list of strings representing the items in the string array.
*
* @throws IllegalStateException if the string array with the given ID is not found.
*/
@ExperimentalResourceApi
suspend fun getStringArray(resource: StringArrayResource): List<String> =
loadStringArray(resource, DefaultResourceReader, getResourceEnvironment())
@OptIn(ExperimentalResourceApi::class, InternalResourceApi::class)
private suspend fun loadStringArray(
resource: StringArrayResource,
resourceReader: ResourceReader,
environment: ResourceEnvironment
): List<String> {
val resourceItem = resource.getResourceItemByEnvironment(environment)
val item = getStringItem(resourceItem, resourceReader) as StringItem.Array
return item.items
}

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

@ -1,19 +1,6 @@
package org.jetbrains.compose.resources
import androidx.compose.runtime.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jetbrains.compose.resources.plural.PluralCategory
import org.jetbrains.compose.resources.plural.PluralRuleList
import org.jetbrains.compose.resources.vector.xmldom.Element
import org.jetbrains.compose.resources.vector.xmldom.NodeList
private val SimpleStringFormatRegex = Regex("""%(\d)\$[ds]""")
private fun String.replaceWithArgs(args: List<String>) = SimpleStringFormatRegex.replace(this) { matchResult ->
args[matchResult.groupValues[1].toInt() - 1]
}
/**
* Represents a string resource in the application.
@ -22,79 +9,11 @@ private fun String.replaceWithArgs(args: List<String>) = SimpleStringFormatRegex
* @param key The key used to retrieve the string resource.
* @param items The set of resource items associated with the string resource.
*/
@OptIn(InternalResourceApi::class)
@ExperimentalResourceApi
@Immutable
class StringResource
@InternalResourceApi constructor(id: String, val key: String, items: Set<ResourceItem>) : Resource(id, items)
/**
* Represents a quantity string resource in the application.
*
* @param id The unique identifier of the resource.
* @param key The key used to retrieve the string resource.
* @param items The set of resource items associated with the string resource.
*/
@OptIn(InternalResourceApi::class)
@ExperimentalResourceApi
@Immutable
class PluralStringResource
@InternalResourceApi constructor(id: String, val key: String, items: Set<ResourceItem>) : Resource(id, items)
private sealed interface StringItem {
data class Value(val text: String) : StringItem
data class Plurals(val items: Map<PluralCategory, String>) : StringItem
data class Array(val items: List<String>) : StringItem
}
private val stringsCacheMutex = Mutex()
private val parsedStringsCache = mutableMapOf<String, Deferred<Map<String, StringItem>>>()
//@TestOnly
internal fun dropStringsCache() {
parsedStringsCache.clear()
}
private suspend fun getParsedStrings(
path: String,
resourceReader: ResourceReader
): Map<String, StringItem> = coroutineScope {
val deferred = stringsCacheMutex.withLock {
parsedStringsCache.getOrPut(path) {
//LAZY - to free the mutex lock as fast as possible
async(start = CoroutineStart.LAZY) {
parseStringXml(path, resourceReader)
}
}
}
deferred.await()
}
private suspend fun parseStringXml(path: String, resourceReader: ResourceReader): Map<String, StringItem> {
val nodes = resourceReader.read(path).toXmlElement().childNodes
val strings = nodes.getElementsWithName("string").associate { element ->
val rawString = element.textContent.orEmpty()
element.getAttribute("name") to StringItem.Value(handleSpecialCharacters(rawString))
}
val plurals = nodes.getElementsWithName("plurals").associate { pluralElement ->
val items = pluralElement.childNodes.getElementsWithName("item").mapNotNull { element ->
val pluralCategory = PluralCategory.fromString(
element.getAttribute("quantity"),
) ?: return@mapNotNull null
pluralCategory to handleSpecialCharacters(element.textContent.orEmpty())
}
pluralElement.getAttribute("name") to StringItem.Plurals(items.toMap())
}
val arrays = nodes.getElementsWithName("string-array").associate { arrayElement ->
val items = arrayElement.childNodes.getElementsWithName("item").map { element ->
val rawString = element.textContent.orEmpty()
handleSpecialCharacters(rawString)
}
arrayElement.getAttribute("name") to StringItem.Array(items)
}
return strings + plurals + arrays
}
/**
* Retrieves a string using the specified string resource.
*
@ -125,16 +44,14 @@ fun stringResource(resource: StringResource): String {
suspend fun getString(resource: StringResource): String =
loadString(resource, DefaultResourceReader, getResourceEnvironment())
@OptIn(ExperimentalResourceApi::class)
@OptIn(ExperimentalResourceApi::class, InternalResourceApi::class)
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!")
val resourceItem = resource.getResourceItemByEnvironment(environment)
val item = getStringItem(resourceItem, resourceReader) as StringItem.Value
return item.text
}
@ -185,187 +102,3 @@ private suspend fun loadString(
val str = loadString(resource, resourceReader, environment)
return str.replaceWithArgs(args)
}
/**
* Retrieves the string for the pluralization for the given quantity using the specified quantity string resource.
*
* @param resource The quantity string resource to be used.
* @param quantity The quantity of the pluralization to use.
* @return The retrieved string resource.
*
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file.
*/
@ExperimentalResourceApi
@Composable
fun pluralStringResource(resource: PluralStringResource, quantity: Int): String {
val resourceReader = LocalResourceReader.current
val pluralStr by rememberResourceState(resource, quantity, { "" }) { env ->
loadPluralString(resource, quantity, resourceReader, env)
}
return pluralStr
}
/**
* Loads a string using the specified string resource.
*
* @param resource The string resource to be used.
* @param quantity The quantity of the pluralization to use.
* @return The loaded string resource.
*
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file.
*/
@ExperimentalResourceApi
suspend fun getPluralString(resource: PluralStringResource, quantity: Int): String =
loadPluralString(resource, quantity, DefaultResourceReader, getResourceEnvironment())
@OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
private suspend fun loadPluralString(
resource: PluralStringResource,
quantity: Int,
resourceReader: ResourceReader,
environment: ResourceEnvironment
): String {
val path = resource.getPathByEnvironment(environment)
val keyToValue = getParsedStrings(path, resourceReader)
val item = keyToValue[resource.key] as? StringItem.Plurals
?: error("Quantity string ID=`${resource.key}` is not found!")
val pluralRuleList = PluralRuleList.getInstance(
environment.language,
environment.region,
)
val pluralCategory = pluralRuleList.getCategory(quantity)
val str = item.items[pluralCategory]
?: item.items[PluralCategory.OTHER]
?: error("Quantity string ID=`${resource.key}` does not have the pluralization $pluralCategory for quantity $quantity!")
return str
}
/**
* Retrieves the string for the pluralization for the given quantity using the specified quantity string resource.
*
* @param resource The quantity string resource to be used.
* @param quantity The quantity of the pluralization to use.
* @param formatArgs The arguments to be inserted into the formatted string.
* @return The retrieved string resource.
*
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file.
*/
@ExperimentalResourceApi
@Composable
fun pluralStringResource(resource: PluralStringResource, quantity: Int, vararg formatArgs: Any): String {
val resourceReader = LocalResourceReader.current
val args = formatArgs.map { it.toString() }
val pluralStr by rememberResourceState(resource, quantity, args, { "" }) { env ->
loadPluralString(resource, quantity, args, resourceReader, env)
}
return pluralStr
}
/**
* Loads a string using the specified string resource.
*
* @param resource The string resource to be used.
* @param quantity The quantity of the pluralization to use.
* @param formatArgs The arguments to be inserted into the formatted string.
* @return The loaded string resource.
*
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file.
*/
@ExperimentalResourceApi
suspend fun getPluralString(resource: PluralStringResource, quantity: Int, vararg formatArgs: Any): String =
loadPluralString(
resource, quantity,
formatArgs.map { it.toString() },
DefaultResourceReader,
getResourceEnvironment(),
)
@OptIn(ExperimentalResourceApi::class)
private suspend fun loadPluralString(
resource: PluralStringResource,
quantity: Int,
args: List<String>,
resourceReader: ResourceReader,
environment: ResourceEnvironment
): String {
val str = loadPluralString(resource, quantity, resourceReader, environment)
return str.replaceWithArgs(args)
}
/**
* Retrieves a list of strings using the specified string array resource.
*
* @param resource The string resource to be used.
* @return A list of strings representing the items in the string array.
*
* @throws IllegalStateException if the string array with the given ID is not found.
*/
@ExperimentalResourceApi
@Composable
fun stringArrayResource(resource: StringResource): List<String> {
val resourceReader = LocalResourceReader.current
val array by rememberResourceState(resource, { emptyList() }) { env ->
loadStringArray(resource, resourceReader, env)
}
return array
}
/**
* Loads a list of strings using the specified string array resource.
*
* @param resource The string resource to be used.
* @return A list of strings representing the items in the string array.
*
* @throws IllegalStateException if the string array with the given ID is not found.
*/
@ExperimentalResourceApi
suspend fun getStringArray(resource: StringResource): List<String> =
loadStringArray(resource, DefaultResourceReader, getResourceEnvironment())
@OptIn(ExperimentalResourceApi::class)
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!")
return item.items
}
private fun NodeList.getElementsWithName(name: String): List<Element> =
List(length) { item(it) }
.filterIsInstance<Element>()
.filter { it.localName == name }
//https://developer.android.com/guide/topics/resources/string-resource#escaping_quotes
/**
* Replaces
*
* '\n' -> new line
*
* '\t' -> tab
*
* '\uXXXX' -> unicode symbol
*
* '\\' -> '\'
*
* @param string The input string to handle.
* @return The string with special characters replaced according to the logic.
*/
internal fun handleSpecialCharacters(string: String): String {
val unicodeNewLineTabRegex = Regex("""\\u[a-fA-F\d]{4}|\\n|\\t""")
val doubleSlashRegex = Regex("""\\\\""")
val doubleSlashIndexes = doubleSlashRegex.findAll(string).map { it.range.first }
val handledString = unicodeNewLineTabRegex.replace(string) { matchResult ->
if (doubleSlashIndexes.contains(matchResult.range.first - 1)) matchResult.value
else when (matchResult.value) {
"\\n" -> "\n"
"\\t" -> "\t"
else -> matchResult.value.substring(2).toInt(16).toChar().toString()
}
}.replace("""\\""", """\""")
return handledString
}

76
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResourcesUtils.kt

@ -0,0 +1,76 @@
package org.jetbrains.compose.resources
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jetbrains.compose.resources.plural.PluralCategory
import org.jetbrains.compose.resources.vector.xmldom.Element
import org.jetbrains.compose.resources.vector.xmldom.NodeList
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
private val SimpleStringFormatRegex = Regex("""%(\d)\$[ds]""")
internal fun String.replaceWithArgs(args: List<String>) = SimpleStringFormatRegex.replace(this) { matchResult ->
args[matchResult.groupValues[1].toInt() - 1]
}
internal sealed interface StringItem {
data class Value(val text: String) : StringItem
data class Plurals(val items: Map<PluralCategory, String>) : StringItem
data class Array(val items: List<String>) : StringItem
}
private val stringsCacheMutex = Mutex()
private val stringItemsCache = mutableMapOf<String, Deferred<StringItem>>()
//@TestOnly
internal fun dropStringItemsCache() {
stringItemsCache.clear()
}
internal suspend fun getStringItem(
resourceItem: ResourceItem,
resourceReader: ResourceReader
): StringItem = coroutineScope {
val deferred = stringsCacheMutex.withLock {
stringItemsCache.getOrPut("${resourceItem.path}/${resourceItem.offset}-${resourceItem.size}") {
//LAZY - to free the mutex lock as fast as possible
async(start = CoroutineStart.LAZY) {
val record = resourceReader.readPart(
resourceItem.path,
resourceItem.offset,
resourceItem.size
).decodeToString()
val recordItems = record.split('|')
val recordType = recordItems.first()
val recordData = recordItems.last()
when (recordType) {
"plurals" -> recordData.decodeAsPlural()
"string-array" -> recordData.decodeAsArray()
else -> recordData.decodeAsString()
}
}
}
}
deferred.await()
}
@OptIn(ExperimentalEncodingApi::class)
private fun String.decodeAsString(): StringItem.Value = StringItem.Value(
Base64.decode(this).decodeToString()
)
@OptIn(ExperimentalEncodingApi::class)
private fun String.decodeAsArray(): StringItem.Array = StringItem.Array(
split(",").map { item ->
Base64.decode(item).decodeToString()
}
)
@OptIn(ExperimentalEncodingApi::class)
private fun String.decodeAsPlural(): StringItem.Plurals = StringItem.Plurals(
split(",").associate { item ->
val category = item.substringBefore(':')
val valueBase64 = item.substringAfter(':')
PluralCategory.fromString(category)!! to Base64.decode(valueBase64).decodeToString()
}
)

60
components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt

@ -12,7 +12,7 @@ import kotlin.test.*
class ComposeResourceTest {
init {
dropStringsCache()
dropStringItemsCache()
dropImageCache()
getResourceEnvironment = ::getTestEnvironment
}
@ -71,7 +71,7 @@ class ComposeResourceTest {
) {
str = stringResource(res)
Text(str)
Text(stringArrayResource(TestStringResource("str_arr")).joinToString())
Text(stringArrayResource(TestStringArrayResource("str_arr")).joinToString())
}
}
waitForIdle()
@ -82,9 +82,25 @@ class ComposeResourceTest {
res = TestStringResource("app_name")
waitForIdle()
assertEquals(str, "Compose Resources App")
res = TestStringResource("hello")
waitForIdle()
assertEquals(str, "\uD83D\uDE0A Hello world!")
res = TestStringResource("app_name")
waitForIdle()
assertEquals(str, "Compose Resources App")
res = TestStringResource("hello")
waitForIdle()
assertEquals(str, "\uD83D\uDE0A Hello world!")
res = TestStringResource("app_name")
waitForIdle()
assertEquals(str, "Compose Resources App")
assertEquals(
expected = listOf("strings.xml"), //just one string.xml read
expected = listOf(
"strings.cvr/314-44",
"strings.cvr/211-47",
"strings.cvr/359-37"
), //only three different
actual = testResourceReader.readPaths
)
}
@ -100,7 +116,7 @@ class ComposeResourceTest {
app_name = stringResource(TestStringResource("app_name"))
accentuated_characters = stringResource(TestStringResource("accentuated_characters"))
str_template = stringResource(TestStringResource("str_template"), "test-name", 42)
str_arr = stringArrayResource(TestStringResource("str_arr"))
str_arr = stringArrayResource(TestStringArrayResource("str_arr"))
}
}
waitForIdle()
@ -141,7 +157,7 @@ class ComposeResourceTest {
"Hello, test-name! You have 42 new messages.",
getString(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"), getStringArray(TestStringArrayResource("str_arr")))
}
@Test
@ -253,32 +269,18 @@ class ComposeResourceTest {
@Test
fun testReadFileResource() = runTest {
val bytes = readResourceBytes("strings.xml")
val bytes = readResourceBytes("strings.cvr")
assertEquals(
"""
<resources>
<string name="app_name">Compose Resources App</string>
<string name="hello">😊 Hello world!</string>
<string name="accentuated_characters">Créer une table</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>
<plurals name="plurals">
<item quantity="one">one</item>
<item quantity="other">other</item>
</plurals>
<plurals name="another_plurals">
<item quantity="one">another one</item>
<item quantity="other">another other</item>
</plurals>
<plurals name="messages">
<item quantity="one">%1${'$'}d message for %2${'$'}s</item>
<item quantity="other">%1${'$'}d messages for %2${'$'}s</item>
</plurals>
</resources>
version:0
plurals|another_plurals|ONE:YW5vdGhlciBvbmU=,OTHER:YW5vdGhlciBvdGhlcg==
plurals|messages|ONE:JTEkZCBtZXNzYWdlIGZvciAlMiRz,OTHER:JTEkZCBtZXNzYWdlcyBmb3IgJTIkcw==
plurals|plurals|ONE:b25l,OTHER:b3RoZXI=
string-array|str_arr|aXRlbSAx,aXRlbSAy,aXRlbSAz
string|accentuated_characters|Q3LDqWVyIHVuZSB0YWJsZQ==
string|app_name|Q29tcG9zZSBSZXNvdXJjZXMgQXBw
string|hello|8J+YiiBIZWxsbyB3b3JsZCE=
string|str_template|SGVsbG8sICUxJHMhIFlvdSBoYXZlICUyJGQgbmV3IG1lc3NhZ2VzLg==
""".trimIndent(),
bytes.decodeToString()

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

@ -27,11 +27,11 @@ class ResourceTest {
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"),
ResourceItem(setOf(), "default", -1, -1),
ResourceItem(setOf(LanguageQualifier("en")), "en", -1, -1),
ResourceItem(setOf(LanguageQualifier("en"), RegionQualifier("US"), XHDPI), "en-rUS-xhdpi", -1, -1),
ResourceItem(setOf(LanguageQualifier("fr"), LIGHT), "fr-light", -1, -1),
ResourceItem(setOf(DARK), "dark", -1, -1),
)
)
fun env(lang: String, reg: String, theme: ThemeQualifier, density: DensityQualifier) = ResourceEnvironment(
@ -42,46 +42,46 @@ class ResourceTest {
)
assertEquals(
"en-rUS-xhdpi",
resource.getPathByEnvironment(env("en", "US", DARK, XXHDPI))
resource.getResourceItemByEnvironment(env("en", "US", DARK, XXHDPI)).path
)
assertEquals(
"en",
resource.getPathByEnvironment(env("en", "IN", LIGHT, LDPI))
resource.getResourceItemByEnvironment(env("en", "IN", LIGHT, LDPI)).path
)
assertEquals(
"default",
resource.getPathByEnvironment(env("ch", "", LIGHT, MDPI))
resource.getResourceItemByEnvironment(env("ch", "", LIGHT, MDPI)).path
)
assertEquals(
"dark",
resource.getPathByEnvironment(env("ch", "", DARK, MDPI))
resource.getResourceItemByEnvironment(env("ch", "", DARK, MDPI)).path
)
assertEquals(
"fr-light",
resource.getPathByEnvironment(env("fr", "", DARK, MDPI))
resource.getResourceItemByEnvironment(env("fr", "", DARK, MDPI)).path
)
assertEquals(
"fr-light",
resource.getPathByEnvironment(env("fr", "IN", LIGHT, MDPI))
resource.getResourceItemByEnvironment(env("fr", "IN", LIGHT, MDPI)).path
)
assertEquals(
"default",
resource.getPathByEnvironment(env("ru", "US", LIGHT, XHDPI))
resource.getResourceItemByEnvironment(env("ru", "US", LIGHT, XHDPI)).path
)
assertEquals(
"dark",
resource.getPathByEnvironment(env("ru", "US", DARK, XHDPI))
resource.getResourceItemByEnvironment(env("ru", "US", DARK, XHDPI)).path
)
val resourceWithNoDefault = DrawableResource(
id = "ImageResource:test2",
items = setOf(
ResourceItem(setOf(LanguageQualifier("en")), "en"),
ResourceItem(setOf(LanguageQualifier("fr"), LIGHT), "fr-light")
ResourceItem(setOf(LanguageQualifier("en")), "en", -1, -1),
ResourceItem(setOf(LanguageQualifier("fr"), LIGHT), "fr-light", -1, -1)
)
)
assertFailsWith<IllegalStateException> {
resourceWithNoDefault.getPathByEnvironment(env("ru", "US", DARK, XHDPI))
resourceWithNoDefault.getResourceItemByEnvironment(env("ru", "US", DARK, XHDPI))
}.message.let { msg ->
assertEquals("Resource with ID='ImageResource:test2' not found", msg)
}
@ -89,22 +89,14 @@ class ResourceTest {
val resourceWithFewFiles = DrawableResource(
id = "ImageResource:test3",
items = setOf(
ResourceItem(setOf(LanguageQualifier("en")), "en1"),
ResourceItem(setOf(LanguageQualifier("en")), "en2")
ResourceItem(setOf(LanguageQualifier("en")), "en1", -1, -1),
ResourceItem(setOf(LanguageQualifier("en")), "en2", -1, -1)
)
)
assertFailsWith<IllegalStateException> {
resourceWithFewFiles.getPathByEnvironment(env("en", "US", DARK, XHDPI))
resourceWithFewFiles.getResourceItemByEnvironment(env("en", "US", DARK, XHDPI))
}.message.let { msg ->
assertEquals("Resource with ID='ImageResource:test3' has more than one file: en1, en2", msg)
}
}
@Test
fun testEscapedSymbols() {
assertEquals(
"abc \n \\n \t \\t \u1234 \ua45f \\u1234 \\ \\u355g",
handleSpecialCharacters("""abc \n \\n \t \\t \u1234 \ua45f \\u1234 \\ \u355g""")
)
}
}

1
components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/TestComposeEnvironment.kt

@ -2,7 +2,6 @@ package org.jetbrains.compose.resources
import androidx.compose.runtime.Composable
@OptIn(InternalResourceApi::class)
internal fun getTestEnvironment() = ResourceEnvironment(
language = LanguageQualifier("en"),
region = RegionQualifier("US"),

5
components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/TestResourceReader.kt

@ -8,4 +8,9 @@ internal class TestResourceReader : ResourceReader {
readPathsList.add(path)
return DefaultResourceReader.read(path)
}
override suspend fun readPart(path: String, offset: Long, size: Long): ByteArray {
readPathsList.add("$path/$offset-$size")
return DefaultResourceReader.readPart(path, offset, size)
}
}

28
components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/TestUtils.kt

@ -4,18 +4,38 @@ import org.jetbrains.compose.resources.plural.PluralCategory
import org.jetbrains.compose.resources.plural.PluralRule
import org.jetbrains.compose.resources.plural.PluralRuleList
@OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
private val cvrMap: Map<String, ResourceItem> = mapOf(
"accentuated_characters" to ResourceItem(setOf(), "strings.cvr", 259, 54),
"app_name" to ResourceItem(setOf(), "strings.cvr", 314, 44),
"hello" to ResourceItem(setOf(), "strings.cvr", 359, 37),
"str_template" to ResourceItem(setOf(), "strings.cvr", 397, 76),
"another_plurals" to ResourceItem(setOf(), "strings.cvr", 10, 71),
"messages" to ResourceItem(setOf(), "strings.cvr", 82, 88),
"plurals" to ResourceItem(setOf(), "strings.cvr", 171, 39),
"str_arr" to ResourceItem(setOf(), "strings.cvr", 211, 47),
)
@OptIn(ExperimentalResourceApi::class)
internal fun TestStringResource(key: String) = StringResource(
"STRING:$key",
key,
setOf(ResourceItem(emptySet(), "strings.xml"))
setOf(cvrMap[key] ?: error("String ID=`$key` is not found!"))
)
@OptIn(ExperimentalResourceApi::class)
internal fun TestStringArrayResource(key: String) = StringArrayResource(
"STRING:$key",
key,
setOf(cvrMap[key] ?: error("String ID=`$key` is not found!"))
)
@OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
@OptIn(ExperimentalResourceApi::class)
internal fun TestPluralStringResource(key: String) = PluralStringResource(
"PLURALS:$key",
key,
setOf(ResourceItem(emptySet(), "strings.xml"))
setOf(cvrMap[key] ?: error("String ID=`$key` is not found!"))
)
internal fun parsePluralSamples(samples: String): List<Int> {

9
components/resources/library/src/commonTest/resources/strings.cvr

@ -0,0 +1,9 @@
version:0
plurals|another_plurals|ONE:YW5vdGhlciBvbmU=,OTHER:YW5vdGhlciBvdGhlcg==
plurals|messages|ONE:JTEkZCBtZXNzYWdlIGZvciAlMiRz,OTHER:JTEkZCBtZXNzYWdlcyBmb3IgJTIkcw==
plurals|plurals|ONE:b25l,OTHER:b3RoZXI=
string-array|str_arr|aXRlbSAx,aXRlbSAy,aXRlbSAz
string|accentuated_characters|Q3LDqWVyIHVuZSB0YWJsZQ==
string|app_name|Q29tcG9zZSBSZXNvdXJjZXMgQXBw
string|hello|8J+YiiBIZWxsbyB3b3JsZCE=
string|str_template|SGVsbG8sICUxJHMhIFlvdSBoYXZlICUyJGQgbmV3IG1lc3NhZ2VzLg==

23
components/resources/library/src/commonTest/resources/strings.xml

@ -1,23 +0,0 @@
<resources>
<string name="app_name">Compose Resources App</string>
<string name="hello">😊 Hello world!</string>
<string name="accentuated_characters">Créer une table</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>
<plurals name="plurals">
<item quantity="one">one</item>
<item quantity="other">other</item>
</plurals>
<plurals name="another_plurals">
<item quantity="one">another one</item>
<item quantity="other">another other</item>
</plurals>
<plurals name="messages">
<item quantity="one">%1$d message for %2$s</item>
<item quantity="other">%1$d messages for %2$s</item>
</plurals>
</resources>

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

@ -5,7 +5,6 @@ import org.jetbrains.skiko.currentSystemTheme
import java.awt.Toolkit
import java.util.*
@OptIn(InternalResourceApi::class)
internal actual fun getSystemEnvironment(): ResourceEnvironment {
val locale = Locale.getDefault()
//FIXME: don't use skiko internals

31
components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceReader.desktop.kt

@ -1,11 +1,26 @@
package org.jetbrains.compose.resources
private object JvmResourceReader
@OptIn(ExperimentalResourceApi::class)
@InternalResourceApi
actual suspend fun readResourceBytes(path: String): ByteArray {
val classLoader = Thread.currentThread().contextClassLoader ?: JvmResourceReader.javaClass.classLoader
val resource = classLoader.getResourceAsStream(path) ?: throw MissingResourceException(path)
return resource.readBytes()
import java.io.InputStream
internal actual fun getPlatformResourceReader(): ResourceReader = object : ResourceReader {
override suspend fun read(path: String): ByteArray {
val resource = getResourceAsStream(path)
return resource.readBytes()
}
override suspend fun readPart(path: String, offset: Long, size: Long): ByteArray {
val resource = getResourceAsStream(path)
val result = ByteArray(size.toInt())
resource.use { input ->
input.skip(offset)
input.read(result, 0, size.toInt())
}
return result
}
@OptIn(ExperimentalResourceApi::class)
private fun getResourceAsStream(path: String): InputStream {
val classLoader = Thread.currentThread().contextClassLoader ?: this.javaClass.classLoader
return classLoader.getResourceAsStream(path) ?: throw MissingResourceException(path)
}
}

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

@ -4,7 +4,6 @@ import platform.Foundation.*
import platform.UIKit.UIScreen
import platform.UIKit.UIUserInterfaceStyle
@OptIn(InternalResourceApi::class)
internal actual fun getSystemEnvironment(): ResourceEnvironment {
val locale = NSLocale.preferredLanguages.firstOrNull()
?.let { NSLocale(it as String) }

41
components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/ResourceReader.ios.kt

@ -2,20 +2,39 @@ package org.jetbrains.compose.resources
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import platform.Foundation.NSBundle
import platform.Foundation.NSFileManager
import platform.Foundation.*
import platform.posix.memcpy
@OptIn(ExperimentalResourceApi::class)
@InternalResourceApi
actual suspend fun readResourceBytes(path: String): ByteArray {
val fileManager = NSFileManager.defaultManager()
// todo: support fallback path at bundle root?
val composeResourcesPath = NSBundle.mainBundle.resourcePath + "/compose-resources/" + path
val contentsAtPath = fileManager.contentsAtPath(composeResourcesPath) ?: throw MissingResourceException(path)
return ByteArray(contentsAtPath.length.toInt()).apply {
usePinned {
memcpy(it.addressOf(0), contentsAtPath.bytes, contentsAtPath.length)
internal actual fun getPlatformResourceReader(): ResourceReader = object : ResourceReader {
override suspend fun read(path: String): ByteArray {
val data = readData(getPathInBundle(path))
return ByteArray(data.length.toInt()).apply {
usePinned { memcpy(it.addressOf(0), data.bytes, data.length) }
}
}
override suspend fun readPart(path: String, offset: Long, size: Long): ByteArray {
val data = readData(getPathInBundle(path), offset, size)
return ByteArray(data.length.toInt()).apply {
usePinned { memcpy(it.addressOf(0), data.bytes, data.length) }
}
}
private fun readData(path: String): NSData {
return NSFileManager.defaultManager().contentsAtPath(path) ?: throw MissingResourceException(path)
}
private fun readData(path: String, offset: Long, size: Long): NSData {
val fileHandle = NSFileHandle.fileHandleForReadingAtPath(path) ?: throw MissingResourceException(path)
fileHandle.seekToOffset(offset.toULong(), null)
val result = fileHandle.readDataOfLength(size.toULong())
fileHandle.closeFile()
return result
}
private fun getPathInBundle(path: String): String {
// todo: support fallback path at bundle root?
return NSBundle.mainBundle.resourcePath + "/compose-resources/" + path
}
}

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

@ -9,7 +9,6 @@ private external class Intl {
}
}
@OptIn(InternalResourceApi::class)
internal actual fun getSystemEnvironment(): ResourceEnvironment {
val locale = Intl.Locale(window.navigator.language)
val isDarkTheme = window.matchMedia("(prefers-color-scheme: dark)").matches

35
components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.js.kt

@ -4,17 +4,32 @@ import kotlinx.browser.window
import kotlinx.coroutines.await
import org.khronos.webgl.ArrayBuffer
import org.khronos.webgl.Int8Array
private fun ArrayBuffer.toByteArray(): ByteArray =
Int8Array(this, 0, byteLength).unsafeCast<ByteArray>()
import org.w3c.files.Blob
import kotlin.js.Promise
@OptIn(ExperimentalResourceApi::class)
@InternalResourceApi
actual suspend fun readResourceBytes(path: String): ByteArray {
val resPath = WebResourcesConfiguration.getResourcePath(path)
val response = window.fetch(resPath).await()
if (!response.ok) {
throw MissingResourceException(resPath)
internal actual fun getPlatformResourceReader(): ResourceReader = object : ResourceReader {
override suspend fun read(path: String): ByteArray {
return readAsBlob(path).asByteArray()
}
override suspend fun readPart(path: String, offset: Long, size: Long): ByteArray {
val part = readAsBlob(path).slice(offset.toInt(), (offset + size).toInt())
return part.asByteArray()
}
private suspend fun readAsBlob(path: String): Blob {
val resPath = WebResourcesConfiguration.getResourcePath(path)
val response = window.fetch(resPath).await()
if (!response.ok) {
throw MissingResourceException(resPath)
}
return response.blob().await()
}
private suspend fun Blob.asByteArray(): ByteArray {
//https://developer.mozilla.org/en-US/docs/Web/API/Blob/arrayBuffer
val buffer = asDynamic().arrayBuffer() as Promise<ArrayBuffer>
return Int8Array(buffer.await()).unsafeCast<ByteArray>()
}
return response.arrayBuffer().await().toByteArray()
}

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

@ -6,7 +6,6 @@ import platform.CoreGraphics.CGDisplayPixelsWide
import platform.CoreGraphics.CGDisplayScreenSize
import platform.Foundation.*
@OptIn(InternalResourceApi::class)
internal actual fun getSystemEnvironment(): ResourceEnvironment {
val locale = NSLocale.currentLocale()
val isDarkTheme = NSUserDefaults.standardUserDefaults.stringForKey("AppleInterfaceStyle") == "Dark"

51
components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/ResourceReader.macos.kt

@ -2,23 +2,46 @@ package org.jetbrains.compose.resources
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import platform.Foundation.NSFileManager
import platform.Foundation.*
import platform.posix.memcpy
@OptIn(ExperimentalResourceApi::class)
@InternalResourceApi
actual suspend fun readResourceBytes(path: String): ByteArray {
val currentDirectoryPath = NSFileManager.defaultManager().currentDirectoryPath
val contentsAtPath = NSFileManager.defaultManager().run {
//todo in future bundle resources with app and use all sourceSets (skikoMain, nativeMain)
contentsAtPath("$currentDirectoryPath/src/macosMain/composeResources/$path")
?: contentsAtPath("$currentDirectoryPath/src/macosTest/composeResources/$path")
?: contentsAtPath("$currentDirectoryPath/src/commonMain/composeResources/$path")
?: contentsAtPath("$currentDirectoryPath/src/commonTest/composeResources/$path")
} ?: throw MissingResourceException(path)
return ByteArray(contentsAtPath.length.toInt()).apply {
usePinned {
memcpy(it.addressOf(0), contentsAtPath.bytes, contentsAtPath.length)
internal actual fun getPlatformResourceReader(): ResourceReader = object : ResourceReader {
override suspend fun read(path: String): ByteArray {
val data = readData(getPathOnDisk(path))
return ByteArray(data.length.toInt()).apply {
usePinned { memcpy(it.addressOf(0), data.bytes, data.length) }
}
}
override suspend fun readPart(path: String, offset: Long, size: Long): ByteArray {
val data = readData(getPathOnDisk(path), offset, size)
return ByteArray(data.length.toInt()).apply {
usePinned { memcpy(it.addressOf(0), data.bytes, data.length) }
}
}
private fun readData(path: String): NSData {
return NSFileManager.defaultManager().contentsAtPath(path) ?: throw MissingResourceException(path)
}
private fun readData(path: String, offset: Long, size: Long): NSData {
val fileHandle = NSFileHandle.fileHandleForReadingAtPath(path) ?: throw MissingResourceException(path)
fileHandle.seekToOffset(offset.toULong(), null)
val result = fileHandle.readDataOfLength(size.toULong())
fileHandle.closeFile()
return result
}
private fun getPathOnDisk(path: String): String {
val fm = NSFileManager.defaultManager()
val currentDirectoryPath = fm.currentDirectoryPath
return listOf(
//todo in future bundle resources with app and use all sourceSets (skikoMain, nativeMain)
"$currentDirectoryPath/src/macosMain/composeResources/$path",
"$currentDirectoryPath/src/macosTest/composeResources/$path",
"$currentDirectoryPath/src/commonMain/composeResources/$path",
"$currentDirectoryPath/src/commonTest/composeResources/$path"
).firstOrNull { p -> fm.fileExistsAtPath(p) } ?: throw MissingResourceException(path)
}
}

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

@ -34,7 +34,7 @@ private val defaultEmptyFont by lazy { Font("org.jetbrains.compose.emptyFont", B
actual fun Font(resource: FontResource, weight: FontWeight, style: FontStyle): Font {
val resourceReader = LocalResourceReader.current
val fontFile by rememberResourceState(resource, weight, style, { defaultEmptyFont }) { env ->
val path = resource.getPathByEnvironment(env)
val path = resource.getResourceItemByEnvironment(env).path
val fontBytes = resourceReader.read(path)
Font(path, fontBytes, weight, style)
}

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

@ -9,7 +9,6 @@ private external class Intl {
}
}
@OptIn(InternalResourceApi::class)
internal actual fun getSystemEnvironment(): ResourceEnvironment {
val locale = Intl.Locale(window.navigator.language)
val isDarkTheme = window.matchMedia("(prefers-color-scheme: dark)").matches

72
components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt

@ -5,31 +5,11 @@ import kotlinx.coroutines.await
import org.khronos.webgl.ArrayBuffer
import org.khronos.webgl.Int8Array
import org.w3c.fetch.Response
import org.w3c.files.Blob
import kotlin.js.Promise
import kotlin.wasm.unsafe.UnsafeWasmMemoryApi
import kotlin.wasm.unsafe.withScopedMemoryAllocator
/**
* Reads the content of the resource file at the specified path and returns it as a byte array.
*
* @param path The path of the file to read in the resource's directory.
* @return The content of the file as a byte array.
*/
@OptIn(ExperimentalResourceApi::class)
@InternalResourceApi
actual suspend fun readResourceBytes(path: String): ByteArray {
val resPath = WebResourcesConfiguration.getResourcePath(path)
val response = window.fetch(resPath).await<Response>()
if (!response.ok) {
throw MissingResourceException(resPath)
}
return response.arrayBuffer().await<ArrayBuffer>().toByteArray()
}
private fun ArrayBuffer.toByteArray(): ByteArray {
val source = Int8Array(this, 0, byteLength)
return jsInt8ArrayToKotlinByteArray(source)
}
@JsFun(
""" (src, size, dstAddr) => {
const mem8 = new Int8Array(wasmExports.memory.buffer, dstAddr, size);
@ -37,16 +17,46 @@ private fun ArrayBuffer.toByteArray(): ByteArray {
}
"""
)
internal external fun jsExportInt8ArrayToWasm(src: Int8Array, size: Int, dstAddr: Int)
private external fun jsExportInt8ArrayToWasm(src: Int8Array, size: Int, dstAddr: Int)
@JsFun("(blob) => blob.arrayBuffer()")
private external fun jsExportBlobAsArrayBuffer(blob: Blob): Promise<ArrayBuffer>
@OptIn(ExperimentalResourceApi::class)
internal actual fun getPlatformResourceReader(): ResourceReader = object : ResourceReader {
override suspend fun read(path: String): ByteArray {
return readAsBlob(path).asByteArray()
}
override suspend fun readPart(path: String, offset: Long, size: Long): ByteArray {
val part = readAsBlob(path).slice(offset.toInt(), (offset + size).toInt())
return part.asByteArray()
}
private suspend fun readAsBlob(path: String): Blob {
val resPath = WebResourcesConfiguration.getResourcePath(path)
val response = window.fetch(resPath).await<Response>()
if (!response.ok) {
throw MissingResourceException(resPath)
}
return response.blob().await()
}
private suspend fun Blob.asByteArray(): ByteArray {
val buffer: ArrayBuffer = jsExportBlobAsArrayBuffer(this).await()
return Int8Array(buffer).asByteArray()
}
internal fun jsInt8ArrayToKotlinByteArray(x: Int8Array): ByteArray {
val size = x.length
private fun Int8Array.asByteArray(): ByteArray {
val array = this
val size = array.length
@OptIn(UnsafeWasmMemoryApi::class)
return withScopedMemoryAllocator { allocator ->
val memBuffer = allocator.allocate(size)
val dstAddress = memBuffer.address.toInt()
jsExportInt8ArrayToWasm(x, size, dstAddress)
ByteArray(size) { i -> (memBuffer + i).loadByte() }
@OptIn(UnsafeWasmMemoryApi::class)
return withScopedMemoryAllocator { allocator ->
val memBuffer = allocator.allocate(size)
val dstAddress = memBuffer.address.toInt()
jsExportInt8ArrayToWasm(array, size, dstAddress)
ByteArray(size) { i -> (memBuffer + i).loadByte() }
}
}
}

14
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/ComposePlugin.kt

@ -19,24 +19,17 @@ import org.jetbrains.compose.desktop.DesktopExtension
import org.jetbrains.compose.desktop.application.internal.configureDesktop
import org.jetbrains.compose.desktop.preview.internal.initializePreview
import org.jetbrains.compose.experimental.dsl.ExperimentalExtension
import org.jetbrains.compose.experimental.internal.configureExperimental
import org.jetbrains.compose.experimental.internal.configureExperimentalTargetsFlagsCheck
import org.jetbrains.compose.experimental.internal.configureNativeCompilerCaching
import org.jetbrains.compose.internal.KOTLIN_MPP_PLUGIN_ID
import org.jetbrains.compose.internal.mppExt
import org.jetbrains.compose.internal.mppExtOrNull
import org.jetbrains.compose.experimental.internal.*
import org.jetbrains.compose.internal.*
import org.jetbrains.compose.internal.service.ConfigurationProblemReporterService
import org.jetbrains.compose.internal.service.GradlePropertySnapshotService
import org.jetbrains.compose.internal.utils.currentTarget
import org.jetbrains.compose.resources.ResourcesExtension
import org.jetbrains.compose.resources.configureComposeResources
import org.jetbrains.compose.resources.ios.configureSyncTask
import org.jetbrains.compose.web.WebExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinCompile
import org.jetbrains.kotlin.gradle.dsl.KotlinJsCompile
import org.jetbrains.kotlin.gradle.plugin.KotlinDependencyHandler
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion
import org.jetbrains.kotlin.gradle.plugin.*
internal val composeVersion get() = ComposeBuildConfig.composeVersion
@ -75,7 +68,6 @@ abstract class ComposePlugin : Plugin<Project> {
project.plugins.withId(KOTLIN_MPP_PLUGIN_ID) {
val mppExt = project.mppExt
project.configureExperimentalTargetsFlagsCheck(mppExt)
project.configureSyncTask(mppExt)
}
project.tasks.withType(KotlinCompile::class.java).configureEach {

7
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/ComposeProjectProperties.kt

@ -56,10 +56,7 @@ internal object ComposeProperties {
providers.valueOrNull(CHECK_JDK_VENDOR).toBooleanProvider(true)
//providers.valueOrNull works only with root gradle.properties
fun alwaysGenerateResourceAccessors(project: Project): Provider<Boolean> = project.provider {
project.findProperty(ALWAYS_GENERATE_RESOURCE_ACCESSORS)?.toString().equals("true", true)
fun dontSyncResources(project: Project): Provider<Boolean> = project.provider {
project.findProperty(SYNC_RESOURCES_PROPERTY)?.toString().equals("false", true)
}
fun syncResources(providers: ProviderFactory): Provider<Boolean> =
providers.valueOrNull(SYNC_RESOURCES_PROPERTY).toBooleanProvider(true)
}

26
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/experimental/uikit/internal/utils/cocoapodsDslHelpers.kt

@ -1,26 +0,0 @@
/*
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.experimental.uikit.internal.utils
import org.gradle.api.Project
import org.gradle.api.plugins.ExtensionAware
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension
private const val COCOAPODS_PLUGIN_ID = "org.jetbrains.kotlin.native.cocoapods"
internal fun Project.withCocoapodsPlugin(fn: () -> Unit) {
project.plugins.withId(COCOAPODS_PLUGIN_ID) {
fn()
}
}
internal val KotlinMultiplatformExtension.cocoapodsExt: CocoapodsExtension
get() {
val extensionAware = (this as? ExtensionAware) ?: error("KotlinMultiplatformExtension is not ExtensionAware")
val extName = "cocoapods"
val ext = extensionAware.extensions.findByName(extName) ?: error("KotlinMultiplatformExtension does not contain '$extName' extension")
return ext as CocoapodsExtension
}

22
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/experimental/uikit/internal/utils/kotlinNativeTargetUtils.kt

@ -1,22 +0,0 @@
/*
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.experimental.uikit.internal.utils
import org.jetbrains.kotlin.gradle.plugin.KotlinTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
import org.jetbrains.kotlin.konan.target.KonanTarget
internal fun KotlinNativeTarget.isIosSimulatorTarget(): Boolean =
konanTarget === KonanTarget.IOS_X64 || konanTarget === KonanTarget.IOS_SIMULATOR_ARM64
internal fun KotlinNativeTarget.isIosDeviceTarget(): Boolean =
konanTarget === KonanTarget.IOS_ARM64 || konanTarget === KonanTarget.IOS_ARM32
internal fun KotlinNativeTarget.isIosTarget(): Boolean =
isIosSimulatorTarget() || isIosDeviceTarget()
internal fun KotlinTarget.asIosNativeTargetOrNull(): KotlinNativeTarget? =
(this as? KotlinNativeTarget)?.takeIf { it.isIosTarget() }

53
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/experimental/uikit/tasks/AbstractComposeIosTask.kt

@ -1,53 +0,0 @@
/*
* Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.experimental.uikit.tasks
import org.gradle.api.DefaultTask
import org.gradle.api.file.Directory
import org.gradle.api.file.FileSystemOperations
import org.gradle.api.file.ProjectLayout
import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.Property
import org.gradle.api.provider.Provider
import org.gradle.api.provider.ProviderFactory
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.LocalState
import org.gradle.process.ExecOperations
import org.jetbrains.compose.desktop.application.internal.ComposeProperties
import org.jetbrains.compose.desktop.application.internal.ExternalToolRunner
import org.jetbrains.compose.internal.utils.notNullProperty
import javax.inject.Inject
abstract class AbstractComposeIosTask : DefaultTask() {
@get:Inject
protected abstract val objects: ObjectFactory
@get:Inject
protected abstract val providers: ProviderFactory
@get:Inject
protected abstract val execOperations: ExecOperations
@get:Inject
protected abstract val fileOperations: FileSystemOperations
@get:Inject
protected abstract val layout: ProjectLayout
@get:LocalState
protected val logsDir: Provider<Directory> = project.layout.buildDirectory.dir("compose/logs/$name")
@get:Internal
val verbose: Property<Boolean> = objects.notNullProperty<Boolean>().apply {
set(providers.provider {
logger.isDebugEnabled || ComposeProperties.isVerbose(providers).get()
})
}
@get:Internal
internal val runExternalTool: ExternalToolRunner
get() = ExternalToolRunner(verbose, logsDir, execOperations)
}

100
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/AndroidResources.kt

@ -0,0 +1,100 @@
package org.jetbrains.compose.resources
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.gradle.BaseExtension
import org.gradle.api.DefaultTask
import org.gradle.api.Project
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.FileSystemOperations
import org.gradle.api.provider.Property
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.*
import org.jetbrains.compose.internal.utils.registerTask
import org.jetbrains.compose.internal.utils.uppercaseFirstChar
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinJvmAndroidCompilation
import org.jetbrains.kotlin.gradle.plugin.sources.android.androidSourceSetInfoOrNull
import org.jetbrains.kotlin.gradle.utils.ObservableSet
import java.io.File
import javax.inject.Inject
@OptIn(ExperimentalKotlinGradlePluginApi::class)
internal fun Project.configureAndroidComposeResources(
kotlinExtension: KotlinMultiplatformExtension,
androidExtension: BaseExtension,
preparedCommonResources: Provider<File>
) {
val commonMain = KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME
val commonResourcesDir = projectDir.resolve("src/$commonMain/$COMPOSE_RESOURCES_DIR")
// 1) get the Kotlin Android Target Compilation -> [A]
// 2) get default source set name for the 'A'
// 3) find the associated Android SourceSet in the AndroidExtension -> [B]
// 4) get all source sets in the 'A' and add its resources to the 'B'
kotlinExtension.targets.withType(KotlinAndroidTarget::class.java).all { androidTarget ->
androidTarget.compilations.all { compilation: KotlinJvmAndroidCompilation ->
//fix for AGP < 8.0
//usually 'androidSourceSet.resources.srcDir(preparedCommonResources)' should be enough
compilation.androidVariant.processJavaResourcesProvider.configure { it.dependsOn(preparedCommonResources) }
compilation.defaultSourceSet.androidSourceSetInfoOrNull?.let { kotlinAndroidSourceSet ->
androidExtension.sourceSets
.matching { it.name == kotlinAndroidSourceSet.androidSourceSetName }
.all { androidSourceSet ->
(compilation.allKotlinSourceSets as? ObservableSet<KotlinSourceSet>)?.forAll { kotlinSourceSet ->
if (kotlinSourceSet.name == commonMain) {
androidSourceSet.resources.srcDir(preparedCommonResources)
} else {
androidSourceSet.resources.srcDir(
projectDir.resolve("src/${kotlinSourceSet.name}/$COMPOSE_RESOURCES_DIR")
)
}
}
}
}
}
}
//copy fonts from the compose resources dir to android assets
val androidComponents = project.extensions.findByType(AndroidComponentsExtension::class.java) ?: return
androidComponents.onVariants { variant ->
val copyFonts = registerTask<CopyAndroidFontsToAssetsTask>(
"copy${variant.name.uppercaseFirstChar()}FontsToAndroidAssets"
) {
from.set(commonResourcesDir)
}
variant.sources?.assets?.addGeneratedSourceDirectory(
taskProvider = copyFonts,
wiredWith = CopyAndroidFontsToAssetsTask::outputDirectory
)
//exclude a duplication of fonts in apks
variant.packaging.resources.excludes.add("**/font*/*")
}
}
//Copy task doesn't work with 'variant.sources?.assets?.addGeneratedSourceDirectory' API
internal abstract class CopyAndroidFontsToAssetsTask : DefaultTask() {
@get:Inject
abstract val fileSystem: FileSystemOperations
@get:InputFiles
@get:IgnoreEmptyDirectories
abstract val from: Property<File>
@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty
@TaskAction
fun action() {
fileSystem.copy {
it.includeEmptyDirs = false
it.from(from)
it.include("**/font*/*")
it.into(outputDirectory)
}
}
}

110
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ComposeResources.kt

@ -0,0 +1,110 @@
package org.jetbrains.compose.resources
import com.android.build.gradle.BaseExtension
import org.gradle.api.Project
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.SourceSet
import org.gradle.api.tasks.TaskProvider
import org.gradle.util.GradleVersion
import org.jetbrains.compose.internal.KOTLIN_JVM_PLUGIN_ID
import org.jetbrains.compose.internal.KOTLIN_MPP_PLUGIN_ID
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet
import org.jetbrains.kotlin.gradle.plugin.extraProperties
import java.io.File
internal const val COMPOSE_RESOURCES_DIR = "composeResources"
internal const val RES_GEN_DIR = "generated/compose/resourceGenerator"
private const val KMP_RES_EXT = "multiplatformResourcesPublication"
private const val MIN_GRADLE_VERSION_FOR_KMP_RESOURCES = "7.6"
private val androidPluginIds = listOf(
"com.android.application",
"com.android.library"
)
internal fun Project.configureComposeResources(extension: ResourcesExtension) {
val config = provider { extension }
plugins.withId(KOTLIN_MPP_PLUGIN_ID) { onKgpApplied(config) }
plugins.withId(KOTLIN_JVM_PLUGIN_ID) { onKotlinJvmApplied(config) }
}
private fun Project.onKgpApplied(config: Provider<ResourcesExtension>) {
val kotlinExtension = project.extensions.getByType(KotlinMultiplatformExtension::class.java)
//common resources must be converted (XML -> CVR)
val commonMain = KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME
val preparedCommonResources = prepareCommonResources(commonMain)
val hasKmpResources = extraProperties.has(KMP_RES_EXT)
val currentGradleVersion = GradleVersion.current()
val minGradleVersion = GradleVersion.version(MIN_GRADLE_VERSION_FOR_KMP_RESOURCES)
val kmpResourcesAreAvailable = hasKmpResources && currentGradleVersion >= minGradleVersion
if (kmpResourcesAreAvailable) {
configureKmpResources(kotlinExtension, extraProperties.get(KMP_RES_EXT)!!, preparedCommonResources, config)
} else {
if (!hasKmpResources) logger.info(
"""
Compose resources publication requires Kotlin Gradle Plugin >= 2.0
Current Kotlin Gradle Plugin is ${KotlinVersion.CURRENT}
""".trimIndent()
)
if (currentGradleVersion < minGradleVersion) logger.info(
"""
Compose resources publication requires Gradle >= $MIN_GRADLE_VERSION_FOR_KMP_RESOURCES
Current Gradle is ${currentGradleVersion.version}
""".trimIndent()
)
configureComposeResources(kotlinExtension, commonMain, preparedCommonResources, config)
//when applied AGP then configure android resources
androidPluginIds.forEach { pluginId ->
plugins.withId(pluginId) {
val androidExtension = project.extensions.getByType(BaseExtension::class.java)
configureAndroidComposeResources(kotlinExtension, androidExtension, preparedCommonResources)
}
}
}
configureSyncIosComposeResources(kotlinExtension)
}
private fun Project.onKotlinJvmApplied(config: Provider<ResourcesExtension>) {
val kotlinExtension = project.extensions.getByType(KotlinProjectExtension::class.java)
val main = SourceSet.MAIN_SOURCE_SET_NAME
val preparedCommonResources = prepareCommonResources(main)
configureComposeResources(kotlinExtension, main, preparedCommonResources, config)
}
//common resources must be converted (XML -> CVR)
private fun Project.prepareCommonResources(commonSourceSetName: String): Provider<File> {
val preparedResourcesTask = registerPrepareComposeResourcesTask(
project.projectDir.resolve("src/$commonSourceSetName/$COMPOSE_RESOURCES_DIR"),
layout.buildDirectory.dir("$RES_GEN_DIR/preparedResources/$commonSourceSetName/$COMPOSE_RESOURCES_DIR")
)
return preparedResourcesTask.flatMap { it.outputDir }
}
// sourceSet.resources.srcDirs doesn't work for Android targets.
// Android resources should be configured separately
private fun Project.configureComposeResources(
kotlinExtension: KotlinProjectExtension,
commonSourceSetName: String,
preparedCommonResources: Provider<File>,
config: Provider<ResourcesExtension>
) {
logger.info("Configure compose resources")
kotlinExtension.sourceSets.all { sourceSet ->
val sourceSetName = sourceSet.name
val resourcesDir = project.projectDir.resolve("src/$sourceSetName/$COMPOSE_RESOURCES_DIR")
if (sourceSetName == commonSourceSetName) {
sourceSet.resources.srcDirs(preparedCommonResources)
configureGenerationComposeResClass(preparedCommonResources, sourceSet, config, false)
} else {
sourceSet.resources.srcDirs(resourcesDir)
}
}
}

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

@ -1,12 +1,11 @@
package org.jetbrains.compose.resources
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.*
import java.io.File
import java.io.RandomAccessFile
import java.nio.file.Path
import javax.xml.parsers.DocumentBuilderFactory
import kotlin.io.path.relativeTo
/**
@ -31,12 +30,12 @@ internal abstract class GenerateResClassTask : DefaultTask() {
abstract val resDir: Property<File>
@get:OutputDirectory
abstract val codeDir: DirectoryProperty
abstract val codeDir: Property<File>
@TaskAction
fun generate() {
try {
val kotlinDir = codeDir.get().asFile
val kotlinDir = codeDir.get()
logger.info("Clean directory $kotlinDir")
kotlinDir.deleteRecursively()
kotlinDir.mkdirs()
@ -100,35 +99,49 @@ internal abstract class GenerateResClassTask : DefaultTask() {
return null
}
if (typeString == "values" && file.name.equals("strings.xml", true)) {
return getStringResources(file).mapNotNull { (typeName, strId) ->
val type = when(typeName) {
"string", "string-array" -> ResourceType.STRING
"plurals" -> ResourceType.PLURAL_STRING
else -> return@mapNotNull null
}
ResourceItem(type, qualifiers, strId.asUnderscoredIdentifier(), path)
}
if (typeString == "values" && file.extension.equals(XmlValuesConverterTask.CONVERTED_RESOURCE_EXT, true)) {
return getValueResourceItems(file, qualifiers, path)
}
val type = ResourceType.fromString(typeString)
val type = ResourceType.fromString(typeString) ?: error("Unknown resource type: '$typeString'.")
return listOf(ResourceItem(type, qualifiers, file.nameWithoutExtension.asUnderscoredIdentifier(), path))
}
//type -> id
private val stringTypeNames = listOf("string", "string-array", "plurals")
private fun getStringResources(stringsXml: File): List<Pair<String, String>> {
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(stringsXml)
val items = doc.getElementsByTagName("resources").item(0).childNodes
return List(items.length) { items.item(it) }
.filter { it.nodeName in stringTypeNames }
.map { it.nodeName to it.attributes.getNamedItem("name").nodeValue }
private fun getValueResourceItems(dataFile: File, qualifiers: List<String>, path: Path) : List<ResourceItem> {
val result = mutableListOf<ResourceItem>()
dataFile.bufferedReader().use { f ->
var offset = 0L
var line: String? = f.readLine()
while (line != null) {
val size = line.encodeToByteArray().size
//first line is meta info
if (offset > 0) {
result.add(getValueResourceItem(line, offset, size.toLong(), qualifiers, path))
}
offset += size + 1 // "+1" for newline character
line = f.readLine()
}
}
return result
}
private fun File.listNotHiddenFiles(): List<File> =
listFiles()?.filter { !it.isHidden }.orEmpty()
private fun getValueResourceItem(
recordString: String,
offset: Long,
size: Long,
qualifiers: List<String>,
path: Path
) : ResourceItem {
val record = ValueResourceRecord.createFromString(recordString)
return ResourceItem(record.type, qualifiers, record.key.asUnderscoredIdentifier(), path, offset, size)
}
}
internal fun File.listNotHiddenFiles(): List<File> =
listFiles()?.filter { !it.isHidden }.orEmpty()
internal fun String.asUnderscoredIdentifier(): String =
replace('-', '_')
.let { if (it.isNotEmpty() && it.first().isDigit()) "_$it" else it }

38
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt → gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/GeneratedResClassSpec.kt

@ -6,19 +6,18 @@ import java.nio.file.Path
import java.util.*
import kotlin.io.path.invariantSeparatorsPathString
internal enum class ResourceType(val typeName: String) {
DRAWABLE("drawable"),
STRING("string"),
PLURAL_STRING("plurals"),
FONT("font");
internal enum class ResourceType(val typeName: String, val accessorName: String) {
DRAWABLE("drawable", "drawable"),
STRING("string", "string"),
STRING_ARRAY("string-array", "array"),
PLURAL_STRING("plurals", "plurals"),
FONT("font", "font");
override fun toString(): String = typeName
companion object {
fun fromString(str: String): ResourceType =
ResourceType.values()
.firstOrNull { it.typeName.equals(str, true) }
?: error("Unknown resource type: '$str'.")
fun fromString(str: String): ResourceType? =
ResourceType.values().firstOrNull { it.typeName.equals(str, true) }
}
}
@ -26,16 +25,22 @@ internal data class ResourceItem(
val type: ResourceType,
val qualifiers: List<String>,
val name: String,
val path: Path
val path: Path,
val offset: Long = -1,
val size: Long = -1,
)
private fun ResourceType.getClassName(): ClassName = when (this) {
ResourceType.DRAWABLE -> ClassName("org.jetbrains.compose.resources", "DrawableResource")
ResourceType.FONT -> ClassName("org.jetbrains.compose.resources", "FontResource")
ResourceType.STRING -> ClassName("org.jetbrains.compose.resources", "StringResource")
ResourceType.STRING_ARRAY -> ClassName("org.jetbrains.compose.resources", "StringArrayResource")
ResourceType.PLURAL_STRING -> ClassName("org.jetbrains.compose.resources", "PluralStringResource")
ResourceType.FONT -> ClassName("org.jetbrains.compose.resources", "FontResource")
}
private fun ResourceType.requiresKeyName() =
this in setOf(ResourceType.STRING, ResourceType.STRING_ARRAY, ResourceType.PLURAL_STRING)
private val resourceItemClass = ClassName("org.jetbrains.compose.resources", "ResourceItem")
private val experimentalAnnotation = AnnotationSpec.builder(
ClassName("org.jetbrains.compose.resources", "ExperimentalResourceApi")
@ -156,7 +161,7 @@ internal fun getResFileSpecs(
.build()
)
ResourceType.values().forEach { type ->
resObject.addType(TypeSpec.objectBuilder(type.typeName).build())
resObject.addType(TypeSpec.objectBuilder(type.accessorName).build())
}
}.build())
}.build()
@ -191,7 +196,7 @@ private fun getChunkFileSpec(
resModifier: KModifier,
idToResources: Map<String, List<ResourceItem>>
): FileSpec {
val chunkClassName = type.typeName.uppercaseFirstChar() + index
val chunkClassName = type.accessorName.uppercaseFirstChar() + index
return FileSpec.builder(packageName, chunkClassName).also { chunkFile ->
chunkFile.addAnnotation(
AnnotationSpec.builder(ClassName("kotlin", "OptIn"))
@ -213,7 +218,7 @@ private fun getChunkFileSpec(
idToResources.forEach { (resName, items) ->
val accessor = PropertySpec.builder(resName, type.getClassName(), resModifier)
.receiver(ClassName(packageName, "Res", type.typeName))
.receiver(ClassName(packageName, "Res", type.accessorName))
.addAnnotation(experimentalAnnotation)
.getter(FunSpec.getterBuilder().addStatement("return $chunkClassName.$resName").build())
.build()
@ -227,14 +232,15 @@ private fun getChunkFileSpec(
CodeBlock.builder()
.add("return %T(\n", type.getClassName()).withIndent {
add("\"${type}:${resName}\",")
if (type == ResourceType.STRING || type == ResourceType.PLURAL_STRING) add(" \"$resName\",")
if (type.requiresKeyName()) add(" \"$resName\",")
withIndent {
add("\nsetOf(\n").withIndent {
items.forEach { item ->
add("%T(", resourceItemClass)
add("setOf(").addQualifiers(item).add("), ")
//file separator should be '/' on all platforms
add("\"$moduleDir${item.path.invariantSeparatorsPathString}\"")
add("\"$moduleDir${item.path.invariantSeparatorsPathString}\", ")
add("${item.offset}, ${item.size}")
add("),\n")
}
}

132
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/IosResources.kt

@ -0,0 +1,132 @@
package org.jetbrains.compose.resources
import org.gradle.api.Project
import org.gradle.api.file.Directory
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.Copy
import org.jetbrains.compose.desktop.application.internal.ComposeProperties
import org.jetbrains.compose.internal.utils.*
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension
import org.jetbrains.kotlin.gradle.plugin.mpp.*
import org.jetbrains.kotlin.konan.target.KonanTarget
import java.io.File
private const val COCOAPODS_PLUGIN_ID = "org.jetbrains.kotlin.native.cocoapods"
private const val IOS_COMPOSE_RESOURCES_ROOT_DIR = "compose-resources"
internal fun Project.configureSyncIosComposeResources(
kotlinExtension: KotlinMultiplatformExtension
) {
if (ComposeProperties.dontSyncResources(project).get()) {
logger.info(
"Compose Multiplatform resource management for iOS is disabled: " +
"'${ComposeProperties.SYNC_RESOURCES_PROPERTY}' value is 'false'"
)
return
}
kotlinExtension.targets.withType(KotlinNativeTarget::class.java).all { nativeTarget ->
if (nativeTarget.isIosTarget()) {
nativeTarget.binaries.withType(Framework::class.java).all { iosFramework ->
val frameworkClassifier = iosFramework.getClassifier()
val checkNoSandboxTask = tasks.registerOrConfigure<CheckCanAccessComposeResourcesDirectory>(
"checkCanSync${frameworkClassifier}ComposeResourcesForIos"
) {}
val frameworkResources = files()
iosFramework.compilation.allKotlinSourceSets.forAll { ss ->
frameworkResources.from(ss.resources.sourceDirectories)
}
val syncComposeResourcesTask = tasks.registerOrConfigure<SyncComposeResourcesForIosTask>(
iosFramework.getSyncResourcesTaskName()
) {
dependsOn(checkNoSandboxTask)
dependsOn(frameworkResources) //!!! explicit dependency because targetResources is not an input
outputDir.set(iosFramework.getFinalResourcesDir())
targetResources.put(iosFramework.target.konanTarget.name, frameworkResources)
}
val externalTaskName = if (iosFramework.isCocoapodsFramework()) {
"syncFramework"
} else {
"embedAndSign${frameworkClassifier}AppleFrameworkForXcode"
}
project.tasks.named(externalTaskName).dependsOn(syncComposeResourcesTask)
}
nativeTarget.binaries.withType(TestExecutable::class.java).all { testExec ->
val copyTestResourcesTask = tasks.registerOrConfigure<Copy>(
"copyTestComposeResourcesFor${testExec.target.targetName.uppercaseFirstChar()}"
) {
from({
(testExec.compilation.associatedCompilations + testExec.compilation).flatMap { compilation ->
compilation.allKotlinSourceSets.map { it.resources }
}
})
into(testExec.outputDirectory.resolve(IOS_COMPOSE_RESOURCES_ROOT_DIR))
}
testExec.linkTask.dependsOn(copyTestResourcesTask)
}
}
}
plugins.withId(COCOAPODS_PLUGIN_ID) {
project.extensions.getByType(CocoapodsExtension::class.java).apply {
framework { podFramework ->
val syncDir = podFramework.getFinalResourcesDir().get().asFile.relativeTo(projectDir)
val specAttr = "['${syncDir.path}']"
extraSpecAttributes["resources"] = specAttr
project.tasks.named("podInstall").configure {
it.doFirst {
if (extraSpecAttributes["resources"] != specAttr) {
error("""
|Kotlin.cocoapods.extraSpecAttributes["resources"] is not compatible with Compose Multiplatform's resources management for iOS.
| * Recommended action: remove extraSpecAttributes["resources"] from '${project.buildFile}' and run '${project.path}:podInstall' once;
| * Alternative action: turn off Compose Multiplatform's resources management for iOS by adding '${ComposeProperties.SYNC_RESOURCES_PROPERTY}=false' to your gradle.properties;
""".trimMargin())
}
syncDir.mkdirs()
}
}
}
}
}
}
private fun Framework.getClassifier(): String {
val suffix = joinLowerCamelCase(buildType.getName(), outputKind.taskNameClassifier)
return if (name == suffix) ""
else name.substringBeforeLast(suffix.uppercaseFirstChar()).uppercaseFirstChar()
}
internal fun Framework.getSyncResourcesTaskName() = "sync${getClassifier()}ComposeResourcesForIos"
private fun Framework.isCocoapodsFramework() = name.startsWith("pod")
private fun Framework.getFinalResourcesDir(): Provider<Directory> {
val providers = project.providers
return if (isCocoapodsFramework()) {
project.layout.buildDirectory.dir("compose/ios/$baseName/$IOS_COMPOSE_RESOURCES_ROOT_DIR/")
} else {
providers.environmentVariable("BUILT_PRODUCTS_DIR")
.zip(
providers.environmentVariable("CONTENTS_FOLDER_PATH")
) { builtProductsDir, contentsFolderPath ->
File("$builtProductsDir/$contentsFolderPath/$IOS_COMPOSE_RESOURCES_ROOT_DIR").canonicalPath
}
.flatMap {
project.objects.directoryProperty().apply { set(File(it)) }
}
}
}
private fun KotlinNativeTarget.isIosSimulatorTarget(): Boolean =
konanTarget === KonanTarget.IOS_X64 || konanTarget === KonanTarget.IOS_SIMULATOR_ARM64
private fun KotlinNativeTarget.isIosDeviceTarget(): Boolean =
konanTarget === KonanTarget.IOS_ARM64 || konanTarget === KonanTarget.IOS_ARM32
private fun KotlinNativeTarget.isIosTarget(): Boolean =
isIosSimulatorTarget() || isIosDeviceTarget()

148
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/IosResourcesTasks.kt

@ -0,0 +1,148 @@
package org.jetbrains.compose.resources
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.FileCollection
import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.*
import org.gradle.api.tasks.*
import org.jetbrains.kotlin.konan.target.KonanTarget
import javax.inject.Inject
internal abstract class SyncComposeResourcesForIosTask : DefaultTask() {
private fun Provider<String>.orElseThrowMissingAttributeError(attribute: String): Provider<String> {
val noProvidedValue = "__NO_PROVIDED_VALUE__"
return this.orElse(noProvidedValue).map {
if (it == noProvidedValue) {
error(
"Could not infer iOS target $attribute. Make sure to build " +
"via XCode (directly or via Kotlin Multiplatform Mobile plugin for Android Studio)"
)
}
it
}
}
@get:Inject
protected abstract val providers: ProviderFactory
@get:Inject
protected abstract val objects: ObjectFactory
@get:Input
val xcodeTargetPlatform: Provider<String> =
providers.gradleProperty("compose.ios.resources.platform")
.orElse(providers.environmentVariable("PLATFORM_NAME"))
.orElseThrowMissingAttributeError("platform")
@get:Input
val xcodeTargetArchs: Provider<List<String>> =
providers.gradleProperty("compose.ios.resources.archs")
.orElse(providers.environmentVariable("ARCHS"))
.orElseThrowMissingAttributeError("architectures")
.map { str -> str.split(",", " ").filter { it.isNotBlank() } }
@get:Internal
internal abstract val targetResources: MapProperty<String, FileCollection>
@get:PathSensitive(PathSensitivity.ABSOLUTE)
@get:InputFiles
val resourceFiles: Provider<FileCollection> =
xcodeTargetPlatform.zip(xcodeTargetArchs, ::Pair).map { (xcodeTargetPlatform, xcodeTargetArchs) ->
val allResources = getRequestedKonanTargetsByXcode(xcodeTargetPlatform, xcodeTargetArchs)
.mapNotNull { konanTarget -> targetResources.getting(konanTarget.name).get() }
objects.fileCollection().from(*allResources.toTypedArray())
}
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun run() {
val outputDir = outputDir.get().asFile
outputDir.deleteRecursively()
outputDir.mkdirs()
logger.info("Clean ${outputDir.path}")
resourceFiles.get().forEach { dir ->
if (dir.exists() && dir.isDirectory) {
logger.info("Copy '${dir.path}' to '${outputDir.path}'")
dir.walkTopDown().filter { !it.isDirectory && !it.isHidden }.forEach { file ->
val targetFile = outputDir.resolve(file.relativeTo(dir))
if (targetFile.exists()) {
logger.info("Skip [already exists] '${file.path}'")
} else {
logger.info(" -> '${file.path}'")
file.copyTo(targetFile)
}
}
} else {
logger.info("File '${dir.path}' is not a dir or doesn't exist")
}
}
}
}
// based on AppleSdk.kt from Kotlin Gradle Plugin
// See https://github.com/JetBrains/kotlin/blob/142421da5b966049b4eab44ce6856eb172cf122a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/mpp/apple/AppleSdk.kt
private fun getRequestedKonanTargetsByXcode(platform: String, archs: List<String>): List<KonanTarget> {
val targets: MutableSet<KonanTarget> = mutableSetOf()
when {
platform.startsWith("iphoneos") -> {
targets.addAll(archs.map { arch ->
when (arch) {
"arm64", "arm64e" -> KonanTarget.IOS_ARM64
"armv7", "armv7s" -> KonanTarget.IOS_ARM32
else -> error("Unknown iOS device arch: '$arch'")
}
})
}
platform.startsWith("iphonesimulator") -> {
targets.addAll(archs.map { arch ->
when (arch) {
"arm64", "arm64e" -> KonanTarget.IOS_SIMULATOR_ARM64
"x86_64" -> KonanTarget.IOS_X64
else -> error("Unknown iOS simulator arch: '$arch'")
}
})
}
else -> error("Unknown iOS platform: '$platform'")
}
return targets.toList()
}
/**
* Since Xcode 15, there is a new default setting: `ENABLE_USER_SCRIPT_SANDBOXING = YES`
* It's set in project.pbxproj
*
* SyncComposeResourcesForIosTask fails to work with it right now.
*
* Gradle attempts to create an output folder for SyncComposeResourcesForIosTask on our behalf,
* so we can't handle an exception when it occurs. Therefore, we make SyncComposeResourcesForIosTask
* depend on CheckCanAccessComposeResourcesDirectory, where we check ENABLE_USER_SCRIPT_SANDBOXING.
*/
internal abstract class CheckCanAccessComposeResourcesDirectory : DefaultTask() {
@get:Input
val enabled = project.providers.environmentVariable("ENABLE_USER_SCRIPT_SANDBOXING")
.orElse("NOT_DEFINED")
.map { it == "YES" }
@TaskAction
fun run() {
if (enabled.get()) {
logger.error("""
Failed to sync compose resources!
Please make sure ENABLE_USER_SCRIPT_SANDBOXING is set to 'NO' in 'project.pbxproj'
""".trimIndent())
throw IllegalStateException(
"Sandbox environment detected (ENABLE_USER_SCRIPT_SANDBOXING = YES). It's not supported so far."
)
}
}
}

123
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/KmpResources.kt

@ -0,0 +1,123 @@
package org.jetbrains.compose.resources
import org.gradle.api.Project
import org.gradle.api.provider.Provider
import org.jetbrains.kotlin.gradle.ComposeKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension
import org.jetbrains.kotlin.gradle.plugin.*
import org.jetbrains.kotlin.gradle.plugin.mpp.*
import org.jetbrains.kotlin.gradle.plugin.mpp.resources.KotlinTargetResourcesPublication
import java.io.File
@OptIn(ComposeKotlinGradlePluginApi::class)
internal fun Project.configureKmpResources(
kotlinExtension: KotlinProjectExtension,
kmpResources: Any,
preparedCommonResources: Provider<File>,
config: Provider<ResourcesExtension>
) {
kotlinExtension as KotlinMultiplatformExtension
kmpResources as KotlinTargetResourcesPublication
logger.info("Configure KMP resources")
//configure KMP resources publishing for each supported target
kotlinExtension.targets
.matching { target -> kmpResources.canPublishResources(target) }
.all { target ->
logger.info("Configure resources publication for '${target.targetName}' target")
val packedResourceDir = config.getModuleResourcesDir(project)
kmpResources.publishResourcesAsKotlinComponent(
target,
{ sourceSet ->
val sourceSetName = sourceSet.name
val composeResDir: Provider<File>
if (sourceSetName == KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME) {
composeResDir = preparedCommonResources
} else {
composeResDir = provider { project.file("src/$sourceSetName/$COMPOSE_RESOURCES_DIR") }
}
KotlinTargetResourcesPublication.ResourceRoot(
composeResDir,
emptyList(),
//for android target exclude fonts
if (target is KotlinAndroidTarget) listOf("**/font*/*") else emptyList()
)
},
packedResourceDir
)
if (target is KotlinAndroidTarget) {
//for android target publish fonts in assets
logger.info("Configure fonts relocation for '${target.targetName}' target")
kmpResources.publishInAndroidAssets(
target,
{ sourceSet ->
val sourceSetName = sourceSet.name
val composeResDir: Provider<File>
if (sourceSetName == KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME) {
composeResDir = preparedCommonResources
} else {
composeResDir = provider { project.file("src/$sourceSetName/$COMPOSE_RESOURCES_DIR") }
}
KotlinTargetResourcesPublication.ResourceRoot(
composeResDir,
listOf("**/font*/*"),
emptyList()
)
},
packedResourceDir
)
}
}
//generate accessors for common resources
kotlinExtension.sourceSets.all { sourceSet ->
val sourceSetName = sourceSet.name
if (sourceSetName == KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME) {
configureGenerationComposeResClass(
preparedCommonResources,
sourceSet,
config,
true
)
}
}
//add all resolved resources for browser and native compilations
val platformsForSetupCompilation = listOf(KotlinPlatformType.native, KotlinPlatformType.js, KotlinPlatformType.wasm)
kotlinExtension.targets
.matching { target -> target.platformType in platformsForSetupCompilation }
.all { target: KotlinTarget ->
val allResources = kmpResources.resolveResources(target)
target.compilations.all { compilation ->
if (compilation.name == KotlinCompilation.MAIN_COMPILATION_NAME) {
configureResourcesForCompilation(compilation, allResources)
}
}
}
}
/**
* Add resolved resources to a kotlin compilation to include it into a resulting platform artefact
* It is required for JS and Native targets.
* For JVM and Android it works automatically via jar files
*/
private fun Project.configureResourcesForCompilation(
compilation: KotlinCompilation<*>,
directoryWithAllResourcesForCompilation: Provider<File>
) {
logger.info("Add all resolved resources to '${compilation.target.targetName}' target '${compilation.name}' compilation")
compilation.defaultSourceSet.resources.srcDir(directoryWithAllResourcesForCompilation)
//JS packaging requires explicit dependency
if (compilation is KotlinJsCompilation) {
tasks.named(compilation.processResourcesTaskName).configure { processResourcesTask ->
processResourcesTask.dependsOn(directoryWithAllResourcesForCompilation)
}
}
}

235
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/PrepareComposeResources.kt

@ -0,0 +1,235 @@
package org.jetbrains.compose.resources
import org.gradle.api.DefaultTask
import org.gradle.api.Project
import org.gradle.api.file.*
import org.gradle.api.provider.Property
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.*
import org.w3c.dom.Node
import java.io.File
import java.util.*
import javax.inject.Inject
import javax.xml.parsers.DocumentBuilderFactory
internal fun Project.registerPrepareComposeResourcesTask(
userComposeResourcesDir: File,
preparedComposeResourcesDir: Provider<Directory>
): TaskProvider<PrepareComposeResourcesTask> {
val convertXmlValueResources = tasks.register(
"convertXmlValueResources",
XmlValuesConverterTask::class.java
) { task ->
task.originalResourcesDir.set(userComposeResourcesDir)
task.outputDir.set(preparedComposeResourcesDir)
}
val copyNonXmlValueResources = tasks.register(
"copyNonXmlValueResources",
CopyNonXmlValueResourcesTask::class.java
) { task ->
task.originalResourcesDir.set(userComposeResourcesDir)
task.outputDir.set(preparedComposeResourcesDir)
}
val prepareComposeResourcesTask = tasks.register(
"prepareComposeResourcesTask",
PrepareComposeResourcesTask::class.java
) { task ->
task.convertedXmls.set(convertXmlValueResources.map { it.realOutputFiles.get() })
task.copiedNonXmls.set(copyNonXmlValueResources.map { it.realOutputFiles.get() })
task.outputDir.set(preparedComposeResourcesDir.map { it.asFile })
}
return prepareComposeResourcesTask
}
internal abstract class CopyNonXmlValueResourcesTask : DefaultTask() {
@get:Inject
abstract val fileSystem: FileSystemOperations
@get:Internal
abstract val originalResourcesDir: DirectoryProperty
@get:InputFiles
val realInputFiles = originalResourcesDir.map { dir ->
dir.asFileTree.matching { it.exclude("values*/*.xml") }
}
@get:Internal
abstract val outputDir: DirectoryProperty
@get:OutputFiles
val realOutputFiles = outputDir.map { dir ->
dir.asFileTree.matching { it.exclude("values*/*.${XmlValuesConverterTask.CONVERTED_RESOURCE_EXT}") }
}
@TaskAction
fun run() {
realOutputFiles.get().forEach { f -> f.delete() }
fileSystem.copy { copy ->
copy.includeEmptyDirs = false
copy.from(originalResourcesDir) {
it.exclude("values*/*.xml")
}
copy.into(outputDir)
}
}
}
internal abstract class PrepareComposeResourcesTask : DefaultTask() {
@get:InputFiles
abstract val convertedXmls: Property<FileTree>
@get:InputFiles
abstract val copiedNonXmls: Property<FileTree>
@get:OutputDirectory
abstract val outputDir: Property<File>
@TaskAction
fun run() {}
}
internal data class ValueResourceRecord(
val type: ResourceType,
val key: String,
val content: String
) {
fun getAsString(): String {
return listOf(type.typeName, key, content).joinToString(SEPARATOR)
}
companion object {
private const val SEPARATOR = "|"
fun createFromString(string: String): ValueResourceRecord {
val parts = string.split(SEPARATOR)
return ValueResourceRecord(
ResourceType.fromString(parts[0])!!,
parts[1],
parts[2]
)
}
}
}
internal abstract class XmlValuesConverterTask : DefaultTask() {
companion object {
const val CONVERTED_RESOURCE_EXT = "cvr" //Compose Value Resource
private const val FORMAT_VERSION = 0
}
@get:Internal
abstract val originalResourcesDir: DirectoryProperty
@get:InputFiles
val realInputFiles = originalResourcesDir.map { dir ->
dir.asFileTree.matching { it.include("values*/*.xml") }
}
@get:Internal
abstract val outputDir: DirectoryProperty
@get:OutputFiles
val realOutputFiles = outputDir.map { dir ->
dir.asFileTree.matching { it.include("values*/*.$CONVERTED_RESOURCE_EXT") }
}
@TaskAction
fun run() {
val outDir = outputDir.get().asFile
realOutputFiles.get().forEach { f -> f.delete() }
originalResourcesDir.get().asFile.listNotHiddenFiles().forEach { valuesDir ->
if (valuesDir.isDirectory && valuesDir.name.startsWith("values")) {
valuesDir.listNotHiddenFiles().forEach { f ->
if (f.extension.equals("xml", true)) {
val output = outDir
.resolve(f.parentFile.name)
.resolve(f.nameWithoutExtension + ".$CONVERTED_RESOURCE_EXT")
output.parentFile.mkdirs()
convert(f, output)
}
}
}
}
}
private fun convert(original: File, converted: File) {
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(original)
val items = doc.getElementsByTagName("resources").item(0).childNodes
val records = List(items.length) { items.item(it) }.mapNotNull { getItemRecord(it)?.getAsString() }
val fileContent = buildString {
appendLine("version:$FORMAT_VERSION")
records.sorted().forEach { appendLine(it) }
}
converted.writeText(fileContent)
}
private fun getItemRecord(node: Node): ValueResourceRecord? {
val type = ResourceType.fromString(node.nodeName) ?: return null
val key = node.attributes.getNamedItem("name").nodeValue
val value: String
when (type) {
ResourceType.STRING -> {
val content = handleSpecialCharacters(node.textContent)
value = content.asBase64()
}
ResourceType.STRING_ARRAY -> {
val children = node.childNodes
value = List(children.length) { children.item(it) }
.filter { it.nodeName == "item" }
.joinToString(",") { child ->
val content = handleSpecialCharacters(child.textContent)
content.asBase64()
}
}
ResourceType.PLURAL_STRING -> {
val children = node.childNodes
value = List(children.length) { children.item(it) }
.filter { it.nodeName == "item" }
.joinToString(",") { child ->
val content = handleSpecialCharacters(child.textContent)
val quantity = child.attributes.getNamedItem("quantity").nodeValue
quantity.uppercase() + ":" + content.asBase64()
}
}
else -> error("Unknown string resource type: '$type'")
}
return ValueResourceRecord(type, key, value)
}
private fun String.asBase64() =
Base64.getEncoder().encode(this.encodeToByteArray()).decodeToString()
}
//https://developer.android.com/guide/topics/resources/string-resource#escaping_quotes
/**
* Replaces
*
* '\n' -> new line
*
* '\t' -> tab
*
* '\uXXXX' -> unicode symbol
*
* '\\' -> '\'
*
* @param string The input string to handle.
* @return The string with special characters replaced according to the logic.
*/
internal fun handleSpecialCharacters(string: String): String {
val unicodeNewLineTabRegex = Regex("""\\u[a-fA-F\d]{4}|\\n|\\t""")
val doubleSlashRegex = Regex("""\\\\""")
val doubleSlashIndexes = doubleSlashRegex.findAll(string).map { it.range.first }
val handledString = unicodeNewLineTabRegex.replace(string) { matchResult ->
if (doubleSlashIndexes.contains(matchResult.range.first - 1)) matchResult.value
else when (matchResult.value) {
"\\n" -> "\n"
"\\t" -> "\t"
else -> matchResult.value.substring(2).toInt(16).toChar().toString()
}
}.replace("""\\""", """\""")
return handledString
}

65
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResClassGeneration.kt

@ -0,0 +1,65 @@
package org.jetbrains.compose.resources
import org.gradle.api.Project
import org.gradle.api.provider.Provider
import org.jetbrains.compose.ComposePlugin
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet
import java.io.File
internal fun Project.configureGenerationComposeResClass(
commonComposeResourcesDir: Provider<File>,
commonSourceSet: KotlinSourceSet,
config: Provider<ResourcesExtension>,
generateModulePath: Boolean
) {
logger.info("Configure accessors for '${commonSourceSet.name}'")
//lazy check a dependency on the Resources library
val shouldGenerateResClass = config.map {
when (it.generateResClass) {
ResourcesExtension.ResourceClassGeneration.Auto -> {
configurations.run {
//because the implementation configuration doesn't extend the api in the KGP ¯\_(ツ)_/¯
getByName(commonSourceSet.implementationConfigurationName).allDependencies +
getByName(commonSourceSet.apiConfigurationName).allDependencies
}.any { dep ->
val depStringNotation = dep.let { "${it.group}:${it.name}:${it.version}" }
depStringNotation == ComposePlugin.CommonComponentsDependencies.resources
}
}
ResourcesExtension.ResourceClassGeneration.Always -> {
true
}
ResourcesExtension.ResourceClassGeneration.Never -> {
false
}
}
}
val genTask = tasks.register(
"generateComposeResClass",
GenerateResClassTask::class.java
) { task ->
task.packageName.set(config.getResourcePackage(project))
task.shouldGenerateResClass.set(shouldGenerateResClass)
task.makeResClassPublic.set(config.map { it.publicResClass })
task.resDir.set(commonComposeResourcesDir)
task.codeDir.set(layout.buildDirectory.dir("$RES_GEN_DIR/kotlin").map { it.asFile })
if (generateModulePath) {
task.moduleDir.set(config.getModuleResourcesDir(project))
}
}
//register generated source set
commonSourceSet.kotlin.srcDir(genTask.map { it.codeDir })
//setup task execution during IDE import
tasks.configureEach {
if (it.name == "prepareKotlinIdeaImport") {
it.dependsOn(genTask)
}
}
}

18
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesExtension.kt → gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesDSL.kt

@ -1,5 +1,9 @@
package org.jetbrains.compose.resources
import org.gradle.api.Project
import org.gradle.api.provider.Provider
import java.io.File
abstract class ResourcesExtension {
/**
* Whether the generated resources accessors class should be public or not.
@ -32,4 +36,16 @@ abstract class ResourcesExtension {
* - `never`: Never generate the Res class.
*/
var generateResClass: ResourceClassGeneration = auto
}
}
internal fun Provider<ResourcesExtension>.getResourcePackage(project: Project) = map { config ->
config.packageOfResClass.takeIf { it.isNotEmpty() } ?: run {
val groupName = project.group.toString().lowercase().asUnderscoredIdentifier()
val moduleName = project.name.lowercase().asUnderscoredIdentifier()
val id = if (groupName.isNotEmpty()) "$groupName.$moduleName" else moduleName
"$id.generated.resources"
}
}
//the dir where resources must be placed in the final artefact
internal fun Provider<ResourcesExtension>.getModuleResourcesDir(project: Project) =
getResourcePackage(project).map { packageName -> File("$COMPOSE_RESOURCES_DIR/$packageName") }

381
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesGenerator.kt

@ -1,381 +0,0 @@
package org.jetbrains.compose.resources
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.gradle.BaseExtension
import org.gradle.api.DefaultTask
import org.gradle.api.Project
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.FileSystemOperations
import org.gradle.api.provider.Property
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.*
import org.gradle.util.GradleVersion
import org.jetbrains.compose.ComposePlugin
import org.jetbrains.compose.desktop.application.internal.ComposeProperties
import org.jetbrains.compose.internal.KOTLIN_JVM_PLUGIN_ID
import org.jetbrains.compose.internal.KOTLIN_MPP_PLUGIN_ID
import org.jetbrains.compose.internal.utils.registerTask
import org.jetbrains.compose.internal.utils.uppercaseFirstChar
import org.jetbrains.compose.resources.ios.getSyncResourcesTaskName
import org.jetbrains.kotlin.gradle.ComposeKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension
import org.jetbrains.kotlin.gradle.plugin.*
import org.jetbrains.kotlin.gradle.plugin.mpp.*
import org.jetbrains.kotlin.gradle.plugin.mpp.resources.KotlinTargetResourcesPublication
import org.jetbrains.kotlin.gradle.plugin.sources.android.androidSourceSetInfoOrNull
import org.jetbrains.kotlin.gradle.utils.ObservableSet
import java.io.File
import javax.inject.Inject
private const val COMPOSE_RESOURCES_DIR = "composeResources"
private const val RES_GEN_DIR = "generated/compose/resourceGenerator"
private const val KMP_RES_EXT = "multiplatformResourcesPublication"
private const val MIN_GRADLE_VERSION_FOR_KMP_RESOURCES = "7.6"
private val androidPluginIds = listOf(
"com.android.application",
"com.android.library"
)
internal fun Project.configureComposeResources(config: ResourcesExtension) {
val resourcePackage = provider {
config.packageOfResClass.takeIf { it.isNotEmpty() } ?: run {
val groupName = project.group.toString().lowercase().asUnderscoredIdentifier()
val moduleName = project.name.lowercase().asUnderscoredIdentifier()
val id = if (groupName.isNotEmpty()) "$groupName.$moduleName" else moduleName
"$id.generated.resources"
}
}
val publicResClass = provider { config.publicResClass }
val generateResClassMode = provider { config.generateResClass }
plugins.withId(KOTLIN_MPP_PLUGIN_ID) {
val kotlinExtension = project.extensions.getByType(KotlinMultiplatformExtension::class.java)
val hasKmpResources = extraProperties.has(KMP_RES_EXT)
val currentGradleVersion = GradleVersion.current()
val minGradleVersion = GradleVersion.version(MIN_GRADLE_VERSION_FOR_KMP_RESOURCES)
if (hasKmpResources && currentGradleVersion >= minGradleVersion) {
configureKmpResources(
kotlinExtension,
extraProperties.get(KMP_RES_EXT)!!,
resourcePackage,
publicResClass,
generateResClassMode
)
} else {
if (!hasKmpResources) {
logger.info(
"""
Compose resources publication requires Kotlin Gradle Plugin >= 2.0
Current Kotlin Gradle Plugin is ${KotlinVersion.CURRENT}
""".trimIndent()
)
}
if (currentGradleVersion < minGradleVersion) {
logger.info(
"""
Compose resources publication requires Gradle >= $MIN_GRADLE_VERSION_FOR_KMP_RESOURCES
Current Gradle is ${currentGradleVersion.version}
""".trimIndent()
)
}
//current KGP doesn't have KPM resources
configureComposeResources(
kotlinExtension,
KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME,
resourcePackage,
publicResClass,
generateResClassMode
)
//when applied AGP then configure android resources
androidPluginIds.forEach { pluginId ->
plugins.withId(pluginId) {
val androidExtension = project.extensions.getByType(BaseExtension::class.java)
configureAndroidComposeResources(kotlinExtension, androidExtension)
}
}
}
}
plugins.withId(KOTLIN_JVM_PLUGIN_ID) {
val kotlinExtension = project.extensions.getByType(KotlinProjectExtension::class.java)
configureComposeResources(
kotlinExtension,
SourceSet.MAIN_SOURCE_SET_NAME,
resourcePackage,
publicResClass,
generateResClassMode
)
}
}
private fun Project.configureComposeResources(
kotlinExtension: KotlinProjectExtension,
commonSourceSetName: String,
resourcePackage: Provider<String>,
publicResClass: Provider<Boolean>,
generateResClassMode: Provider<ResourcesExtension.ResourceClassGeneration>
) {
logger.info("Configure compose resources")
kotlinExtension.sourceSets.all { sourceSet ->
val sourceSetName = sourceSet.name
val composeResourcesPath = project.projectDir.resolve("src/$sourceSetName/$COMPOSE_RESOURCES_DIR")
//To compose resources will be packed to a final artefact we need to mark them as resources
//sourceSet.resources works for all targets except ANDROID!
sourceSet.resources.srcDirs(composeResourcesPath)
if (sourceSetName == commonSourceSetName) {
configureResourceGenerator(
composeResourcesPath,
sourceSet,
resourcePackage,
publicResClass,
generateResClassMode,
false
)
}
}
}
@OptIn(ComposeKotlinGradlePluginApi::class)
private fun Project.configureKmpResources(
kotlinExtension: KotlinProjectExtension,
kmpResources: Any,
resourcePackage: Provider<String>,
publicResClass: Provider<Boolean>,
generateResClassMode: Provider<ResourcesExtension.ResourceClassGeneration>
) {
kotlinExtension as KotlinMultiplatformExtension
kmpResources as KotlinTargetResourcesPublication
logger.info("Configure KMP resources")
//configure KMP resources publishing for each supported target
kotlinExtension.targets
.matching { target -> kmpResources.canPublishResources(target) }
.all { target ->
logger.info("Configure resources publication for '${target.targetName}' target")
kmpResources.publishResourcesAsKotlinComponent(
target,
{ sourceSet ->
KotlinTargetResourcesPublication.ResourceRoot(
project.provider { project.file("src/${sourceSet.name}/$COMPOSE_RESOURCES_DIR") },
emptyList(),
//for android target exclude fonts
if (target is KotlinAndroidTarget) listOf("**/font*/*") else emptyList()
)
},
resourcePackage.asModuleDir()
)
if (target is KotlinAndroidTarget) {
//for android target publish fonts in assets
logger.info("Configure fonts relocation for '${target.targetName}' target")
kmpResources.publishInAndroidAssets(
target,
{ sourceSet ->
KotlinTargetResourcesPublication.ResourceRoot(
project.provider { project.file("src/${sourceSet.name}/$COMPOSE_RESOURCES_DIR") },
listOf("**/font*/*"),
emptyList()
)
},
resourcePackage.asModuleDir()
)
}
}
//generate accessors for common resources
kotlinExtension.sourceSets.all { sourceSet ->
val sourceSetName = sourceSet.name
if (sourceSetName == KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME) {
val composeResourcesPath = project.projectDir.resolve("src/$sourceSetName/$COMPOSE_RESOURCES_DIR")
configureResourceGenerator(
composeResourcesPath,
sourceSet,
resourcePackage,
publicResClass,
generateResClassMode,
true
)
}
}
//add all resolved resources for browser and native compilations
val platformsForSetupCompilation = listOf(KotlinPlatformType.native, KotlinPlatformType.js, KotlinPlatformType.wasm)
kotlinExtension.targets
.matching { target -> target.platformType in platformsForSetupCompilation }
.all { target: KotlinTarget ->
val allResources = kmpResources.resolveResources(target)
target.compilations.all { compilation ->
if (compilation.name == KotlinCompilation.MAIN_COMPILATION_NAME) {
configureResourcesForCompilation(compilation, allResources)
}
}
}
}
/**
* Add resolved resources to a kotlin compilation to include it into a resulting platform artefact
* It is required for JS and Native targets.
* For JVM and Android it works automatically via jar files
*/
private fun Project.configureResourcesForCompilation(
compilation: KotlinCompilation<*>,
directoryWithAllResourcesForCompilation: Provider<File>
) {
logger.info("Add all resolved resources to '${compilation.target.targetName}' target '${compilation.name}' compilation")
compilation.defaultSourceSet.resources.srcDir(directoryWithAllResourcesForCompilation)
if (compilation is KotlinJsCompilation) {
tasks.named(compilation.processResourcesTaskName).configure { processResourcesTask ->
processResourcesTask.dependsOn(directoryWithAllResourcesForCompilation)
}
}
if (compilation is KotlinNativeCompilation) {
compilation.target.binaries.withType(Framework::class.java).all { framework ->
tasks.configureEach { task ->
if (task.name == framework.getSyncResourcesTaskName()) {
task.dependsOn(directoryWithAllResourcesForCompilation)
}
}
}
}
}
@OptIn(ExperimentalKotlinGradlePluginApi::class)
private fun Project.configureAndroidComposeResources(
kotlinExtension: KotlinMultiplatformExtension,
androidExtension: BaseExtension
) {
//mark all composeResources as Android resources
kotlinExtension.targets.withType(KotlinAndroidTarget::class.java).all { androidTarget ->
androidTarget.compilations.all { compilation: KotlinJvmAndroidCompilation ->
compilation.defaultSourceSet.androidSourceSetInfoOrNull?.let { kotlinAndroidSourceSet ->
androidExtension.sourceSets
.matching { it.name == kotlinAndroidSourceSet.androidSourceSetName }
.all { androidSourceSet ->
(compilation.allKotlinSourceSets as? ObservableSet<KotlinSourceSet>)?.forAll { kotlinSourceSet ->
androidSourceSet.resources.srcDir(
projectDir.resolve("src/${kotlinSourceSet.name}/$COMPOSE_RESOURCES_DIR")
)
}
}
}
}
}
//copy fonts from the compose resources dir to android assets
val commonResourcesDir = projectDir.resolve(
"src/${KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME}/$COMPOSE_RESOURCES_DIR"
)
val androidComponents = project.extensions.findByType(AndroidComponentsExtension::class.java) ?: return
androidComponents.onVariants { variant ->
val copyFonts = registerTask<CopyAndroidFontsToAssetsTask>(
"copy${variant.name.uppercaseFirstChar()}FontsToAndroidAssets"
) {
from.set(commonResourcesDir)
}
variant.sources?.assets?.addGeneratedSourceDirectory(
taskProvider = copyFonts,
wiredWith = CopyAndroidFontsToAssetsTask::outputDirectory
)
//exclude a duplication of fonts in apks
variant.packaging.resources.excludes.add("**/font*/*")
}
}
private fun Project.configureResourceGenerator(
commonComposeResourcesDir: File,
commonSourceSet: KotlinSourceSet,
resourcePackage: Provider<String>,
publicResClass: Provider<Boolean>,
generateResClassMode: Provider<ResourcesExtension.ResourceClassGeneration>,
generateModulePath: Boolean
) {
logger.info("Configure accessors for '${commonSourceSet.name}'")
fun buildDir(path: String) = layout.dir(layout.buildDirectory.map { File(it.asFile, path) })
//lazy check a dependency on the Resources library
val shouldGenerateResClass = generateResClassMode.map { mode ->
when (mode) {
ResourcesExtension.ResourceClassGeneration.Auto -> {
//todo remove the gradle property when the gradle plugin will be published
if (ComposeProperties.alwaysGenerateResourceAccessors(project).get()) {
true
} else {
configurations.run {
//because the implementation configuration doesn't extend the api in the KGP ¯\_(ツ)_/¯
getByName(commonSourceSet.implementationConfigurationName).allDependencies +
getByName(commonSourceSet.apiConfigurationName).allDependencies
}.any { dep ->
val depStringNotation = dep.let { "${it.group}:${it.name}:${it.version}" }
depStringNotation == ComposePlugin.CommonComponentsDependencies.resources
}
}
}
ResourcesExtension.ResourceClassGeneration.Always -> {
true
}
ResourcesExtension.ResourceClassGeneration.Never -> {
false
}
}
}
val genTask = tasks.register(
"generateComposeResClass",
GenerateResClassTask::class.java
) { task ->
task.packageName.set(resourcePackage)
task.shouldGenerateResClass.set(shouldGenerateResClass)
task.makeResClassPublic.set(publicResClass)
task.resDir.set(commonComposeResourcesDir)
task.codeDir.set(buildDir("$RES_GEN_DIR/kotlin"))
if (generateModulePath) {
task.moduleDir.set(resourcePackage.asModuleDir())
}
}
//register generated source set
commonSourceSet.kotlin.srcDir(genTask.map { it.codeDir })
//setup task execution during IDE import
tasks.configureEach {
if (it.name == "prepareKotlinIdeaImport") {
it.dependsOn(genTask)
}
}
}
private fun Provider<String>.asModuleDir() = map { File("$COMPOSE_RESOURCES_DIR/$it") }
//Copy task doesn't work with 'variant.sources?.assets?.addGeneratedSourceDirectory' API
internal abstract class CopyAndroidFontsToAssetsTask : DefaultTask() {
@get:Inject
abstract val fileSystem: FileSystemOperations
@get:InputFiles
@get:IgnoreEmptyDirectories
abstract val from: Property<File>
@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty
@TaskAction
fun action() {
fileSystem.copy {
it.includeEmptyDirs = false
it.from(from)
it.include("**/font*/*")
it.into(outputDirectory)
}
}
}

56
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ios/IosTargetResources.kt

@ -1,56 +0,0 @@
/*
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.resources.ios
import org.gradle.api.provider.Property
import org.gradle.api.provider.SetProperty
import org.gradle.api.tasks.Input
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import java.io.Serializable
internal abstract class IosTargetResources : Serializable {
@get:Input
abstract val name: Property<String>
@get:Input
abstract val konanTarget: Property<String>
@get:Input
abstract val dirs: SetProperty<String>
@Suppress("unused") // used by Gradle Configuration Cache
fun readObject(input: ObjectInputStream) {
name.set(input.readUTF())
konanTarget.set(input.readUTF())
dirs.set(input.readUTFStrings())
}
@Suppress("unused") // used by Gradle Configuration Cache
fun writeObject(output: ObjectOutputStream) {
output.writeUTF(name.get())
output.writeUTF(konanTarget.get())
output.writeUTFStrings(dirs.get())
}
private fun ObjectOutputStream.writeUTFStrings(collection: Collection<String>) {
writeInt(collection.size)
collection.forEach { writeUTF(it) }
}
private fun ObjectInputStream.readUTFStrings(): Set<String> {
val size = readInt()
return LinkedHashSet<String>(size).apply {
repeat(size) {
add(readUTF())
}
}
}
companion object {
private const val serialVersionUID: Long = 0
}
}

108
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ios/SyncComposeResourcesForIosTask.kt

@ -1,108 +0,0 @@
/*
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.resources.ios
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.FileCollection
import org.gradle.api.provider.Provider
import org.gradle.api.provider.SetProperty
import org.gradle.api.tasks.*
import org.jetbrains.compose.experimental.uikit.tasks.AbstractComposeIosTask
import org.jetbrains.compose.internal.utils.clearDirs
import java.io.File
import kotlin.io.path.Path
import kotlin.io.path.pathString
import kotlin.io.path.relativeTo
abstract class SyncComposeResourcesForIosTask : AbstractComposeIosTask() {
private fun Provider<String>.orElseThrowMissingAttributeError(attribute: String): Provider<String> {
val noProvidedValue = "__NO_PROVIDED_VALUE__"
return this.orElse(noProvidedValue).map {
if (it == noProvidedValue) {
error(
"Could not infer iOS target $attribute. Make sure to build " +
"via XCode (directly or via Kotlin Multiplatform Mobile plugin for Android Studio)")
}
it
}
}
@get:Input
val xcodeTargetPlatform: Provider<String> =
providers.gradleProperty("compose.ios.resources.platform")
.orElse(providers.environmentVariable("PLATFORM_NAME"))
.orElseThrowMissingAttributeError("platform")
@get:Input
val xcodeTargetArchs: Provider<List<String>> =
providers.gradleProperty("compose.ios.resources.archs")
.orElse(providers.environmentVariable("ARCHS"))
.orElseThrowMissingAttributeError("architectures")
.map {
it.split(",", " ").filter { it.isNotBlank() }
}
@get:Input
internal val iosTargets: SetProperty<IosTargetResources> = objects.setProperty(IosTargetResources::class.java)
@get:PathSensitive(PathSensitivity.ABSOLUTE)
@get:InputFiles
val resourceFiles: Provider<FileCollection> = xcodeTargetPlatform.zip(xcodeTargetArchs, ::Pair)
.map { (xcodeTargetPlatform, xcodeTargetArchs) ->
val allResources = objects.fileCollection()
val activeKonanTargets = determineIosKonanTargetsFromEnv(xcodeTargetPlatform, xcodeTargetArchs)
.mapTo(HashSet()) { it.name }
val dirsToInclude = iosTargets.get()
.filter { it.konanTarget.get() in activeKonanTargets }
.flatMapTo(HashSet()) { it.dirs.get() }
for (dirPath in dirsToInclude) {
val fileTree = objects.fileTree().apply {
setDir(layout.projectDirectory.dir(dirPath))
include("**/*")
}
allResources.from(fileTree)
}
allResources
}
@get:OutputDirectory
val outputDir: DirectoryProperty = objects.directoryProperty()
@TaskAction
fun run() {
val outputDir = outputDir.get().asFile
fileOperations.clearDirs(outputDir)
val allResourceDirs = iosTargets.get().flatMapTo(HashSet()) { it.dirs.get().map { Path(it).toAbsolutePath() } }
fun copyFileToOutputDir(file: File) {
for (dir in allResourceDirs) {
val path = file.toPath().toAbsolutePath()
if (path.startsWith(dir)) {
val targetFile = outputDir.resolve(path.relativeTo(dir).pathString)
file.copyTo(targetFile, overwrite = true)
return
}
}
error(
buildString {
appendLine("Resource file '$file' does not belong to a known resource directory:")
allResourceDirs.forEach {
appendLine("* $it")
}
}
)
}
val resourceFiles = resourceFiles.get().files
for (file in resourceFiles) {
copyFileToOutputDir(file)
}
logger.info("Synced Compose resource files. Copied ${resourceFiles.size} files to $outputDir")
}
}

265
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ios/configureSyncIosResources.kt

@ -1,265 +0,0 @@
/*
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.resources.ios
import org.gradle.api.Project
import org.gradle.api.file.Directory
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.Copy
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskContainer
import org.gradle.api.tasks.TaskAction
import org.jetbrains.compose.desktop.application.internal.ComposeProperties
import org.jetbrains.compose.experimental.uikit.internal.utils.asIosNativeTargetOrNull
import org.jetbrains.compose.experimental.uikit.internal.utils.cocoapodsExt
import org.jetbrains.compose.experimental.uikit.internal.utils.withCocoapodsPlugin
import org.jetbrains.compose.experimental.uikit.tasks.AbstractComposeIosTask
import org.jetbrains.compose.internal.utils.joinLowerCamelCase
import org.jetbrains.compose.internal.utils.new
import org.jetbrains.compose.internal.utils.registerOrConfigure
import org.jetbrains.compose.internal.utils.uppercaseFirstChar
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.mpp.*
import java.io.File
private val incompatiblePlugins = listOf(
"dev.icerock.mobile.multiplatform-resources",
"io.github.skeptick.libres",
)
private const val IOS_COMPOSE_RESOURCES_ROOT_DIR = "compose-resources"
internal fun Project.configureSyncTask(mppExt: KotlinMultiplatformExtension) {
fun reportSyncIsDisabled(reason: String) {
logger.info("Compose Multiplatform resource management for iOS is disabled: $reason")
}
if (!ComposeProperties.syncResources(providers).get()) {
reportSyncIsDisabled("'${ComposeProperties.SYNC_RESOURCES_PROPERTY}' value is 'false'")
return
}
for (incompatiblePluginId in incompatiblePlugins) {
if (project.plugins.hasPlugin(incompatiblePluginId)) {
reportSyncIsDisabled("resource management is not compatible with '$incompatiblePluginId'")
return
}
}
with(SyncIosResourcesContext(project, mppExt)) {
configureSyncResourcesTasks()
configureCocoapodsResourcesAttribute()
}
}
private class SyncIosResourcesContext(
val project: Project,
val mppExt: KotlinMultiplatformExtension
) {
fun syncDirFor(framework: Framework): Provider<Directory> {
val providers = framework.project.providers
return if (framework.isCocoapodsFramework) {
project.layout.buildDirectory.dir("compose/ios/${framework.baseName}/$IOS_COMPOSE_RESOURCES_ROOT_DIR/")
} else {
providers.environmentVariable("BUILT_PRODUCTS_DIR")
.zip(providers.environmentVariable("CONTENTS_FOLDER_PATH")) { builtProductsDir, contentsFolderPath ->
File(builtProductsDir)
.resolve(contentsFolderPath)
.resolve(IOS_COMPOSE_RESOURCES_ROOT_DIR)
.canonicalPath
}.flatMap {
framework.project.objects.directoryProperty().apply { set(File(it)) }
}
}
}
fun configureEachIosTestExecutable(fn: (TestExecutable) -> Unit) {
mppExt.targets.all { target ->
target.asIosNativeTargetOrNull()?.let { iosTarget ->
iosTarget.binaries.withType(TestExecutable::class.java).configureEach { bin ->
fn(bin)
}
}
}
}
fun configureEachIosFramework(fn: (Framework) -> Unit) {
mppExt.targets.all { target ->
target.asIosNativeTargetOrNull()?.let { iosTarget ->
iosTarget.binaries.withType(Framework::class.java).configureEach { framework ->
fn(framework)
}
}
}
}
}
private const val RESOURCES_SPEC_ATTR = "resources"
private fun SyncIosResourcesContext.configureCocoapodsResourcesAttribute() {
project.withCocoapodsPlugin {
project.gradle.taskGraph.whenReady {
val cocoapodsExt = mppExt.cocoapodsExt
val specAttributes = cocoapodsExt.extraSpecAttributes
val resourcesSpec = specAttributes[RESOURCES_SPEC_ATTR]
if (!resourcesSpec.isNullOrBlank()) {
error("""
|Kotlin.cocoapods.extraSpecAttributes["resources"] is not compatible with Compose Multiplatform's resources management for iOS.
| * Recommended action: remove extraSpecAttributes["resources"] from '${project.buildFile}' and run '${project.path}:podInstall' once;
| * Alternative action: turn off Compose Multiplatform's resources management for iOS by adding '${ComposeProperties.SYNC_RESOURCES_PROPERTY}=false' to your gradle.properties;
""".trimMargin())
}
cocoapodsExt.framework {
val syncDir = syncDirFor(this).get().asFile
specAttributes[RESOURCES_SPEC_ATTR] = "['${syncDir.relativeTo(project.projectDir).path}']"
project.tasks.named("podInstall").configure {
it.doFirst {
syncDir.mkdirs()
}
}
}
}
}
}
/**
* Since Xcode 15, there is a new default setting: `ENABLE_USER_SCRIPT_SANDBOXING = YES`
* It's set in project.pbxproj
*
* SyncComposeResourcesForIosTask fails to work with it right now.
*
* Gradle attempts to create an output folder for SyncComposeResourcesForIosTask on our behalf,
* so we can't handle an exception when it occurs. Therefore, we make SyncComposeResourcesForIosTask
* depend on CheckCanAccessComposeResourcesDirectory, where we check ENABLE_USER_SCRIPT_SANDBOXING.
*/
internal abstract class CheckCanAccessComposeResourcesDirectory : AbstractComposeIosTask() {
@get:Input
val enabled = providers.environmentVariable("ENABLE_USER_SCRIPT_SANDBOXING")
.orElse("NOT_DEFINED")
.map { it == "YES" }
private val errorMessage = """
|Failed to sync compose resources!
|Please make sure ENABLE_USER_SCRIPT_SANDBOXING is set to 'NO' in 'project.pbxproj'
|""".trimMargin()
@TaskAction
fun run() {
if (enabled.get()) {
logger.error(errorMessage)
throw IllegalStateException(
"Sandbox environment detected (ENABLE_USER_SCRIPT_SANDBOXING = YES). It's not supported so far."
)
}
}
}
private fun SyncIosResourcesContext.configureSyncResourcesTasks() {
val lazyTasksDependencies = LazyTasksDependencyConfigurator(project.tasks)
configureEachIosFramework { framework ->
val frameworkClassifier = framework.namePrefix.uppercaseFirstChar()
val syncResourcesTaskName = framework.getSyncResourcesTaskName()
val checkSyncResourcesTaskName = "checkCanSync${frameworkClassifier}ComposeResourcesForIos"
val checkNoSandboxTask = framework.project.tasks.registerOrConfigure<CheckCanAccessComposeResourcesDirectory>(checkSyncResourcesTaskName) {}
val syncTask = framework.project.tasks.registerOrConfigure<SyncComposeResourcesForIosTask>(syncResourcesTaskName) {
dependsOn(checkNoSandboxTask)
outputDir.set(syncDirFor(framework))
iosTargets.add(iosTargetResourcesProvider(framework))
}
with (lazyTasksDependencies) {
if (framework.isCocoapodsFramework) {
"syncFramework".lazyDependsOn(syncTask.name)
} else {
"embedAndSign${frameworkClassifier}AppleFrameworkForXcode".lazyDependsOn(syncTask.name)
}
}
}
configureEachIosTestExecutable { bin ->
val copyTestResourcesTask = "copyTestComposeResourcesFor${bin.target.targetName.uppercaseFirstChar()}"
val task = project.tasks.registerOrConfigure<Copy>(copyTestResourcesTask) {
from({
(bin.compilation.associateWith + bin.compilation).flatMap { compilation ->
compilation.allKotlinSourceSets.map { it.resources }
}
})
into(bin.outputDirectory.resolve(IOS_COMPOSE_RESOURCES_ROOT_DIR))
}
bin.linkTask.dependsOn(task)
}
}
internal fun Framework.getSyncResourcesTaskName() =
"sync${namePrefix.uppercaseFirstChar()}ComposeResourcesForIos"
private val Framework.isCocoapodsFramework: Boolean
get() = name.startsWith("pod")
private val Framework.namePrefix: String
get() = extractPrefixFromBinaryName(
name,
buildType,
outputKind.taskNameClassifier
)
private fun extractPrefixFromBinaryName(name: String, buildType: NativeBuildType, outputKindClassifier: String): String {
val suffix = joinLowerCamelCase(buildType.getName(), outputKindClassifier)
return if (name == suffix)
""
else
name.substringBeforeLast(suffix.uppercaseFirstChar())
}
private fun iosTargetResourcesProvider(bin: NativeBinary): Provider<IosTargetResources> {
val kotlinTarget = bin.target
val project = bin.project
return project.provider {
val resourceDirs = bin.compilation.allKotlinSourceSets
.flatMap { sourceSet ->
sourceSet.resources.srcDirs.map { it.canonicalPath }
}
project.objects.new<IosTargetResources>().apply {
name.set(kotlinTarget.name)
konanTarget.set(kotlinTarget.konanTarget.name)
dirs.set(resourceDirs)
}
}
}
/**
* Ensures, that a dependency between tasks is set up,
* when a dependent task (fromTask) is created, while avoiding eager configuration.
*/
private class LazyTasksDependencyConfigurator(private val tasks: TaskContainer) {
private val existingDependencies = HashSet<Pair<String, String>>()
private val requestedDependencies = HashMap<String, MutableSet<String>>()
init {
tasks.configureEach { fromTask ->
val onTasks = requestedDependencies.remove(fromTask.name) ?: return@configureEach
for (onTaskName in onTasks) {
val dependency = fromTask.name to onTaskName
if (existingDependencies.add(dependency)) {
fromTask.dependsOn(onTaskName)
}
}
}
}
fun String.lazyDependsOn(dependencyTask: String) {
val dependingTask = this
val dependency = dependingTask to dependencyTask
if (dependency in existingDependencies) return
if (dependingTask in tasks.names) {
tasks.named(dependingTask).configure { it.dependsOn(dependencyTask) }
existingDependencies.add(dependency)
} else {
requestedDependencies
.getOrPut(dependingTask) { HashSet() }
.add(dependencyTask)
}
}
}

38
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ios/determineIosKonanTargetsFromEnv.kt

@ -1,38 +0,0 @@
/*
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.resources.ios
import org.jetbrains.kotlin.konan.target.KonanTarget
// based on AppleSdk.kt from Kotlin Gradle Plugin
// See https://github.com/JetBrains/kotlin/blob/142421da5b966049b4eab44ce6856eb172cf122a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/mpp/apple/AppleSdk.kt
internal fun determineIosKonanTargetsFromEnv(platform: String, archs: List<String>): List<KonanTarget> {
val targets: MutableSet<KonanTarget> = mutableSetOf()
when {
platform.startsWith("iphoneos") -> {
targets.addAll(archs.map { arch ->
when (arch) {
"arm64", "arm64e" -> KonanTarget.IOS_ARM64
"armv7", "armv7s" -> KonanTarget.IOS_ARM32
else -> error("Unknown iOS device arch: '$arch'")
}
})
}
platform.startsWith("iphonesimulator") -> {
targets.addAll(archs.map { arch ->
when (arch) {
"arm64", "arm64e" -> KonanTarget.IOS_SIMULATOR_ARM64
"x86_64" -> KonanTarget.IOS_X64
else -> error("Unknown iOS simulator arch: '$arch'")
}
})
}
else -> error("Unknown iOS platform: '$platform'")
}
return targets.toList()
}

22
gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/GradlePluginTest.kt

@ -103,28 +103,6 @@ class GradlePluginTest : GradlePluginTestBase() {
}
}
@Test
fun iosMokoResources() {
Assumptions.assumeTrue(currentOS == OS.MacOS)
val iosTestEnv = iosTestEnv()
val testEnv = defaultTestEnvironment.copy(
additionalEnvVars = iosTestEnv.envVars
)
with(testProject(TestProjects.iosMokoResources, testEnv)) {
gradle(
":embedAndSignAppleFrameworkForXcode",
":copyFrameworkResourcesToApp",
"--dry-run",
"--info"
).checks {
// This test is not intended to actually run embedAndSignAppleFrameworkForXcode.
// Instead, it should check that the sync disables itself.
check.logContains("Compose Multiplatform resource management for iOS is disabled")
check.logDoesntContain(":syncComposeResourcesForIos")
}
}
}
@Test
fun nativeCacheKind() {
Assumptions.assumeTrue(currentOS == OS.MacOS)

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

@ -2,6 +2,7 @@ package org.jetbrains.compose.test.tests.integration
import org.gradle.util.GradleVersion
import org.jetbrains.compose.internal.utils.*
import org.jetbrains.compose.resources.XmlValuesConverterTask
import org.jetbrains.compose.test.utils.*
import org.junit.jupiter.api.Test
import java.io.File
@ -163,7 +164,7 @@ class ResourcesTest : GradlePluginTestBase() {
val resDir = file("cmplib/src/commonMain/composeResources")
val resourcesFiles = resDir.walkTopDown()
.filter { !it.isDirectory && !it.isHidden }
.map { it.relativeTo(resDir).invariantSeparatorsPath }
.getConvertedResources(resDir)
val subdir = "me.sample.library.resources"
fun libpath(target: String, ext: String) =
@ -293,7 +294,7 @@ class ResourcesTest : GradlePluginTestBase() {
val commonResourcesDir = file("src/commonMain/composeResources")
val commonResourcesFiles = commonResourcesDir.walkTopDown()
.filter { !it.isDirectory && !it.isHidden }
.map { it.relativeTo(commonResourcesDir).invariantSeparatorsPath }
.getConvertedResources(commonResourcesDir)
gradle("build").checks {
check.taskSuccessful(":copyDemoDebugFontsToAndroidAssets")
@ -351,7 +352,7 @@ class ResourcesTest : GradlePluginTestBase() {
val commonResourcesDir = file("src/commonMain/composeResources")
val commonResourcesFiles = commonResourcesDir.walkTopDown()
.filter { !it.isDirectory && !it.isHidden }
.map { it.relativeTo(commonResourcesDir).invariantSeparatorsPath }
.getConvertedResources(commonResourcesDir)
gradle("assembleDebug").checks {
check.taskSuccessful(":copyDebugFontsToAndroidAssets")
@ -366,7 +367,7 @@ class ResourcesTest : GradlePluginTestBase() {
)
val newCommonResourcesFiles = commonResourcesDir.walkTopDown()
.filter { !it.isDirectory && !it.isHidden }
.map { it.relativeTo(commonResourcesDir).invariantSeparatorsPath }
.getConvertedResources(commonResourcesDir)
gradle("assembleDebug").checks {
check.taskSuccessful(":copyDebugFontsToAndroidAssets")
@ -376,6 +377,19 @@ class ResourcesTest : GradlePluginTestBase() {
}
}
private fun Sequence<File>.getConvertedResources(baseDir: File) = map { file ->
val newFile = if (
file.parentFile.name.startsWith("value") &&
file.extension.equals("xml", true)
) {
file.parentFile.resolve(file.nameWithoutExtension + "." + XmlValuesConverterTask.CONVERTED_RESOURCE_EXT)
} else {
file
}
newFile.relativeTo(baseDir).invariantSeparatorsPath
}
private fun File.writeNewFile(text: String) {
parentFile.mkdirs()
createNewFile()
@ -434,22 +448,12 @@ class ResourcesTest : GradlePluginTestBase() {
assertFalse(file("build/generated/compose/resourceGenerator/kotlin/app/group/resources_test/generated/resources/Res.kt").exists())
}
gradle("prepareKotlinIdeaImport", "-Pcompose.resources.always.generate.accessors=true").checks {
check.taskSuccessful(":generateComposeResClass")
assertTrue(file("build/generated/compose/resourceGenerator/kotlin/app/group/resources_test/generated/resources/Res.kt").exists())
}
modifyText("build.gradle.kts") { str ->
str.replace(
"//api(compose.components.resources)",
"api(compose.components.resources)"
)
}
gradle("prepareKotlinIdeaImport").checks {
check.taskUpToDate(":generateComposeResClass")
assertTrue(file("build/generated/compose/resourceGenerator/kotlin/app/group/resources_test/generated/resources/Res.kt").exists())
}
modifyText("build.gradle.kts") { str ->
str.replace(
"group = \"app.group\"",

16
gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/unit/TestEscapedResourceSymbols.kt

@ -0,0 +1,16 @@
package org.jetbrains.compose.test.tests.unit
import org.jetbrains.compose.resources.handleSpecialCharacters
import kotlin.test.Test
import kotlin.test.assertEquals
class TestEscapedResourceSymbols {
@Test
fun testEscapedSymbols() {
assertEquals(
"abc \n \\n \t \\t \u1234 \ua45f \\u1234 \\ \\u355g",
handleSpecialCharacters("""abc \n \\n \t \\t \u1234 \ua45f \\u1234 \\ \u355g""")
)
}
}

63
gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected-open-res/Drawable0.kt

@ -8,17 +8,17 @@ import org.jetbrains.compose.resources.ExperimentalResourceApi
@ExperimentalResourceApi
private object Drawable0 {
public val _3_strange_name: DrawableResource by
lazy { init__3_strange_name() }
public val _3_strange_name: DrawableResource by
lazy { init__3_strange_name() }
public val camelCaseName: DrawableResource by
lazy { init_camelCaseName() }
public val camelCaseName: DrawableResource by
lazy { init_camelCaseName() }
public val vector: DrawableResource by
lazy { init_vector() }
public val vector: DrawableResource by
lazy { init_vector() }
public val vector_2: DrawableResource by
lazy { init_vector_2() }
public val vector_2: DrawableResource by
lazy { init_vector_2() }
}
@ExperimentalResourceApi
@ -27,12 +27,12 @@ public val Res.drawable._3_strange_name: DrawableResource
@ExperimentalResourceApi
private fun init__3_strange_name(): DrawableResource =
org.jetbrains.compose.resources.DrawableResource(
"drawable:_3_strange_name",
org.jetbrains.compose.resources.DrawableResource(
"drawable:_3_strange_name",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/3-strange-name.xml"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/3-strange-name.xml", -1, -1),
)
)
)
@ExperimentalResourceApi
public val Res.drawable.camelCaseName: DrawableResource
@ -40,12 +40,12 @@ public val Res.drawable.camelCaseName: DrawableResource
@ExperimentalResourceApi
private fun init_camelCaseName(): DrawableResource =
org.jetbrains.compose.resources.DrawableResource(
"drawable:camelCaseName",
org.jetbrains.compose.resources.DrawableResource(
"drawable:camelCaseName",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/camelCaseName.xml"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/camelCaseName.xml", -1, -1),
)
)
)
@ExperimentalResourceApi
public val Res.drawable.vector: DrawableResource
@ -54,21 +54,22 @@ public val Res.drawable.vector: DrawableResource
@ExperimentalResourceApi
private fun init_vector(): DrawableResource = org.jetbrains.compose.resources.DrawableResource(
"drawable:vector",
setOf(
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(org.jetbrains.compose.resources.LanguageQualifier("ast"),
), "drawable-ast/vector.xml"),
), "drawable-ast/vector.xml", -1, -1),
org.jetbrains.compose.resources.ResourceItem(setOf(org.jetbrains.compose.resources.LanguageQualifier("au"),
org.jetbrains.compose.resources.RegionQualifier("US"), ), "drawable-au-rUS/vector.xml"),
org.jetbrains.compose.resources.RegionQualifier("US"), ), "drawable-au-rUS/vector.xml", -1, -1),
org.jetbrains.compose.resources.ResourceItem(setOf(org.jetbrains.compose.resources.ThemeQualifier.DARK,
org.jetbrains.compose.resources.LanguageQualifier("ge"), ), "drawable-dark-ge/vector.xml"),
org.jetbrains.compose.resources.LanguageQualifier("ge"), ),
"drawable-dark-ge/vector.xml", -1, -1),
org.jetbrains.compose.resources.ResourceItem(setOf(org.jetbrains.compose.resources.LanguageQualifier("en"),
), "drawable-en/vector.xml"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/vector.xml"),
)
), "drawable-en/vector.xml", -1, -1),
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/vector.xml", -1, -1),
)
)
@ExperimentalResourceApi
@ -78,7 +79,7 @@ public val Res.drawable.vector_2: DrawableResource
@ExperimentalResourceApi
private fun init_vector_2(): DrawableResource = org.jetbrains.compose.resources.DrawableResource(
"drawable:vector_2",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/vector_2.xml"),
)
)
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/vector_2.xml", -1, -1),
)
)

10
gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected-open-res/Font0.kt

@ -8,8 +8,8 @@ import org.jetbrains.compose.resources.FontResource
@ExperimentalResourceApi
private object Font0 {
public val emptyFont: FontResource by
lazy { init_emptyFont() }
public val emptyFont: FontResource by
lazy { init_emptyFont() }
}
@ExperimentalResourceApi
@ -22,7 +22,7 @@ private fun init_emptyFont(): FontResource = org.jetbrains.compose.resources.Fon
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(org.jetbrains.compose.resources.LanguageQualifier("en"),
), "font-en/emptyFont.otf"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "font/emptyFont.otf"),
), "font-en/emptyFont.otf", -1, -1),
org.jetbrains.compose.resources.ResourceItem(setOf(), "font/emptyFont.otf", -1, -1),
)
)
)

2
gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected-open-res/Plurals0.kt

@ -21,6 +21,6 @@ private fun init_numberOfSongsAvailable(): PluralStringResource =
org.jetbrains.compose.resources.PluralStringResource(
"plurals:numberOfSongsAvailable", "numberOfSongsAvailable",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.cvr", 10, 124),
)
)

2
gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected-open-res/Res.kt

@ -27,6 +27,8 @@ public object Res {
public object string
public object array
public object plurals
public object font

87
gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected-open-res/String0.kt

@ -8,29 +8,26 @@ import org.jetbrains.compose.resources.StringResource
@ExperimentalResourceApi
private object String0 {
public val PascalCase: StringResource by
lazy { init_PascalCase() }
public val PascalCase: StringResource by
lazy { init_PascalCase() }
public val _1_kebab_case: StringResource by
lazy { init__1_kebab_case() }
public val _1_kebab_case: StringResource by
lazy { init__1_kebab_case() }
public val app_name: StringResource by
lazy { init_app_name() }
public val app_name: StringResource by
lazy { init_app_name() }
public val camelCase: StringResource by
lazy { init_camelCase() }
public val camelCase: StringResource by
lazy { init_camelCase() }
public val hello: StringResource by
lazy { init_hello() }
public val hello: StringResource by
lazy { init_hello() }
public val multi_line: StringResource by
lazy { init_multi_line() }
public val multi_line: StringResource by
lazy { init_multi_line() }
public val str_arr: StringResource by
lazy { init_str_arr() }
public val str_template: StringResource by
lazy { init_str_template() }
public val str_template: StringResource by
lazy { init_str_template() }
}
@ExperimentalResourceApi
@ -40,9 +37,9 @@ public val Res.string.PascalCase: StringResource
@ExperimentalResourceApi
private fun init_PascalCase(): StringResource = org.jetbrains.compose.resources.StringResource(
"string:PascalCase", "PascalCase",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
)
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.cvr", 172, 34),
)
)
@ExperimentalResourceApi
@ -52,9 +49,9 @@ public val Res.string._1_kebab_case: StringResource
@ExperimentalResourceApi
private fun init__1_kebab_case(): StringResource = org.jetbrains.compose.resources.StringResource(
"string:_1_kebab_case", "_1_kebab_case",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
)
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.cvr", 135, 36),
)
)
@ExperimentalResourceApi
@ -64,9 +61,9 @@ public val Res.string.app_name: StringResource
@ExperimentalResourceApi
private fun init_app_name(): StringResource = org.jetbrains.compose.resources.StringResource(
"string:app_name", "app_name",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
)
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.cvr", 207, 44),
)
)
@ExperimentalResourceApi
@ -76,9 +73,9 @@ public val Res.string.camelCase: StringResource
@ExperimentalResourceApi
private fun init_camelCase(): StringResource = org.jetbrains.compose.resources.StringResource(
"string:camelCase", "camelCase",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
)
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.cvr", 252, 29),
)
)
@ExperimentalResourceApi
@ -88,9 +85,9 @@ public val Res.string.hello: StringResource
@ExperimentalResourceApi
private fun init_hello(): StringResource = org.jetbrains.compose.resources.StringResource(
"string:hello", "hello",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
)
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.cvr", 282, 37),
)
)
@ExperimentalResourceApi
@ -100,21 +97,9 @@ public val Res.string.multi_line: StringResource
@ExperimentalResourceApi
private fun init_multi_line(): StringResource = org.jetbrains.compose.resources.StringResource(
"string:multi_line", "multi_line",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
)
)
@ExperimentalResourceApi
public val Res.string.str_arr: StringResource
get() = String0.str_arr
@ExperimentalResourceApi
private fun init_str_arr(): StringResource = org.jetbrains.compose.resources.StringResource(
"string:str_arr", "str_arr",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
)
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.cvr", 320, 178),
)
)
@ExperimentalResourceApi
@ -124,7 +109,7 @@ public val Res.string.str_template: StringResource
@ExperimentalResourceApi
private fun init_str_template(): StringResource = org.jetbrains.compose.resources.StringResource(
"string:str_template", "str_template",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
)
)
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.cvr", 499, 76),
)
)

63
gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected/Drawable0.kt

@ -8,17 +8,17 @@ import org.jetbrains.compose.resources.ExperimentalResourceApi
@ExperimentalResourceApi
private object Drawable0 {
public val _3_strange_name: DrawableResource by
lazy { init__3_strange_name() }
public val _3_strange_name: DrawableResource by
lazy { init__3_strange_name() }
public val camelCaseName: DrawableResource by
lazy { init_camelCaseName() }
public val camelCaseName: DrawableResource by
lazy { init_camelCaseName() }
public val vector: DrawableResource by
lazy { init_vector() }
public val vector: DrawableResource by
lazy { init_vector() }
public val vector_2: DrawableResource by
lazy { init_vector_2() }
public val vector_2: DrawableResource by
lazy { init_vector_2() }
}
@ExperimentalResourceApi
@ -27,12 +27,12 @@ internal val Res.drawable._3_strange_name: DrawableResource
@ExperimentalResourceApi
private fun init__3_strange_name(): DrawableResource =
org.jetbrains.compose.resources.DrawableResource(
"drawable:_3_strange_name",
org.jetbrains.compose.resources.DrawableResource(
"drawable:_3_strange_name",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/3-strange-name.xml"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/3-strange-name.xml", -1, -1),
)
)
)
@ExperimentalResourceApi
internal val Res.drawable.camelCaseName: DrawableResource
@ -40,12 +40,12 @@ internal val Res.drawable.camelCaseName: DrawableResource
@ExperimentalResourceApi
private fun init_camelCaseName(): DrawableResource =
org.jetbrains.compose.resources.DrawableResource(
"drawable:camelCaseName",
org.jetbrains.compose.resources.DrawableResource(
"drawable:camelCaseName",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/camelCaseName.xml"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/camelCaseName.xml", -1, -1),
)
)
)
@ExperimentalResourceApi
internal val Res.drawable.vector: DrawableResource
@ -54,21 +54,22 @@ internal val Res.drawable.vector: DrawableResource
@ExperimentalResourceApi
private fun init_vector(): DrawableResource = org.jetbrains.compose.resources.DrawableResource(
"drawable:vector",
setOf(
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(org.jetbrains.compose.resources.LanguageQualifier("ast"),
), "drawable-ast/vector.xml"),
), "drawable-ast/vector.xml", -1, -1),
org.jetbrains.compose.resources.ResourceItem(setOf(org.jetbrains.compose.resources.LanguageQualifier("au"),
org.jetbrains.compose.resources.RegionQualifier("US"), ), "drawable-au-rUS/vector.xml"),
org.jetbrains.compose.resources.RegionQualifier("US"), ), "drawable-au-rUS/vector.xml", -1, -1),
org.jetbrains.compose.resources.ResourceItem(setOf(org.jetbrains.compose.resources.ThemeQualifier.DARK,
org.jetbrains.compose.resources.LanguageQualifier("ge"), ), "drawable-dark-ge/vector.xml"),
org.jetbrains.compose.resources.LanguageQualifier("ge"), ),
"drawable-dark-ge/vector.xml", -1, -1),
org.jetbrains.compose.resources.ResourceItem(setOf(org.jetbrains.compose.resources.LanguageQualifier("en"),
), "drawable-en/vector.xml"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/vector.xml"),
)
), "drawable-en/vector.xml", -1, -1),
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/vector.xml", -1, -1),
)
)
@ExperimentalResourceApi
@ -78,7 +79,7 @@ internal val Res.drawable.vector_2: DrawableResource
@ExperimentalResourceApi
private fun init_vector_2(): DrawableResource = org.jetbrains.compose.resources.DrawableResource(
"drawable:vector_2",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/vector_2.xml"),
)
)
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/vector_2.xml", -1, -1),
)
)

4
gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected/Font0.kt

@ -22,7 +22,7 @@ private fun init_emptyFont(): FontResource = org.jetbrains.compose.resources.Fon
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(org.jetbrains.compose.resources.LanguageQualifier("en"),
), "font-en/emptyFont.otf"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "font/emptyFont.otf"),
), "font-en/emptyFont.otf", -1, -1),
org.jetbrains.compose.resources.ResourceItem(setOf(), "font/emptyFont.otf", -1, -1),
)
)

2
gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected/Plurals0.kt

@ -21,6 +21,6 @@ private fun init_numberOfSongsAvailable(): PluralStringResource =
org.jetbrains.compose.resources.PluralStringResource(
"plurals:numberOfSongsAvailable", "numberOfSongsAvailable",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.cvr", 10, 124),
)
)

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

@ -27,6 +27,8 @@ internal object Res {
public object string
public object array
public object plurals
public object font

87
gradle-plugins/compose/src/test/test-projects/misc/commonResources/expected/String0.kt

@ -8,123 +8,108 @@ import org.jetbrains.compose.resources.StringResource
@ExperimentalResourceApi
private object String0 {
public val PascalCase: StringResource by
lazy { init_PascalCase() }
public val PascalCase: StringResource by
lazy { init_PascalCase() }
public val _1_kebab_case: StringResource by
lazy { init__1_kebab_case() }
public val _1_kebab_case: StringResource by
lazy { init__1_kebab_case() }
public val app_name: StringResource by
lazy { init_app_name() }
public val app_name: StringResource by
lazy { init_app_name() }
public val camelCase: StringResource by
lazy { init_camelCase() }
public val camelCase: StringResource by
lazy { init_camelCase() }
public val hello: StringResource by
lazy { init_hello() }
public val hello: StringResource by
lazy { init_hello() }
public val multi_line: StringResource by
lazy { init_multi_line() }
public val multi_line: StringResource by
lazy { init_multi_line() }
public val str_arr: StringResource by
lazy { init_str_arr() }
public val str_template: StringResource by
lazy { init_str_template() }
public val str_template: StringResource by
lazy { init_str_template() }
}
@ExperimentalResourceApi
internal val Res.string.PascalCase: StringResource
get() = String0.PascalCase
get() = String0.PascalCase
@ExperimentalResourceApi
private fun init_PascalCase(): StringResource = org.jetbrains.compose.resources.StringResource(
"string:PascalCase", "PascalCase",
"string:PascalCase", "PascalCase",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.cvr", 172, 34),
)
)
@ExperimentalResourceApi
internal val Res.string._1_kebab_case: StringResource
get() = String0._1_kebab_case
get() = String0._1_kebab_case
@ExperimentalResourceApi
private fun init__1_kebab_case(): StringResource = org.jetbrains.compose.resources.StringResource(
"string:_1_kebab_case", "_1_kebab_case",
"string:_1_kebab_case", "_1_kebab_case",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.cvr", 135, 36),
)
)
@ExperimentalResourceApi
internal val Res.string.app_name: StringResource
get() = String0.app_name
get() = String0.app_name
@ExperimentalResourceApi
private fun init_app_name(): StringResource = org.jetbrains.compose.resources.StringResource(
"string:app_name", "app_name",
"string:app_name", "app_name",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.cvr", 207, 44),
)
)
@ExperimentalResourceApi
internal val Res.string.camelCase: StringResource
get() = String0.camelCase
get() = String0.camelCase
@ExperimentalResourceApi
private fun init_camelCase(): StringResource = org.jetbrains.compose.resources.StringResource(
"string:camelCase", "camelCase",
"string:camelCase", "camelCase",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.cvr", 252, 29),
)
)
@ExperimentalResourceApi
internal val Res.string.hello: StringResource
get() = String0.hello
get() = String0.hello
@ExperimentalResourceApi
private fun init_hello(): StringResource = org.jetbrains.compose.resources.StringResource(
"string:hello", "hello",
"string:hello", "hello",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.cvr", 282, 37),
)
)
@ExperimentalResourceApi
internal val Res.string.multi_line: StringResource
get() = String0.multi_line
get() = String0.multi_line
@ExperimentalResourceApi
private fun init_multi_line(): StringResource = org.jetbrains.compose.resources.StringResource(
"string:multi_line", "multi_line",
"string:multi_line", "multi_line",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
)
)
@ExperimentalResourceApi
internal val Res.string.str_arr: StringResource
get() = String0.str_arr
@ExperimentalResourceApi
private fun init_str_arr(): StringResource = org.jetbrains.compose.resources.StringResource(
"string:str_arr", "str_arr",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.cvr", 320, 178),
)
)
@ExperimentalResourceApi
internal val Res.string.str_template: StringResource
get() = String0.str_template
get() = String0.str_template
@ExperimentalResourceApi
private fun init_str_template(): StringResource = org.jetbrains.compose.resources.StringResource(
"string:str_template", "str_template",
"string:str_template", "str_template",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.xml"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "values/strings.cvr", 499, 76),
)
)
)

5
gradle-plugins/compose/src/test/test-projects/misc/commonResources/src/commonMain/composeResources/values/strings.xml

@ -5,11 +5,6 @@
consectetur adipiscing elit.
Donec eget turpis ac sem ultricies consequat.</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>
<string name="PascalCase">PascalCase</string>
<string name="1-kebab-case">1-kebab-case</string>
<string name="camelCase">camelCase</string>

2
gradle-plugins/compose/src/test/test-projects/misc/emptyResources/expected/Res.kt

@ -27,6 +27,8 @@ internal object Res {
public object string
public object array
public object plurals
public object font

53
gradle-plugins/compose/src/test/test-projects/misc/iosMokoResources/build.gradle

@ -1,53 +0,0 @@
plugins {
id "org.jetbrains.kotlin.multiplatform"
id "org.jetbrains.compose"
id "dev.icerock.mobile.multiplatform-resources"
}
kotlin {
iosX64 {
binaries.framework {
baseName = "shared"
isStatic = true
}
}
iosArm64 {
binaries.framework {
baseName = "shared"
isStatic = true
}
}
iosSimulatorArm64 {
binaries.framework {
baseName = "shared"
isStatic = true
}
}
sourceSets {
def commonMain = named("commonMain") {
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
implementation("dev.icerock.moko:resources-compose:MOKO_RESOURCES_PLUGIN_VERSION_PLACEHOLDER") // for compose multiplatform
}
}
def iosMain = create("iosMain") {
dependsOn(commonMain.get())
}
named("iosX64Main") {
dependsOn(iosMain)
}
named("iosArm64Main") {
dependsOn(iosMain)
}
named("iosSimulatorArm64Main") {
dependsOn(iosMain)
}
}
}
multiplatformResources {
multiplatformResourcesPackage = "org.example"
}

1
gradle-plugins/compose/src/test/test-projects/misc/iosMokoResources/gradle.properties

@ -1 +0,0 @@
org.jetbrains.compose.experimental.uikit.enabled=true

27
gradle-plugins/compose/src/test/test-projects/misc/iosMokoResources/settings.gradle

@ -1,27 +0,0 @@
pluginManagement {
plugins {
id 'org.jetbrains.kotlin.multiplatform' version 'KOTLIN_VERSION_PLACEHOLDER'
id 'org.jetbrains.compose' version 'COMPOSE_GRADLE_PLUGIN_VERSION_PLACEHOLDER'
id 'dev.icerock.mobile.multiplatform-resources' version 'MOKO_RESOURCES_PLUGIN_VERSION_PLACEHOLDER'
}
repositories {
mavenLocal()
gradlePluginPortal()
mavenCentral()
google()
maven {
url 'https://maven.pkg.jetbrains.space/public/p/compose/dev'
}
}
}
dependencyResolutionManagement {
repositories {
mavenLocal()
mavenCentral()
google()
maven {
url 'https://maven.pkg.jetbrains.space/public/p/compose/dev'
}
}
}
rootProject.name = "iosResources"

10
gradle-plugins/compose/src/test/test-projects/misc/iosMokoResources/src/commonMain/kotlin/App.kt

@ -1,10 +0,0 @@
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@Composable
fun App() {
MaterialTheme {
Text("Hello, World!")
}
}

4
gradle-plugins/compose/src/test/test-projects/misc/jvmOnlyResources/expected/Drawable0.kt

@ -9,7 +9,7 @@ import org.jetbrains.compose.resources.ExperimentalResourceApi
@ExperimentalResourceApi
private object Drawable0 {
public val vector: DrawableResource by
lazy { init_vector() }
lazy { init_vector() }
}
@ExperimentalResourceApi
@ -20,6 +20,6 @@ internal val Res.drawable.vector: DrawableResource
private fun init_vector(): DrawableResource = org.jetbrains.compose.resources.DrawableResource(
"drawable:vector",
setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/vector.xml"),
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/vector.xml", -1, -1),
)
)

2
gradle-plugins/compose/src/test/test-projects/misc/jvmOnlyResources/expected/Res.kt

@ -27,6 +27,8 @@ internal object Res {
public object string
public object array
public object plurals
public object font

Loading…
Cancel
Save