Skip to content

Commit

Permalink
Support multimodule projects and libraries publication with compose r…
Browse files Browse the repository at this point in the history
…esources (#4454)

- integrate KGP resource API
- packaging final resources to iOS and JS/Wasm applications
- integration tests of publication and multi module support
- info logging of supported Gradle and KGP versions

### requirements
 - Kotlin Gradle Plugin >= 2.0.0-Beta05
 - Gradle >= 7.6
  • Loading branch information
terrakok committed Mar 14, 2024
1 parent 399e482 commit 629cd05
Show file tree
Hide file tree
Showing 72 changed files with 1,020 additions and 130,815 deletions.
9 changes: 7 additions & 2 deletions gradle-plugins/compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ val embeddedDependencies by configurations.creating {
isTransitive = false
}

val kgpResourcesDevVersion = "2.0.0-dev-17632"
//KMP resources API available since ^kgpResourcesDevVersion
repositories {
maven("https://maven.pkg.jetbrains.space/kotlin/p/kotlin/dev/")
}

dependencies {
// By default, Gradle resolves plugins only via Gradle Plugin Portal.
// To avoid declaring an additional repo, all dependencies must:
Expand All @@ -57,8 +63,7 @@ dependencies {

compileOnly(gradleApi())
compileOnly(localGroovy())
compileOnly(kotlin("gradle-plugin-api"))
compileOnly(kotlin("gradle-plugin"))
compileOnly(kotlin("gradle-plugin", kgpResourcesDevVersion))
compileOnly(kotlin("native-utils"))
compileOnly(libs.plugin.android)
compileOnly(libs.plugin.android.api)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ internal abstract class GenerateResClassTask : DefaultTask() {
@get:Input
abstract val packageName: Property<String>

@get:Input
@get:Optional
abstract val moduleDir: Property<File>

@get:Input
abstract val shouldGenerateResClass: Property<Boolean>

Expand Down Expand Up @@ -56,7 +60,11 @@ internal abstract class GenerateResClassTask : DefaultTask() {
}
.groupBy { it.type }
.mapValues { (_, items) -> items.groupBy { it.name } }
getResFileSpecs(resources, packageName.get()).forEach { it.writeTo(kotlinDir) }
getResFileSpecs(
resources,
packageName.get(),
moduleDir.getOrNull()?.let { it.invariantSeparatorsPath + "/" } ?: ""
).forEach { it.writeTo(kotlinDir) }
} else {
logger.info("Generation Res class is disabled")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,58 +2,100 @@ package org.jetbrains.compose.resources

import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.gradle.BaseExtension
import com.android.build.gradle.internal.tasks.ProcessJavaResTask
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.*
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.KotlinCompilation
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.*
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

internal const val COMPOSE_RESOURCES_DIR = "composeResources"
internal const val RES_GEN_DIR = "generated/compose/resourceGenerator"
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() {
val projectId = provider {
val groupName = project.group.toString().lowercase().asUnderscoredIdentifier()
val moduleName = project.name.lowercase().asUnderscoredIdentifier()
if (groupName.isNotEmpty()) "$groupName.$moduleName"
else moduleName
}

plugins.withId(KOTLIN_MPP_PLUGIN_ID) {
val kotlinExtension = project.extensions.getByType(KotlinMultiplatformExtension::class.java)
configureComposeResources(kotlinExtension, KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME)

//when applied AGP then configure android resources
androidPluginIds.forEach { pluginId ->
plugins.withId(pluginId) {
val androidExtension = project.extensions.getByType(BaseExtension::class.java)
configureAndroidComposeResources(kotlinExtension, androidExtension)
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)!!, projectId)
} 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, projectId)

//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)
configureComposeResources(kotlinExtension, SourceSet.MAIN_SOURCE_SET_NAME, projectId)
}
}

private fun Project.configureComposeResources(kotlinExtension: KotlinProjectExtension, commonSourceSetName: String) {
private fun Project.configureComposeResources(
kotlinExtension: KotlinProjectExtension,
commonSourceSetName: String,
projectId: Provider<String>
) {
logger.info("Configure compose resources")
kotlinExtension.sourceSets.all { sourceSet ->
val sourceSetName = sourceSet.name
val composeResourcesPath = project.projectDir.resolve("src/$sourceSetName/$COMPOSE_RESOURCES_DIR")
Expand All @@ -63,7 +105,103 @@ private fun Project.configureComposeResources(kotlinExtension: KotlinProjectExte
sourceSet.resources.srcDirs(composeResourcesPath)

if (sourceSetName == commonSourceSetName) {
configureResourceGenerator(composeResourcesPath, sourceSet)
configureResourceGenerator(composeResourcesPath, sourceSet, projectId, false)
}
}
}

@OptIn(ComposeKotlinGradlePluginApi::class)
private fun Project.configureKmpResources(
kotlinExtension: KotlinProjectExtension,
kmpResources: Any,
projectId: Provider<String>
) {
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()
)
},
projectId.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()
)
},
projectId.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, projectId, 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)
}
}
}
}
}
Expand Down Expand Up @@ -110,16 +248,15 @@ private fun Project.configureAndroidComposeResources(
}
}

