Browse Source

Support test resources in Compose UI tests. (#5122)

1) The PR adds a support test resources in Compose multiplatform
projects.
2) The PR adds a support multi-module resources in JVM-only projects.

Fixes https://youtrack.jetbrains.com/issue/CMP-1470
Fixes https://youtrack.jetbrains.com/issue/CMP-5963

## Release Notes
### Features - Resources
- Added support of test resources in Compose Multiplatform projects
- Added support of multi-module resources in JVM-only projects
release/1.7.0-beta01
Konstantin 3 months ago committed by GitHub
parent
commit
f86b9ed3fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      components/gradle/libs.versions.toml
  2. 4
      components/resources/library/build.gradle.kts
  3. 2
      components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/AndroidContextProvider.kt
  4. 24
      components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ResourceReader.android.kt
  5. 111
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/AndroidResources.kt
  6. 56
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/AssembleTargetResourcesTask.kt
  7. 55
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ComposeResources.kt
  8. 95
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/KmpResources.kt
  9. 144
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/MultimoduleResources.kt
  10. 34
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/SinglemoduleResources.kt
  11. 37
      gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/ResourcesTest.kt
  12. 6
      gradle-plugins/compose/src/test/test-projects/misc/jvmOnlyResources/expected/commonResClass/me/app/jvmonlyresources/generated/resources/Res.kt
  13. 3
      gradle-plugins/compose/src/test/test-projects/misc/jvmOnlyResources/expected/mainResourceAccessors/me/app/jvmonlyresources/generated/resources/Drawable0.main.kt
  14. 39
      gradle-plugins/compose/src/test/test-projects/misc/testResources/build.gradle.kts
  15. 3
      gradle-plugins/compose/src/test/test-projects/misc/testResources/gradle.properties
  16. 24
      gradle-plugins/compose/src/test/test-projects/misc/testResources/settings.gradle.kts
  17. 1
      gradle-plugins/compose/src/test/test-projects/misc/testResources/src/commonMain/composeResources/files/common.txt
  18. 3
      gradle-plugins/compose/src/test/test-projects/misc/testResources/src/commonMain/composeResources/values/strings.xml
  19. 7
      gradle-plugins/compose/src/test/test-projects/misc/testResources/src/commonMain/kotlin/App.kt
  20. 1
      gradle-plugins/compose/src/test/test-projects/misc/testResources/src/commonTest/composeResources/files/data.txt
  21. 3
      gradle-plugins/compose/src/test/test-projects/misc/testResources/src/commonTest/composeResources/values/strings.xml
  22. 34
      gradle-plugins/compose/src/test/test-projects/misc/testResources/src/commonTest/kotlin/CommonUiTest.kt
  23. 3
      gradle-plugins/compose/src/test/test-projects/misc/testResources/src/desktopMain/composeResources/values/desktop_strings.xml
  24. 3
      gradle-plugins/compose/src/test/test-projects/misc/testResources/src/desktopTest/composeResources/values/desktop_strings.xml
  25. 29
      gradle-plugins/compose/src/test/test-projects/misc/testResources/src/desktopTest/kotlin/DesktopUiTest.kt

3
components/gradle/libs.versions.toml

@ -3,7 +3,7 @@ kotlinx-coroutines = "1.8.0"
androidx-appcompat = "1.6.1" androidx-appcompat = "1.6.1"
androidx-activity-compose = "1.8.2" androidx-activity-compose = "1.8.2"
androidx-test = "1.5.0" androidx-test = "1.5.0"
androidx-compose = "1.6.0" androidx-compose = "1.6.1"
[libraries] [libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
@ -11,6 +11,7 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" }
androidx-test-monitor = { module = "androidx.test:monitor", version.ref = "androidx-test" }
androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test", version.ref = "androidx-compose" } androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test", version.ref = "androidx-compose" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose" } androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose" }
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidx-compose" } androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidx-compose" }

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

