diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/mpp/resources/publication/KotlinAndroidTargetResourcesPublication.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/mpp/resources/publication/KotlinAndroidTargetResourcesPublication.kt new file mode 100644 index 00000000000..e94a7e2145a --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/mpp/resources/publication/KotlinAndroidTargetResourcesPublication.kt @@ -0,0 +1,189 @@ +/* + * Copyright 2010-2024 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +@file:Suppress("DEPRECATION") + +package org.jetbrains.kotlin.gradle.plugin.mpp.resources.publication + +import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.api.variant.Variant +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.tasks.* +import org.gradle.work.DisableCachingByDefault +import org.jetbrains.kotlin.gradle.dsl.multiplatformExtension +import org.jetbrains.kotlin.gradle.dsl.multiplatformExtensionOrNull +import org.jetbrains.kotlin.gradle.plugin.* +import org.jetbrains.kotlin.gradle.plugin.AndroidGradlePluginVersion +import org.jetbrains.kotlin.gradle.plugin.KotlinProjectSetupAction +import org.jetbrains.kotlin.gradle.plugin.PropertiesProvider.Companion.kotlinPropertiesProvider +import org.jetbrains.kotlin.gradle.plugin.launch +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinJvmAndroidCompilation +import org.jetbrains.kotlin.gradle.plugin.mpp.disambiguateName +import org.jetbrains.kotlin.gradle.plugin.mpp.resources.KotlinTargetResourcesPublicationImpl +import org.jetbrains.kotlin.gradle.plugin.mpp.resources.assembleHierarchicalResources +import org.jetbrains.kotlin.gradle.plugin.mpp.resources.resourcesPublicationExtension +import org.jetbrains.kotlin.gradle.plugin.sources.android.androidSourceSetInfo +import org.jetbrains.kotlin.gradle.tasks.registerTask +import org.jetbrains.kotlin.gradle.utils.androidExtension +import org.jetbrains.kotlin.gradle.utils.androidPluginIds +import org.jetbrains.kotlin.gradle.utils.findByType +import org.jetbrains.kotlin.gradle.utils.getOrPut +import javax.inject.Inject + +private val Project.kotlinMultiplatformAndroidResourcesPublication: KotlinAndroidTargetResourcesPublication + get() { + return extraProperties.getOrPut("kotlinMultiplatformAndroidResourcesPublication") { + KotlinAndroidTargetResourcesPublication() + } + } + +internal val SetUpMultiplatformAndroidAssetsAndResourcesPublicationAction = KotlinProjectSetupAction { + if (!kotlinPropertiesProvider.mppResourcesPublication) return@KotlinProjectSetupAction + + project.launchInStage(KotlinPluginLifecycle.Stage.AfterFinaliseCompilations) { + multiplatformExtension.awaitTargets() + .filterIsInstance() + .forEach { target -> + target.compilations.forEach { compilation -> + target.setUpMultiplatformResourcesAndAssets(compilation) + } + } + } + + var isConfigured = false + androidPluginIds.forEach { pluginId -> + pluginManager.withPlugin(pluginId) { + if (isConfigured) { + return@withPlugin + } + isConfigured = true + + // Bundle assets and resources only if AGP is 7.3.0+ due to "addGeneratedSourceDirectory" API availability. And the resources + // API is only supported in CMP since 7.3.1 + if (AndroidGradlePluginVersion.current >= KotlinAndroidTargetResourcesPublication.MIN_AGP_VERSION) { + project.extensions.findByType>()?.onVariants { newAgpVariant -> + kotlinMultiplatformAndroidResourcesPublication.setUpCopyAssetsTasksForNewAgpVariants( + project, + newAgpVariant, + ) + } + } + } + } +} + +internal class KotlinAndroidTargetResourcesPublication { + + @DisableCachingByDefault + internal abstract class AssetsCopyForAGPTask : DefaultTask() { + @get:Inject + abstract val fileSystem: FileSystemOperations + + @get:SkipWhenEmpty + @get:PathSensitive(PathSensitivity.RELATIVE) + @get:InputDirectory + abstract val inputDirectory: DirectoryProperty + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun copy() { + fileSystem.copy { + it.from(inputDirectory) + it.into(outputDirectory) + } + } + } + + private val copyTaskFromVariantName: MutableMap> = mutableMapOf() + private val subscribers: MutableMap) -> (Unit)>> = mutableMapOf() + + internal fun setUpCopyAssetsTasksForNewAgpVariants( + project: Project, + newAgpVariant: Variant, + ) { + val variantName = newAgpVariant.name + if (copyTaskFromVariantName[variantName] != null) { + error("Setting up multiple copy assets tasks for variant $variantName") + } + // Prepare these copy tasks in advance because onVariants API has a specific lifecycle requirement and only set up the input for + // this task if assets publication is requested + val copyTask = project.registerTask("${variantName}AssetsCopyForAGP") { + // Specify output just in case + it.outputDirectory.convention( + project.layout.buildDirectory.dir( + "${KotlinTargetResourcesPublicationImpl.MULTIPLATFORM_RESOURCES_DIRECTORY}/assemble-hierarchically/${variantName}-tmp-for-agp" + ) + ) + } + + newAgpVariant.sources.assets?.addGeneratedSourceDirectory( + taskProvider = copyTask, + wiredWith = { task -> task.outputDirectory } + ) + copyTaskFromVariantName[variantName] = copyTask + subscribers[variantName].orEmpty().forEach { notify -> + notify(copyTask) + } + } + + internal fun subscribeOnCopyAssetsTaskForVariant( + variantName: String, + notify: (TaskProvider) -> (Unit), + ) { + // Associate between BaseVariant and Variant by name + copyTaskFromVariantName[variantName]?.let(notify) + subscribers.getOrPut(variantName, { mutableListOf() }).add(notify) + } + + companion object { + const val MIN_AGP_VERSION = "7.3.1" + } + +} + + +internal fun KotlinAndroidTarget.setUpMultiplatformResourcesAndAssets( + compilation: KotlinJvmAndroidCompilation, +) { + if (project.multiplatformExtensionOrNull == null) return + + val legacyAgpVariant = compilation.androidVariant + val legacyAgpVariantName = getVariantName(legacyAgpVariant) + project.multiplatformExtension.resourcesPublicationExtension?.subscribeOnPublishResources(this) { resources -> + val copyResourcesTask = compilation.assembleHierarchicalResources( + disambiguateName("${legacyAgpVariantName}Resources"), + resources, + ) + + if (AndroidGradlePluginVersion.current < "8.0.0") { + // AGP [7.3-8) requires explicit dependsOn + legacyAgpVariant.processJavaResourcesProvider.configure { + it.dependsOn(copyResourcesTask) + } + } + + project.androidExtension.sourceSets.getByName( + compilation.defaultSourceSet.androidSourceSetInfo.androidSourceSetName + ).resources.srcDir(copyResourcesTask) + } + + project.multiplatformExtension.resourcesPublicationExtension?.subscribeOnAndroidPublishAssets(this) { assets -> + project.kotlinMultiplatformAndroidResourcesPublication.subscribeOnCopyAssetsTaskForVariant(legacyAgpVariantName) { copyTaskForAgp -> + val copyAssetsTask = compilation.assembleHierarchicalResources( + disambiguateName("${legacyAgpVariantName}Assets"), + assets, + ) + copyTaskForAgp.configure { + it.inputDirectory.set(copyAssetsTask) + } + } + } +} \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/registerKotlinPluginExtensions.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/registerKotlinPluginExtensions.kt index 8e7b9b959d0..01b6247f7d6 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/registerKotlinPluginExtensions.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/registerKotlinPluginExtensions.kt @@ -20,8 +20,9 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.apple.AddBuildListenerForXCodeSetu import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XcodeVersionSetupAction import org.jetbrains.kotlin.gradle.plugin.mpp.compilationImpl.* import org.jetbrains.kotlin.gradle.plugin.mpp.internal.DeprecatedMppGradlePropertiesMigrationSetupAction -import org.jetbrains.kotlin.gradle.plugin.mpp.resources.RegisterMultiplatformResourcesPublicationExtensionAction +import org.jetbrains.kotlin.gradle.plugin.mpp.resources.publication.SetUpMultiplatformAndroidAssetsAndResourcesPublicationAction import org.jetbrains.kotlin.gradle.plugin.mpp.resources.publication.SetUpMultiplatformJvmResourcesPublicationAction +import org.jetbrains.kotlin.gradle.plugin.mpp.resources.RegisterMultiplatformResourcesPublicationExtensionAction import org.jetbrains.kotlin.gradle.plugin.sources.KotlinMultiplatformSourceSetSetupAction import org.jetbrains.kotlin.gradle.plugin.sources.LanguageSettingsSetupAction import org.jetbrains.kotlin.gradle.plugin.statistics.MultiplatformBuildStatsReportSetupAction @@ -84,6 +85,7 @@ internal fun Project.registerKotlinPluginExtensions() { register(project, SetupConsistentMetadataDependenciesResolution) register(project, RegisterMultiplatformResourcesPublicationExtensionAction) register(project, SetUpMultiplatformJvmResourcesPublicationAction) + register(project, SetUpMultiplatformAndroidAssetsAndResourcesPublicationAction) } } diff --git a/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinAndroidTargetResourcesPublicationTests.kt b/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinAndroidTargetResourcesPublicationTests.kt new file mode 100644 index 00000000000..1fd13dfba9f --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinAndroidTargetResourcesPublicationTests.kt @@ -0,0 +1,159 @@ +/* + * Copyright 2010-2024 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +package org.jetbrains.kotlin.gradle.unitTests + +import com.android.build.gradle.LibraryExtension +import org.gradle.api.Project +import org.gradle.api.internal.project.ProjectInternal +import org.jetbrains.kotlin.gradle.dsl.multiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.KotlinTarget +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget +import org.jetbrains.kotlin.gradle.plugin.mpp.resources.KotlinTargetResourcesPublication +import org.jetbrains.kotlin.gradle.plugin.mpp.resources.resourcesPublicationExtension +import org.jetbrains.kotlin.gradle.util.buildProjectWithMPP +import org.jetbrains.kotlin.gradle.util.kotlin +import org.jetbrains.kotlin.gradle.utils.androidExtension +import org.junit.Test +import java.io.File + +class KotlinAndroidTargetResourcesPublicationTests { + + private val Project.expectedReleaseResourcePath + get() = layout.buildDirectory.dir( + "kotlin-multiplatform-resources/assemble-hierarchically/androidReleaseResources" + ).get().asFile + + private val Project.expectedDebugResourcePath + get() = layout.buildDirectory.dir( + "kotlin-multiplatform-resources/assemble-hierarchically/androidDebugResources" + ).get().asFile + + private val Project.expectedDemoReleaseResourcePath + get() = layout.buildDirectory.dir( + "kotlin-multiplatform-resources/assemble-hierarchically/androidDemoReleaseResources" + ).get().asFile + + private val Project.expectedFullReleaseResourcePath + get() = layout.buildDirectory.dir( + "kotlin-multiplatform-resources/assemble-hierarchically/androidFullReleaseResources" + ).get().asFile + + @Test + fun `test publishing android resources - reflects in android resource directories`() { + val project = mppProjectWithAndroidTarget() + + project.evaluate() + project.publishFakeResources(project.multiplatformExtension.androidTarget()) + project.publishFakeAssets(project.multiplatformExtension.androidTarget()) + + val androidVariantSourceSets = project.multiplatformExtension.androidTarget() + .compilations.getByName("release") + .androidVariant.sourceSets + val actualResourcesDirectories = androidVariantSourceSets.flatMapTo(linkedSetOf()) { it.resourcesDirectories } + + assert( + actualResourcesDirectories.contains(project.expectedReleaseResourcePath) + ) { actualResourcesDirectories.dumpPaths() } + assert( + !actualResourcesDirectories.contains(project.expectedDebugResourcePath) + ) { actualResourcesDirectories.dumpPaths() } + } + + @Test + fun `test publishing android resources with flavors - reflects in android resource directories`() { + val project = mppProjectWithAndroidTarget { + val dimension = "version" + flavorDimensions += dimension + productFlavors { + with(create("demo")) { this.dimension = dimension } + with(create("full")) { this.dimension = dimension } + } + } + + project.evaluate() + project.publishFakeResources(project.multiplatformExtension.androidTarget()) + + val androidVariantSourceSets = project.multiplatformExtension.androidTarget() + .compilations.getByName("demoRelease") + .androidVariant.sourceSets + val actualResourcesDirectories = androidVariantSourceSets.flatMapTo(linkedSetOf()) { + it.resourcesDirectories + } + + assert( + actualResourcesDirectories.contains(project.expectedDemoReleaseResourcePath) + ) { actualResourcesDirectories.dumpPaths() } + assert( + !actualResourcesDirectories.contains(project.expectedFullReleaseResourcePath) + ) { actualResourcesDirectories.dumpPaths() } + assert( + !actualResourcesDirectories.contains(project.expectedReleaseResourcePath) + ) { actualResourcesDirectories.dumpPaths() } + } + + @Test + fun `test publishing android resources - doesn't show up in resources - when there is no publication`() { + val project = mppProjectWithAndroidTarget() + + project.evaluate() + + val actualSourcesDirectories = project.multiplatformExtension.androidTarget() + .compilations.getByName("release") + .androidVariant.sourceSets.flatMapTo(linkedSetOf()) { it.resourcesDirectories } + + assert( + !actualSourcesDirectories.contains(project.expectedReleaseResourcePath) + ) { actualSourcesDirectories.dumpPaths() } + } + + private fun mppProjectWithAndroidTarget( + configure: LibraryExtension.(Unit) -> (Unit) = { }, + ): ProjectInternal { + val project = buildProjectWithMPP { + plugins.apply("com.android.library") + kotlin { + androidTarget() + } + } + val libraryExt = (project.extensions.getByName("android") as LibraryExtension) + libraryExt.compileSdk = 30 + libraryExt.configure(Unit) + return project + } + + private fun Project.publishFakeResources(target: KotlinTarget) { + project.multiplatformExtension.resourcesPublicationExtension?.publishResourcesAsKotlinComponent( + target, + resourcePathForSourceSet = { + KotlinTargetResourcesPublication.ResourceRoot( + project.provider { File(it.name) }, + emptyList(), + emptyList(), + ) + }, + relativeResourcePlacement = project.provider { File("test") }, + ) + } + + private fun Project.publishFakeAssets(target: KotlinAndroidTarget) { + project.multiplatformExtension.resourcesPublicationExtension?.publishInAndroidAssets( + target, + resourcePathForSourceSet = { + KotlinTargetResourcesPublication.ResourceRoot( + project.provider { File(it.name) }, + emptyList(), + emptyList(), + ) + }, + relativeResourcePlacement = project.provider { File("test") }, + ) + } + + private fun Collection.dumpPaths(): String { + return "Actual paths:" + if (isEmpty()) " " else joinToString("") { "\n${it}" } + } + +} \ No newline at end of file