private fun Project.configureResourceGenerator(commonComposeResourcesDir: File, commonSourceSet: KotlinSourceSet) {
val packageName = provider {
buildString {
val group = project.group.toString().lowercase().asUnderscoredIdentifier()
append(group)
if (group.isNotEmpty()) append(".")
append(project.name.lowercase().asUnderscoredIdentifier())
append(".generated.resources")
}
}
private fun Project.configureResourceGenerator(
commonComposeResourcesDir: File,
commonSourceSet: KotlinSourceSet,
projectId: Provider<String>,
generateModulePath: Boolean
) {
val packageName = projectId.map { "$it.generated.resources" }

logger.info("Configure accessors for '${commonSourceSet.name}'")

fun buildDir(path: String) = layout.dir(layout.buildDirectory.map { File(it.asFile, path) })

Expand All @@ -142,11 +279,15 @@ private fun Project.configureResourceGenerator(commonComposeResourcesDir: File,
val genTask = tasks.register(
"generateComposeResClass",
GenerateResClassTask::class.java
) {
it.packageName.set(packageName)
it.shouldGenerateResClass.set(shouldGenerateResClass)
it.resDir.set(commonComposeResourcesDir)
it.codeDir.set(buildDir("$RES_GEN_DIR/kotlin"))
) { task ->
task.packageName.set(packageName)
task.shouldGenerateResClass.set(shouldGenerateResClass)
task.resDir.set(commonComposeResourcesDir)
task.codeDir.set(buildDir("$RES_GEN_DIR/kotlin"))

if (generateModulePath) {
task.moduleDir.set(projectId.asModuleDir())
}
}

//register generated source set
Expand All @@ -160,6 +301,8 @@ private fun Project.configureResourceGenerator(commonComposeResourcesDir: File,
}
}

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ private const val ITEMS_PER_FILE_LIMIT = 500
internal fun getResFileSpecs(
//type -> id -> items
resources: Map<ResourceType, Map<String, List<ResourceItem>>>,
packageName: String
packageName: String,
moduleDir: String
): List<FileSpec> {
val files = mutableListOf<FileSpec>()
val resClass = FileSpec.builder(packageName, "Res").also { file ->
Expand Down Expand Up @@ -147,7 +148,7 @@ internal fun getResFileSpecs(
.addParameter("path", String::class)
.addModifiers(KModifier.SUSPEND)
.returns(ByteArray::class)
.addStatement("return %M(path)", readResourceBytes) //todo: add module ID here
.addStatement("""return %M("$moduleDir" + path)""", readResourceBytes)
.build()
)
ResourceType.values().forEach { type ->
Expand All @@ -163,7 +164,13 @@ internal fun getResFileSpecs(

chunks.forEachIndexed { index, ids ->
files.add(
getChunkFileSpec(type, index, packageName, idToResources.subMap(ids.first(), true, ids.last(), true))
getChunkFileSpec(
type,
index,
packageName,
moduleDir,
idToResources.subMap(ids.first(), true, ids.last(), true)
)
)
}
}
Expand All @@ -175,6 +182,7 @@ private fun getChunkFileSpec(
type: ResourceType,
index: Int,
packageName: String,
moduleDir: String,
idToResources: Map<String, List<ResourceItem>>
): FileSpec {
val chunkClassName = type.typeName.uppercaseFirstChar() + index
Expand Down Expand Up @@ -220,7 +228,7 @@ private fun getChunkFileSpec(
add("%T(", resourceItemClass)
add("setOf(").addQualifiers(item).add("), ")
//file separator should be '/' on all platforms
add("\"${item.path.invariantSeparatorsPathString}\"") //todo: add module ID here
add("\"$moduleDir${item.path.invariantSeparatorsPathString}\"")
add("),\n")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ private fun SyncIosResourcesContext.configureSyncResourcesTasks() {
val lazyTasksDependencies = LazyTasksDependencyConfigurator(project.tasks)
configureEachIosFramework { framework ->
val frameworkClassifier = framework.namePrefix.uppercaseFirstChar()
val syncResourcesTaskName = "sync${frameworkClassifier}ComposeResourcesForIos"
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) {
Expand Down Expand Up @@ -191,6 +191,9 @@ private fun SyncIosResourcesContext.configureSyncResourcesTasks() {
}
}

internal fun Framework.getSyncResourcesTaskName() =
"sync${namePrefix.uppercaseFirstChar()}ComposeResourcesForIos"

private val Framework.isCocoapodsFramework: Boolean
get() = name.startsWith("pod")

Expand Down
Loading

0 comments on commit 629cd05

Please sign in to comment.