@ -113,6 +113,10 @@ kotlin {
} }
val androidMain by getting { val androidMain by getting {
dependsOn(jvmAndAndroidMain) dependsOn(jvmAndAndroidMain)
dependencies {
//it will be called only in android instrumented tests where the library should be available
compileOnly(libs.androidx.test.monitor)
}
} }
val androidInstrumentedTest by getting { val androidInstrumentedTest by getting {
dependsOn(jvmAndAndroidTest) dependsOn(jvmAndAndroidTest)

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

@ -10,8 +10,10 @@ import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.test.platform.app.InstrumentationRegistry
internal val androidContext get() = AndroidContextProvider.ANDROID_CONTEXT internal val androidContext get() = AndroidContextProvider.ANDROID_CONTEXT
internal val androidInstrumentedContext get() = InstrumentationRegistry.getInstrumentation().context
/** /**
* The function configures the android context * The function configures the android context

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

@ -2,6 +2,7 @@ package org.jetbrains.compose.resources
import android.content.res.AssetManager import android.content.res.AssetManager
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.ProvidableCompositionLocal
import java.io.FileNotFoundException import java.io.FileNotFoundException
@ -16,6 +17,14 @@ internal actual fun getPlatformResourceReader(): ResourceReader = object : Resou
context.assets context.assets
} }
private val instrumentedAssets: AssetManager?
get() = try {
androidInstrumentedContext.assets
} catch (e: NoClassDefFoundError) {
Log.d("ResourceReader", "Android Instrumentation context is not available.")
null
}
override suspend fun read(path: String): ByteArray { override suspend fun read(path: String): ByteArray {
val resource = getResourceAsStream(path) val resource = getResourceAsStream(path)
return resource.use { input -> input.readBytes() } return resource.use { input -> input.readBytes() }
@ -52,7 +61,7 @@ internal actual fun getPlatformResourceReader(): ResourceReader = object : Resou
} }
override fun getUri(path: String): String { override fun getUri(path: String): String {
val uri = if (assets.hasFile(path)) { val uri = if (assets.hasFile(path) || instrumentedAssets.hasFile(path)) {
Uri.parse("file:///android_asset/$path") Uri.parse("file:///android_asset/$path")
} else { } else {
val classLoader = getClassLoader() val classLoader = getClassLoader()
@ -66,8 +75,12 @@ internal actual fun getPlatformResourceReader(): ResourceReader = object : Resou
return try { return try {
assets.open(path) assets.open(path)
} catch (e: FileNotFoundException) { } catch (e: FileNotFoundException) {
val classLoader = getClassLoader() try {
classLoader.getResourceAsStream(path) ?: throw MissingResourceException(path) instrumentedAssets.open(path)
} catch (e: FileNotFoundException) {
val classLoader = getClassLoader()
classLoader.getResourceAsStream(path) ?: throw MissingResourceException(path)
}
} }
} }
@ -75,7 +88,7 @@ internal actual fun getPlatformResourceReader(): ResourceReader = object : Resou
return this.javaClass.classLoader ?: error("Cannot find class loader") return this.javaClass.classLoader ?: error("Cannot find class loader")
} }
private fun AssetManager.hasFile(path: String): Boolean { private fun AssetManager?.hasFile(path: String): Boolean {
var inputStream: InputStream? = null var inputStream: InputStream? = null
val result = try { val result = try {
inputStream = open(path) inputStream = open(path)
@ -87,6 +100,9 @@ internal actual fun getPlatformResourceReader(): ResourceReader = object : Resou
} }
return result return result
} }
private fun AssetManager?.open(path: String): InputStream =
this?.open(path) ?: throw FileNotFoundException("Current AssetManager is null.")
} }
internal actual val ProvidableCompositionLocal<ResourceReader>.currentOrPreview: ResourceReader internal actual val ProvidableCompositionLocal<ResourceReader>.currentOrPreview: ResourceReader

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

@ -1,7 +1,8 @@
package org.jetbrains.compose.resources package org.jetbrains.compose.resources
import com.android.build.api.variant.AndroidComponentsExtension import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.Variant import com.android.build.api.variant.Component
import com.android.build.api.variant.HasAndroidTest
import com.android.build.gradle.internal.lint.AndroidLintAnalysisTask import com.android.build.gradle.internal.lint.AndroidLintAnalysisTask
import com.android.build.gradle.internal.lint.LintModelWriterTask import com.android.build.gradle.internal.lint.LintModelWriterTask
import org.gradle.api.DefaultTask import org.gradle.api.DefaultTask
@ -10,23 +11,73 @@ import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.FileCollection import org.gradle.api.file.FileCollection
import org.gradle.api.file.FileSystemOperations import org.gradle.api.file.FileSystemOperations
import org.gradle.api.provider.Property import org.gradle.api.provider.Property
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.IgnoreEmptyDirectories import org.gradle.api.tasks.IgnoreEmptyDirectories
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskAction
import org.jetbrains.compose.internal.utils.registerTask import org.jetbrains.compose.internal.utils.registerTask
import org.jetbrains.compose.internal.utils.uppercaseFirstChar import org.jetbrains.compose.internal.utils.uppercaseFirstChar
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget
import java.io.File
import javax.inject.Inject import javax.inject.Inject
private fun Project.getAndroidVariantComposeResources( internal fun Project.configureAndroidComposeResources(moduleResourceDir: Provider<File>? = null) {
//copy all compose resources to android assets
val androidComponents = project.extensions.findByType(AndroidComponentsExtension::class.java) ?: return
androidComponents.onVariants { variant ->
configureGeneratedAndroidComponentAssets(variant, moduleResourceDir)
if (variant is HasAndroidTest) {
variant.androidTest?.let { androidTest ->
configureGeneratedAndroidComponentAssets(androidTest, moduleResourceDir)
}
}
}
}
private fun Project.configureGeneratedAndroidComponentAssets(
component: Component,
moduleResourceDir: Provider<File>?
) {
val kotlinExtension = project.extensions.findByType(KotlinMultiplatformExtension::class.java) ?: return
val camelComponentName = component.name.uppercaseFirstChar()
val componentAssets = getAndroidComponentComposeResources(kotlinExtension, component.name)
logger.info("Configure ${component.name} resources for 'android' target")
val copyComponentAssets = registerTask<CopyResourcesToAndroidAssetsTask>(
"copy${camelComponentName}ComposeResourcesToAndroidAssets"
) {
from.set(componentAssets)
moduleResourceDir?.let { relativeResourcePlacement.set(it) }
}
component.sources.assets?.addGeneratedSourceDirectory(
copyComponentAssets,
CopyResourcesToAndroidAssetsTask::outputDirectory
)
tasks.configureEach { task ->
//fix agp task dependencies for AndroidStudio preview
if (task.name == "compile${camelComponentName}Sources") {
task.dependsOn(copyComponentAssets)
}
//fix linter task dependencies for `build` task
if (task is AndroidLintAnalysisTask || task is LintModelWriterTask) {
task.mustRunAfter(copyComponentAssets)
}
}
}
private fun Project.getAndroidComponentComposeResources(
kotlinExtension: KotlinMultiplatformExtension, kotlinExtension: KotlinMultiplatformExtension,
variant: Variant componentName: String
): FileCollection = project.files({ ): FileCollection = project.files({
kotlinExtension.targets.withType(KotlinAndroidTarget::class.java).flatMap { androidTarget -> kotlinExtension.targets.withType(KotlinAndroidTarget::class.java).flatMap { androidTarget ->
androidTarget.compilations.flatMap { compilation -> androidTarget.compilations.flatMap { compilation ->
if (compilation.androidVariant.name == variant.name) { if (compilation.androidVariant.name == componentName) {
compilation.allKotlinSourceSets.map { kotlinSourceSet -> compilation.allKotlinSourceSets.map { kotlinSourceSet ->
getPreparedComposeResourcesDir(kotlinSourceSet) getPreparedComposeResourcesDir(kotlinSourceSet)
} }
@ -35,37 +86,6 @@ private fun Project.getAndroidVariantComposeResources(
} }
}) })
internal fun Project.configureAndroidComposeResources() {
//copy all compose resources to android assets
val androidComponents = project.extensions.findByType(AndroidComponentsExtension::class.java) ?: return
val kotlinExtension = project.extensions.findByType(KotlinMultiplatformExtension::class.java) ?: return
androidComponents.onVariants { variant ->
val camelVariantName = variant.name.uppercaseFirstChar()
val variantAssets = getAndroidVariantComposeResources(kotlinExtension, variant)
val copyVariantAssets = registerTask<CopyResourcesToAndroidAssetsTask>(
"copy${camelVariantName}ComposeResourcesToAndroidAssets"
) {
from.set(variantAssets)
}
variant.sources.assets?.addGeneratedSourceDirectory(
copyVariantAssets,
CopyResourcesToAndroidAssetsTask::outputDirectory
)
tasks.configureEach { task ->
//fix agp task dependencies for AndroidStudio preview
if (task.name == "compile${camelVariantName}Sources") {
task.dependsOn(copyVariantAssets)
}
//fix linter task dependencies for `build` task
if (task is AndroidLintAnalysisTask || task is LintModelWriterTask) {
task.mustRunAfter(copyVariantAssets)
}
}
}
}
//Copy task doesn't work with 'variant.sources?.assets?.addGeneratedSourceDirectory' API //Copy task doesn't work with 'variant.sources?.assets?.addGeneratedSourceDirectory' API
internal abstract class CopyResourcesToAndroidAssetsTask : DefaultTask() { internal abstract class CopyResourcesToAndroidAssetsTask : DefaultTask() {
@get:Inject @get:Inject
@ -75,6 +95,10 @@ internal abstract class CopyResourcesToAndroidAssetsTask : DefaultTask() {
@get:IgnoreEmptyDirectories @get:IgnoreEmptyDirectories
abstract val from: Property<FileCollection> abstract val from: Property<FileCollection>
@get:Input
@get:Optional
abstract val relativeResourcePlacement: Property<File>
@get:OutputDirectory @get:OutputDirectory
abstract val outputDirectory: DirectoryProperty abstract val outputDirectory: DirectoryProperty
@ -83,7 +107,11 @@ internal abstract class CopyResourcesToAndroidAssetsTask : DefaultTask() {
fileSystem.copy { fileSystem.copy {
it.includeEmptyDirs = false it.includeEmptyDirs = false
it.from(from) it.from(from)
it.into(outputDirectory) if (relativeResourcePlacement.isPresent) {
it.into(outputDirectory.dir(relativeResourcePlacement.get().path))
} else {
it.into(outputDirectory)
}
} }
} }
} }
@ -105,14 +133,3 @@ internal fun Project.fixAndroidLintTaskDependencies() {
it.mustRunAfter(tasks.withType(GenerateResourceAccessorsTask::class.java)) it.mustRunAfter(tasks.withType(GenerateResourceAccessorsTask::class.java))
} }
} }
internal fun Project.fixKgpAndroidPreviewTaskDependencies() {
val androidComponents = project.extensions.findByType(AndroidComponentsExtension::class.java) ?: return
androidComponents.onVariants { variant ->
tasks.configureEach { task ->
if (task.name == "compile${variant.name.uppercaseFirstChar()}Sources") {
task.dependsOn("${variant.name}AssetsCopyForAGP")
}
}
}
}

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

@ -0,0 +1,56 @@
package org.jetbrains.compose.resources
import org.gradle.api.DefaultTask
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.DuplicatesStrategy
import org.gradle.api.file.FileSystemOperations
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.work.DisableCachingByDefault
import java.io.File
import javax.inject.Inject
@DisableCachingByDefault(because = "There is no logic, just copy files")
internal abstract class AssembleTargetResourcesTask : DefaultTask() {
@get:Inject
abstract val fileSystem: FileSystemOperations
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val resourceDirectories: ConfigurableFileCollection
@get:Input
abstract val relativeResourcePlacement: Property<File>
@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty
@TaskAction
fun action() {
val outputDirectoryFile = outputDirectory.get().asFile
if (outputDirectoryFile.exists()) {
outputDirectoryFile.deleteRecursively()
}
outputDirectoryFile.mkdirs()
fileSystem.copy { copy ->
resourceDirectories.files.forEach { dir ->
copy.from(dir)
}
copy.into(outputDirectoryFile.resolve(relativeResourcePlacement.get()))
copy.duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
if (outputDirectoryFile.listFiles()?.isEmpty() != false) {
// Output an empty directory for the zip task
outputDirectoryFile.resolve(relativeResourcePlacement.get()).mkdirs()
}
}
}

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

@ -2,20 +2,18 @@ package org.jetbrains.compose.resources
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.provider.Provider import org.gradle.api.provider.Provider
import org.gradle.api.tasks.SourceSet
import org.gradle.util.GradleVersion import org.gradle.util.GradleVersion
import org.jetbrains.compose.desktop.application.internal.ComposeProperties import org.jetbrains.compose.desktop.application.internal.ComposeProperties
import org.jetbrains.compose.internal.KOTLIN_JVM_PLUGIN_ID import org.jetbrains.compose.internal.KOTLIN_JVM_PLUGIN_ID
import org.jetbrains.compose.internal.KOTLIN_MPP_PLUGIN_ID import org.jetbrains.compose.internal.KOTLIN_MPP_PLUGIN_ID
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinBasePlugin import org.jetbrains.kotlin.gradle.plugin.KotlinBasePlugin
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet
import org.jetbrains.kotlin.gradle.plugin.extraProperties import org.jetbrains.kotlin.gradle.plugin.extraProperties
internal const val COMPOSE_RESOURCES_DIR = "composeResources" internal const val COMPOSE_RESOURCES_DIR = "composeResources"
internal const val RES_GEN_DIR = "generated/compose/resourceGenerator" internal const val RES_GEN_DIR = "generated/compose/resourceGenerator"
private const val KMP_RES_EXT = "multiplatformResourcesPublication" internal const val KMP_RES_EXT = "multiplatformResourcesPublication"
private const val MIN_GRADLE_VERSION_FOR_KMP_RESOURCES = "7.6" private const val MIN_GRADLE_VERSION_FOR_KMP_RESOURCES = "7.6"
private val androidPluginIds = listOf( private val androidPluginIds = listOf(
"com.android.application", "com.android.application",
@ -38,11 +36,7 @@ private fun Project.onKgpApplied(config: Provider<ResourcesExtension>, kgp: Kotl
val kmpResourcesAreAvailable = !disableMultimoduleResources && hasKmpResources && currentGradleVersion >= minGradleVersion val kmpResourcesAreAvailable = !disableMultimoduleResources && hasKmpResources && currentGradleVersion >= minGradleVersion
if (kmpResourcesAreAvailable) { if (kmpResourcesAreAvailable) {
configureKmpResources(kotlinExtension, extraProperties.get(KMP_RES_EXT)!!, config) configureMultimoduleResources(kotlinExtension, config)
onAgpApplied {
fixKgpAndroidPreviewTaskDependencies()
fixAndroidLintTaskDependencies()
}
} else { } else {
if (!disableMultimoduleResources) { if (!disableMultimoduleResources) {
if (!hasKmpResources) logger.info( if (!hasKmpResources) logger.info(
@ -59,50 +53,21 @@ private fun Project.onKgpApplied(config: Provider<ResourcesExtension>, kgp: Kotl
) )
} }
val commonMain = KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME configureSinglemoduleResources(kotlinExtension, config)
configureComposeResources(kotlinExtension, commonMain, config)
onAgpApplied {
configureAndroidComposeResources()
fixAndroidLintTaskDependencies()
}
} }
configureSyncIosComposeResources(kotlinExtension) configureSyncIosComposeResources(kotlinExtension)
} }
private fun Project.onAgpApplied(block: () -> Unit) { internal fun Project.onKotlinJvmApplied(config: Provider<ResourcesExtension>) {
val kotlinExtension = project.extensions.getByType(KotlinJvmProjectExtension::class.java)
configureJvmOnlyResources(kotlinExtension, config)
}
internal fun Project.onAgpApplied(block: () -> Unit) {
androidPluginIds.forEach { pluginId -> androidPluginIds.forEach { pluginId ->
plugins.withId(pluginId) { plugins.withId(pluginId) {
block() block()
} }
} }
} }
private fun Project.onKotlinJvmApplied(config: Provider<ResourcesExtension>) {
val kotlinExtension = project.extensions.getByType(KotlinProjectExtension::class.java)
val main = SourceSet.MAIN_SOURCE_SET_NAME
configureComposeResources(kotlinExtension, main, config)
}
private fun Project.configureComposeResources(
kotlinExtension: KotlinProjectExtension,
resClassSourceSetName: String,
config: Provider<ResourcesExtension>
) {
logger.info("Configure compose resources")
configureComposeResourcesGeneration(kotlinExtension, resClassSourceSetName, config, false)
// mark prepared resources as sourceSet.resources
// 1) it automatically packs the resources to JVM jars
// 2) it configures the webpack to use the resources
// 3) for native targets we will use source set resources to pack them into the final app. see IosResources.kt
// 4) for the android it DOESN'T pack resources! we copy resources to assets in AndroidResources.kt
kotlinExtension.sourceSets.all { sourceSet ->
// the HACK is here because KGP copy androidMain java resources to Android target
// if the resources were registered in the androidMain source set before the target declaration
afterEvaluate {
sourceSet.resources.srcDirs(getPreparedComposeResourcesDir(sourceSet))
}
}
}

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

@ -1,95 +0,0 @@
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,
config: Provider<ResourcesExtension>
) {
kotlinExtension as KotlinMultiplatformExtension
kmpResources as KotlinTargetResourcesPublication
logger.info("Configure KMP resources")
val commonMain = KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME
configureComposeResourcesGeneration(kotlinExtension, commonMain, config, true)
//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)
if (target !is KotlinAndroidTarget) {
kmpResources.publishResourcesAsKotlinComponent(
target,
{ sourceSet ->
KotlinTargetResourcesPublication.ResourceRoot(
getPreparedComposeResourcesDir(sourceSet),
emptyList(),
emptyList()
)
},
packedResourceDir
)
} else {
//for android target publish resources in assets
kmpResources.publishInAndroidAssets(
target,
{ sourceSet ->
KotlinTargetResourcesPublication.ResourceRoot(
getPreparedComposeResourcesDir(sourceSet),
emptyList(),
emptyList()
)
},
packedResourceDir
)
}
}
//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)
}
}
}

144
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/MultimoduleResources.kt

@ -0,0 +1,144 @@
package org.jetbrains.compose.resources
import org.gradle.api.Project
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.SourceSet
import org.jetbrains.compose.internal.utils.registerTask
import org.jetbrains.compose.internal.utils.uppercaseFirstChar
import org.jetbrains.kotlin.gradle.ComposeKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet
import org.jetbrains.kotlin.gradle.plugin.KotlinTarget
import org.jetbrains.kotlin.gradle.plugin.extraProperties
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinJsCompilation
import org.jetbrains.kotlin.gradle.plugin.mpp.resources.KotlinTargetResourcesPublication
import java.io.File
//configure multi-module resources (with publishing and module isolation)
internal fun Project.configureMultimoduleResources(
kotlinExtension: KotlinMultiplatformExtension,
config: Provider<ResourcesExtension>
) {
logger.info("Configure multi-module compose resources")
val commonMain = KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME
configureComposeResourcesGeneration(kotlinExtension, commonMain, config, true)
val moduleIsolationDirectory = config.getModuleResourcesDir(project)
val platformsForSkip = listOf(
KotlinPlatformType.common, KotlinPlatformType.androidJvm
)
kotlinExtension.targets
.matching { target -> target.platformType !in platformsForSkip }
.all { target -> configureTargetResources(target, moduleIsolationDirectory) }
//configure ANDROID resources
onAgpApplied {
configureAndroidComposeResources(moduleIsolationDirectory)
fixAndroidLintTaskDependencies()
}
}
//configure java multi-module resources (with module isolation)
internal fun Project.configureJvmOnlyResources(
kotlinExtension: KotlinJvmProjectExtension,
config: Provider<ResourcesExtension>
) {
logger.info("Configure java-only compose resources")
val main = SourceSet.MAIN_SOURCE_SET_NAME
configureComposeResourcesGeneration(kotlinExtension, main, config, true)
val moduleIsolationDirectory = config.getModuleResourcesDir(project)
val javaTarget = kotlinExtension.target
configureTargetResources(javaTarget, moduleIsolationDirectory)
}
private fun Project.configureTargetResources(
target: KotlinTarget,
moduleIsolationDirectory: Provider<File>
) {
target.compilations.all { compilation ->
logger.info("Configure ${compilation.name} resources for '${target.targetName}' target")
val compilationResources = files({
compilation.allKotlinSourceSets.map { sourceSet -> getPreparedComposeResourcesDir(sourceSet) }
})
val assembleResTask = registerTask<AssembleTargetResourcesTask>(
name = "assemble${target.targetName.uppercaseFirstChar()}${compilation.name.uppercaseFirstChar()}Resources"
) {
resourceDirectories.setFrom(compilationResources)
relativeResourcePlacement.set(moduleIsolationDirectory)
outputDirectory.set(
layout.buildDirectory.dir(
"$RES_GEN_DIR/assembledResources/${target.targetName}${compilation.name.uppercaseFirstChar()}"
)
)
}
val allCompilationResources = assembleResTask.flatMap { it.outputDirectory.asFile }
if (
target.platformType in platformsForSetupKmpResources
&& compilation.name == KotlinCompilation.MAIN_COMPILATION_NAME
) {
configureKmpResources(compilation, allCompilationResources)
} else {
configureResourcesForCompilation(compilation, allCompilationResources)
}
}
}
private val platformsForSetupKmpResources = listOf(
KotlinPlatformType.native, KotlinPlatformType.js, KotlinPlatformType.wasm
)
@OptIn(ComposeKotlinGradlePluginApi::class)
private fun Project.configureKmpResources(
compilation: KotlinCompilation<*>,
allCompilationResources: Provider<File>
) {
require(compilation.platformType in platformsForSetupKmpResources)
val kmpResources = extraProperties.get(KMP_RES_EXT) as KotlinTargetResourcesPublication
//For Native/Js/Wasm main resources:
// 1) we have to configure new Kotlin component publication
// 2) we have to collect all transitive main resources
//TODO temporary API misuse. will be changed on the KMP side
//https://youtrack.jetbrains.com/issue/KT-70909
val target = compilation.target
val kmpResourceRoot = KotlinTargetResourcesPublication.ResourceRoot(
allCompilationResources,
emptyList(),
emptyList()
)
val kmpEmptyPath = provider { File("") }
logger.info("Configure KMP component publication for '${compilation.target.targetName}'")
kmpResources.publishResourcesAsKotlinComponent(
target,
{ kmpResourceRoot },
kmpEmptyPath
)
val allResources = kmpResources.resolveResources(target)
logger.info("Collect resolved ${compilation.name} resources for '${compilation.target.targetName}'")
configureResourcesForCompilation(compilation, allResources)
}
private fun Project.configureResourcesForCompilation(
compilation: KotlinCompilation<*>,
directoryWithAllResourcesForCompilation: Provider<File>
) {
compilation.defaultSourceSet.resources.srcDir(directoryWithAllResourcesForCompilation)
//JS packaging requires explicit dependency
if (compilation is KotlinJsCompilation) {
tasks.named(compilation.processResourcesTaskName).configure { processResourcesTask ->
processResourcesTask.dependsOn(directoryWithAllResourcesForCompilation)
}
}
}

34
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/SinglemoduleResources.kt

@ -0,0 +1,34 @@
package org.jetbrains.compose.resources
import org.gradle.api.Project
import org.gradle.api.provider.Provider
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet
//configure single-module resources (no publishing, no module isolation)
internal fun Project.configureSinglemoduleResources(
kotlinExtension: KotlinMultiplatformExtension,
config: Provider<ResourcesExtension>
) {
logger.info("Configure single-module compose resources")
val commonMain = KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME
configureComposeResourcesGeneration(kotlinExtension, commonMain, config, false)
// mark prepared resources as sourceSet.resources
// 1) it automatically packs the resources to JVM jars
// 2) it configures the webpack to use the resources
// 3) for native targets we will use source set resources to pack them into the final app. see IosResources.kt
// 4) for the android it DOESN'T pack resources! we copy resources to assets in AndroidResources.kt
kotlinExtension.sourceSets.all { sourceSet ->
// the HACK is here because KGP copy androidMain java resources to Android target
// if the resources were registered in the androidMain source set before the target declaration
afterEvaluate {
sourceSet.resources.srcDirs(getPreparedComposeResourcesDir(sourceSet))
}
}
onAgpApplied {
configureAndroidComposeResources()
fixAndroidLintTaskDependencies()
}
}

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

@ -254,7 +254,7 @@ class ResourcesTest : GradlePluginTestBase() {
} }
gradle(":cmplib:publishAllPublicationsToMavenRepository").checks { gradle(":cmplib:publishAllPublicationsToMavenRepository").checks {
check.logContains("Configure KMP resources") check.logContains("Configure multi-module compose resources")
val resDir = file("cmplib/src/commonMain/composeResources") val resDir = file("cmplib/src/commonMain/composeResources")
val resourcesFiles = resDir.walkTopDown() val resourcesFiles = resDir.walkTopDown()
@ -317,16 +317,12 @@ class ResourcesTest : GradlePluginTestBase() {
@Test @Test
fun testDisableMultimoduleResourcesWithNewKotlin() { fun testDisableMultimoduleResourcesWithNewKotlin() {
val environment = defaultTestEnvironment.copy( with(testProject("misc/kmpResourcePublication")) {
kotlinVersion = "2.0.0-RC2"
)
with(testProject("misc/kmpResourcePublication", environment)) {
file("gradle.properties").modify { content -> file("gradle.properties").modify { content ->
content + "\n" + ComposeProperties.DISABLE_MULTIMODULE_RESOURCES + "=true" content + "\n" + ComposeProperties.DISABLE_MULTIMODULE_RESOURCES + "=true"
} }
gradle(":cmplib:build").checks { gradle(":cmplib:build").checks {
check.logContains("Configure compose resources") check.logContains("Configure single-module compose resources")
} }
} }
} }
@ -388,10 +384,10 @@ class ResourcesTest : GradlePluginTestBase() {
.getConvertedResources(commonResourcesDir, repackDir) .getConvertedResources(commonResourcesDir, repackDir)
gradle("build").checks { gradle("build").checks {
check.taskSuccessful(":demoDebugAssetsCopyForAGP") check.taskSuccessful(":copyDemoDebugComposeResourcesToAndroidAssets")
check.taskSuccessful(":demoReleaseAssetsCopyForAGP") check.taskSuccessful(":copyDemoReleaseComposeResourcesToAndroidAssets")
check.taskSuccessful(":fullDebugAssetsCopyForAGP") check.taskSuccessful(":copyFullDebugComposeResourcesToAndroidAssets")
check.taskSuccessful(":fullReleaseAssetsCopyForAGP") check.taskSuccessful(":copyFullReleaseComposeResourcesToAndroidAssets")
getAndroidApk("demo", "debug", "Resources-Test").let { apk -> getAndroidApk("demo", "debug", "Resources-Test").let { apk ->
checkResourcesZip(apk, commonResourcesFiles, true) checkResourcesZip(apk, commonResourcesFiles, true)
@ -523,6 +519,7 @@ class ResourcesTest : GradlePluginTestBase() {
@Test @Test
fun testJvmOnlyProject(): Unit = with(testProject("misc/jvmOnlyResources")) { fun testJvmOnlyProject(): Unit = with(testProject("misc/jvmOnlyResources")) {
gradle("jar").checks { gradle("jar").checks {
check.logContains("Configure java-only compose resources")
assertDirectoriesContentEquals( assertDirectoriesContentEquals(
file("build/generated/compose/resourceGenerator/kotlin"), file("build/generated/compose/resourceGenerator/kotlin"),
file("expected") file("expected")
@ -683,4 +680,22 @@ class ResourcesTest : GradlePluginTestBase() {
} }
} }
} }
@Test
fun checkTestResources() {
with(testProject("misc/testResources")) {
gradle("check").checks {
check.logContains("Configure main resources for 'desktop' target")
check.logContains("Configure test resources for 'desktop' target")
check.logContains("Configure main resources for 'iosX64' target")
check.logContains("Configure test resources for 'iosX64' target")
check.logContains("Configure main resources for 'iosArm64' target")
check.logContains("Configure test resources for 'iosArm64' target")
check.logContains("Configure main resources for 'iosSimulatorArm64' target")
check.logContains("Configure test resources for 'iosSimulatorArm64' target")
check.taskSuccessful(":desktopTest")
}
}
}
} }

6
gradle-plugins/compose/src/test/test-projects/misc/jvmOnlyResources/expected/commonResClass/me/app/jvmonlyresources/generated/resources/Res.kt

@ -22,7 +22,8 @@ internal object Res {
* @return The content of the file as a byte array. * @return The content of the file as a byte array.
*/ */
@ExperimentalResourceApi @ExperimentalResourceApi
public suspend fun readBytes(path: String): ByteArray = readResourceBytes("" + path) public suspend fun readBytes(path: String): ByteArray =
readResourceBytes("composeResources/me.app.jvmonlyresources.generated.resources/" + path)
/** /**
* Returns the URI string of the resource file at the specified path. * Returns the URI string of the resource file at the specified path.
@ -33,7 +34,8 @@ internal object Res {
* @return The URI string of the file. * @return The URI string of the file.
*/ */
@ExperimentalResourceApi @ExperimentalResourceApi
public fun getUri(path: String): String = getResourceUri("" + path) public fun getUri(path: String): String =
getResourceUri("composeResources/me.app.jvmonlyresources.generated.resources/" + path)
public object drawable public object drawable

3
gradle-plugins/compose/src/test/test-projects/misc/jvmOnlyResources/expected/mainResourceAccessors/me/app/jvmonlyresources/generated/resources/Drawable0.main.kt

@ -24,6 +24,7 @@ internal val Res.drawable.vector: DrawableResource
private fun init_vector(): DrawableResource = org.jetbrains.compose.resources.DrawableResource( private fun init_vector(): DrawableResource = org.jetbrains.compose.resources.DrawableResource(
"drawable:vector", "drawable:vector",
setOf( setOf(
org.jetbrains.compose.resources.ResourceItem(setOf(), "drawable/vector.xml", -1, -1), org.jetbrains.compose.resources.ResourceItem(setOf(),
"composeResources/me.app.jvmonlyresources.generated.resources/drawable/vector.xml", -1, -1),
) )
) )

39
gradle-plugins/compose/src/test/test-projects/misc/testResources/build.gradle.kts

@ -0,0 +1,39 @@
import org.jetbrains.compose.ExperimentalComposeLibrary
plugins {
kotlin("multiplatform")
kotlin("plugin.compose")
id("org.jetbrains.compose")
}
group = "app.group"
kotlin {
jvm("desktop")
iosX64()
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain {
dependencies {
implementation(compose.runtime)
implementation(compose.material)
implementation(compose.components.resources)
}
}
commonTest {
dependencies {
implementation(kotlin("test"))
@OptIn(ExperimentalComposeLibrary::class)
implementation(compose.uiTest)
}
}
val desktopMain by getting {
dependencies {
implementation(compose.desktop.currentOs)
}
}
}
}

3
gradle-plugins/compose/src/test/test-projects/misc/testResources/gradle.properties

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8096M
android.useAndroidX=true
org.jetbrains.compose.experimental.jscanvas.enabled=true

24
gradle-plugins/compose/src/test/test-projects/misc/testResources/settings.gradle.kts

@ -0,0 +1,24 @@
rootProject.name = "Resources-Test"
pluginManagement {
repositories {
mavenLocal()
gradlePluginPortal()
google()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
plugins {
id("com.android.application").version("AGP_VERSION_PLACEHOLDER")
id("org.jetbrains.kotlin.multiplatform").version("KOTLIN_VERSION_PLACEHOLDER")
id("org.jetbrains.kotlin.plugin.compose").version("KOTLIN_VERSION_PLACEHOLDER")
id("org.jetbrains.compose").version("COMPOSE_GRADLE_PLUGIN_VERSION_PLACEHOLDER")
}
}
dependencyResolutionManagement {
repositories {
mavenLocal()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
mavenCentral()
gradlePluginPortal()
google()
}
}

1
gradle-plugins/compose/src/test/test-projects/misc/testResources/src/commonMain/composeResources/files/common.txt

@ -0,0 +1 @@
common 777

3
gradle-plugins/compose/src/test/test-projects/misc/testResources/src/commonMain/composeResources/values/strings.xml

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Compose Resources App</string>
</resources>

7
gradle-plugins/compose/src/test/test-projects/misc/testResources/src/commonMain/kotlin/App.kt

@ -0,0 +1,7 @@
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@Composable
fun App() {
Text("app")
}

1
gradle-plugins/compose/src/test/test-projects/misc/testResources/src/commonTest/composeResources/files/data.txt

@ -0,0 +1 @@
1234567890

3
gradle-plugins/compose/src/test/test-projects/misc/testResources/src/commonTest/composeResources/values/strings.xml

@ -0,0 +1,3 @@
<resources>
<string name="test_string">Common test</string>
</resources>

34
gradle-plugins/compose/src/test/test-projects/misc/testResources/src/commonTest/kotlin/CommonUiTest.kt

@ -0,0 +1,34 @@
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import app.group.resources_test.generated.resources.Res
import app.group.resources_test.generated.resources.app_name
import app.group.resources_test.generated.resources.test_string
import kotlinx.coroutines.test.runTest
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.stringResource
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
@OptIn(ExperimentalTestApi::class, ExperimentalResourceApi::class)
class CommonUiTest {
@Test
fun checkTestResources() = runComposeUiTest {
setContent {
val mainStr = stringResource(Res.string.app_name)
val testStr = stringResource(Res.string.test_string)
assertEquals("Compose Resources App", mainStr)
assertEquals("Common test", testStr)
}
}
@Test
fun checkTestFileResource() = runTest {
val commonFile = Res.readBytes("files/common.txt").decodeToString()
assertEquals("common 777", commonFile)
val testFile = Res.readBytes("files/data.txt").decodeToString()
assertEquals("1234567890", testFile)
}
}

3
gradle-plugins/compose/src/test/test-projects/misc/testResources/src/desktopMain/composeResources/values/desktop_strings.xml

@ -0,0 +1,3 @@
<resources>
<string name="desktop_str">Desktop string</string>
</resources>

3
gradle-plugins/compose/src/test/test-projects/misc/testResources/src/desktopTest/composeResources/values/desktop_strings.xml

@ -0,0 +1,3 @@
<resources>
<string name="desktop_test_str">Desktop test string</string>
</resources>

29
gradle-plugins/compose/src/test/test-projects/misc/testResources/src/desktopTest/kotlin/DesktopUiTest.kt

@ -0,0 +1,29 @@
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import app.group.resources_test.generated.resources.Res
import app.group.resources_test.generated.resources.app_name
import app.group.resources_test.generated.resources.desktop_str
import app.group.resources_test.generated.resources.desktop_test_str
import app.group.resources_test.generated.resources.test_string
import org.jetbrains.compose.resources.stringResource
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(ExperimentalTestApi::class)
class DesktopUiTest {
@Test
fun checkTestResources() = runComposeUiTest {
setContent {
val mainStr = stringResource(Res.string.app_name)
val testStr = stringResource(Res.string.test_string)
val desktopMainStr = stringResource(Res.string.desktop_str)
val desktopTestStr = stringResource(Res.string.desktop_test_str)
assertEquals("Compose Resources App", mainStr)
assertEquals("Common test", testStr)
assertEquals("Desktop string", desktopMainStr)
assertEquals("Desktop test string", desktopTestStr)
}
}
}
Loading…
Cancel
Save