From 1e985d3cdd84ce4ee8dc10d0e0d0727a20afd7d5 Mon Sep 17 00:00:00 2001 From: Kolchurin Konstantin Date: Sat, 19 Sep 2026 21:58:49 +0700 Subject: [PATCH 1/6] update generator Kotlin toolchain --- gradle/libs.versions.toml | 20 ++--- resources-generator/build.gradle.kts | 3 +- .../apple/PackAppleResourcesToKLibAction.kt | 8 +- .../dev/icerock/gradle/utils/ZipUtils.kt | 73 +++++++++++++++++++ .../dev/icerock/gradle/utils/ZipUtilsTest.kt | 43 +++++++++++ 5 files changed, 130 insertions(+), 17 deletions(-) create mode 100644 resources-generator/src/test/kotlin/dev/icerock/gradle/utils/ZipUtilsTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 538188616..19649f310 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,17 +1,16 @@ [versions] -# --- Runtime versions --- -# Minimum compatibility baseline for library modules (resources, compose, etc.). -# Allows users to use moko-resources WITHOUT forcing them to update their projects. +# --- Consumer compatibility baselines --- +# Raising these versions changes the supported consumer matrix; see COMPATIBILITY.md. kotlinVersion = "2.1.0" androidGradleVersion = "8.3.0" + +# --- Library dependencies --- androidSdkCommonVersion = "31.13.2" -# --- Plugin versions (Tooling only) --- -# Used EXCLUSIVELY in the generator plugin module via 'compileOnly'. -# We use current APIs (available in 8.13.2+) to ensure compatibility with -# the widest range of AGP versions: from the minimum (8.3) -# to the current (9.0+ at this time). -pluginKotlinVersion = "2.3.20" +# --- Generator compile toolchain --- +# KGP and AGP are compileOnly host APIs and do not redefine the consumer baselines above. +pluginKotlinVersion = "2.4.20" +# Last AGP 8 API. AGP 9 compile APIs are not binary-compatible with AGP 8 runtimes. pluginAndroidGradleVersion = "8.13.2" @@ -31,7 +30,8 @@ composeJetbrainsVersion = "1.7.0" # jvm apacheCommonsTextVersion = "1.10.0" -kotlinPoetVersion = "1.13.1" +# 2.4.0 has no API or fix currently required by the generator; see COMPATIBILITY.md. +kotlinPoetVersion = "2.3.0" detektVersion = "1.22.0" icu4jVersion = "73.1" commonsCodecVersion = "1.15" diff --git a/resources-generator/build.gradle.kts b/resources-generator/build.gradle.kts index 51d0627a3..ad0bd3c5b 100644 --- a/resources-generator/build.gradle.kts +++ b/resources-generator/build.gradle.kts @@ -49,7 +49,8 @@ kotlin { tasks.withType().configureEach { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) - languageVersion.set(KotlinVersion.fromVersion("2.0")) + languageVersion.set(KotlinVersion.fromVersion("2.1")) + apiVersion.set(KotlinVersion.fromVersion("2.1")) } } diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/PackAppleResourcesToKLibAction.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/PackAppleResourcesToKLibAction.kt index c5fbf25d0..4526fe826 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/PackAppleResourcesToKLibAction.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/PackAppleResourcesToKLibAction.kt @@ -7,15 +7,14 @@ package dev.icerock.gradle.actions.apple import dev.icerock.gradle.generator.Constants import dev.icerock.gradle.generator.platform.apple.LoadableBundle import dev.icerock.gradle.utils.unzipTo +import dev.icerock.gradle.utils.zipDirAs import org.gradle.api.Action import org.gradle.api.GradleException import org.gradle.api.Task import org.gradle.api.provider.Provider import org.jetbrains.kotlin.gradle.tasks.KotlinNativeCompile -import org.jetbrains.kotlin.konan.file.zipDirAs import java.io.File import java.util.Properties -import org.jetbrains.kotlin.konan.file.File as KonanFile internal class PackAppleResourcesToKLibAction( private val assetsDirectory: Provider, @@ -66,11 +65,8 @@ internal class PackAppleResourcesToKLibAction( task = task ) - val repackKonan = KonanFile(repackDir.path) - val klibKonan = KonanFile(klibFile.path) - klibFile.delete() - repackKonan.zipDirAs(klibKonan) + repackDir.zipDirAs(klibFile) repackDir.deleteRecursively() } diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/utils/ZipUtils.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/utils/ZipUtils.kt index 2dc6a1312..48cfbc73d 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/utils/ZipUtils.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/utils/ZipUtils.kt @@ -6,9 +6,82 @@ package dev.icerock.gradle.utils import java.io.File import java.io.InputStream +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.attribute.BasicFileAttributes +import java.nio.file.attribute.FileTime import java.util.zip.ZipEntry import java.util.zip.ZipException import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +private val zeroFileTimestamp: FileTime = FileTime.fromMillis(0) + +internal fun File.zipDirAs(zipFile: File) { + val sourceRoot = toPath().toRealPath() + + zipFile.outputStream().use { output -> + ZipOutputStream(output).use { zip -> + zip.setLevel(5) + + Files.walk(sourceRoot).use { paths -> + paths.sorted().forEach { path -> + val realPath = path.toRealPath() + if (!realPath.startsWith(sourceRoot)) { + throw ZipException( + "An attempt to escape the source directory $sourceRoot in symlink $path" + ) + } + if (realPath == sourceRoot) return@forEach + + val entryName = sourceRoot.relativize(path) + .joinToString(separator = "/") { it.toString() } + val attributes = Files.readAttributes( + realPath, + BasicFileAttributes::class.java, + LinkOption.NOFOLLOW_LINKS + ) + + when { + attributes.isRegularFile -> zip.addFileEntry(entryName, realPath.toFile()) + attributes.isDirectory -> zip.addDirectoryEntry(entryName) + else -> error("Unsupported file type encountered: $path") + } + } + } + } + } +} + +private fun ZipOutputStream.addFileEntry(name: String, file: File) { + addEntry(ZipEntry(name).apply { method = ZipEntry.DEFLATED }) { + file.inputStream().use { it.copyTo(this) } + } +} + +private fun ZipOutputStream.addDirectoryEntry(name: String) { + addEntry( + ZipEntry("$name/").apply { + method = ZipEntry.STORED + size = 0 + crc = 0 + } + ) +} + +private inline fun ZipOutputStream.addEntry(entry: ZipEntry, writeContent: ZipOutputStream.() -> Unit = {}) { + entry.creationTime = zeroFileTimestamp + entry.lastModifiedTime = zeroFileTimestamp + entry.lastAccessTime = zeroFileTimestamp + entry.extra = null + + putNextEntry(entry) + try { + writeContent() + } finally { + closeEntry() + } +} internal fun unzipTo(outputDirectory: File, zipFile: File) { ZipFile(zipFile).use { zip -> diff --git a/resources-generator/src/test/kotlin/dev/icerock/gradle/utils/ZipUtilsTest.kt b/resources-generator/src/test/kotlin/dev/icerock/gradle/utils/ZipUtilsTest.kt new file mode 100644 index 000000000..65c15f02f --- /dev/null +++ b/resources-generator/src/test/kotlin/dev/icerock/gradle/utils/ZipUtilsTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2026 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.gradle.utils + +import java.nio.file.Files +import java.util.zip.ZipFile +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class ZipUtilsTest { + @Test + fun `zip directory is deterministic and preserves its structure`() { + val temporaryDirectory = Files.createTempDirectory("moko-resources-zip-test").toFile() + try { + val sourceDirectory = temporaryDirectory.resolve("source").apply { mkdirs() } + sourceDirectory.resolve("b.txt").writeText("second") + sourceDirectory.resolve("a.txt").writeText("first") + sourceDirectory.resolve("nested").mkdirs() + sourceDirectory.resolve("nested/value.txt").writeText("nested") + sourceDirectory.resolve("empty").mkdirs() + + val firstZip = temporaryDirectory.resolve("first.zip") + val secondZip = temporaryDirectory.resolve("second.zip") + sourceDirectory.zipDirAs(firstZip) + sourceDirectory.zipDirAs(secondZip) + + assertContentEquals(firstZip.readBytes(), secondZip.readBytes()) + ZipFile(firstZip).use { zip -> + assertEquals( + listOf("a.txt", "b.txt", "empty/", "nested/", "nested/value.txt"), + zip.entries().asSequence().map { it.name }.toList() + ) + assertEquals("first", zip.getInputStream(zip.getEntry("a.txt")).bufferedReader().readText()) + assertEquals("nested", zip.getInputStream(zip.getEntry("nested/value.txt")).bufferedReader().readText()) + } + } finally { + temporaryDirectory.deleteRecursively() + } + } +} From 4a7781a9dcade35736a58b3c12e481b1931b0234 Mon Sep 17 00:00:00 2001 From: Kolchurin Konstantin Date: Sat, 19 Sep 2026 21:59:02 +0700 Subject: [PATCH 2/6] document compatibility policy and generated MR API --- COMPATIBILITY.md | 42 +++++++++++++++++++ README.md | 13 +++--- .../gradle/generator/ResourcesGenerator.kt | 13 ++++++ 3 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 COMPATIBILITY.md diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md new file mode 100644 index 000000000..f89305976 --- /dev/null +++ b/COMPATIBILITY.md @@ -0,0 +1,42 @@ +# Compatibility policy + +Consumer minimums and the `resources-generator` compile toolchain are versioned independently. +Updating a generator build dependency does not implicitly raise a consumer requirement. + +## Version matrix + +| Scope | Version | Contract | +|----------------------|---------|--------------------------------------------------------------------------------------------| +| Consumer Kotlin | 2.1.0 | Minimum for published artifacts; also used as the generator language and API level. | +| Generator Kotlin | 2.4.20 | Build toolchain only; does not change the consumer minimum. | +| Generator KotlinPoet | 2.3.0 | Internal implementation dependency; absent from generated and public APIs. | +| Consumer AGP | 8.3.0 | Oldest supported AGP runtime. | +| Generator AGP API | 8.13.2 | Last AGP 8 compile API, used via `compileOnly`; the runtime matrix covers AGP 8 and AGP 9. | + +## Kotlin baseline + +The next planned consumer Kotlin baseline is 2.2.0. It replaces 2.1.0 after the Kotlin 2.5 line is +validated as stable by the compatibility matrix. + +The baseline may move earlier if a Kotlin 2.5 compatibility fix cannot be released while retaining +Kotlin 2.1 support. A required moko-resources release must not be blocked on a later Kotlin 2.5.x +patch solely to preserve the previous baseline. + +## KotlinPoet + +KotlinPoet 2.4.0 is intentionally deferred. Its explicit-backing-field, multi-field value-class, +and code-comment APIs are not used by the generator. KotlinPoet should be upgraded separately when +a required API or fix justifies the change. + +See the [KotlinPoet 2.4.0 release notes](https://github.com/square/kotlinpoet/releases/tag/2.4.0). + +## Android Gradle Plugin + +The generator compiles against AGP 8.13.2 to remain on the AGP 8 DSL ABI while supporting both +AGP 8 and AGP 9 runtimes. Compiling against the AGP 9 API is a separate migration because it drops +AGP 8 binary compatibility. + +AGP 8.13.2 and Gradle 8.14.2 are unrelated version lines. The latter is a Gradle distribution +version used by compatibility samples, not a newer AGP 8 release. + +See the [AGP 8.13 release notes](https://developer.android.com/build/releases/agp-8-13-0-release-notes). diff --git a/README.md b/README.md index adb4736c4..f9f053f13 100755 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ implement all your UI in Kotlin with Jetpack Compose and MOKO resources. - iOS version 12.0+ - Compose Multiplatform 1.6.0+ +The consumer compatibility policy and generator toolchain constraints are documented in +[COMPATIBILITY.md](COMPATIBILITY.md). + ## Installation ### Gradle setup @@ -618,11 +621,11 @@ string source: ```kotlin fun getUserName(user: User?): StringDesc { - if (user != null) { - return StringDesc.Raw(user.name) - } else { - return StringDesc.Resource(MR.strings.name_placeholder) - } + return if (user != null) { + StringDesc.Raw(user.name) + } else { + StringDesc.Resource(MR.strings.name_placeholder) + } } ``` diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/ResourcesGenerator.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/ResourcesGenerator.kt index ed1be332d..472753d9d 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/ResourcesGenerator.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/ResourcesGenerator.kt @@ -192,6 +192,8 @@ internal class ResourcesGenerator( .addModifiers(KModifier.EXPECT) .addModifiers(visibilityModifier) + objectSpec.addResourceContainersKdoc(objects) + // Add generated objects and create metadata of current sourceSet objects.forEach { result -> objectSpec.addType(result.typeSpec) @@ -231,6 +233,7 @@ internal class ResourcesGenerator( .map { it.contentHash() } .calculateHash() + objectSpec.addResourceContainersKdoc(generatedObjects) objectSpec.addContentHashProperty(contentHash) objectSpec.also { builder -> @@ -243,4 +246,14 @@ internal class ResourcesGenerator( fileSpec.addType(objectSpec.build()) } + + private fun TypeSpec.Builder.addResourceContainersKdoc( + generatedObjects: List, + ) { + addKdoc("Entry point for generated resources.\n\n") + addKdoc("Available resource containers:\n") + generatedObjects.forEach { result -> + addKdoc("- [%L]\n", result.metadata.name) + } + } } From 789c068d485d9d9ffafe8aa12268565d35ec4800 Mon Sep 17 00:00:00 2001 From: Kolchurin Konstantin Date: Sun, 20 Sep 2026 21:24:06 +0700 Subject: [PATCH 3/6] reformat --- .../dev/icerock/gradle/utils/ZipUtils.kt | 66 +++++++++++-------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/utils/ZipUtils.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/utils/ZipUtils.kt index 48cfbc73d..2b69b643d 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/utils/ZipUtils.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/utils/ZipUtils.kt @@ -8,6 +8,7 @@ import java.io.File import java.io.InputStream import java.nio.file.Files import java.nio.file.LinkOption +import java.nio.file.Path import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.FileTime import java.util.zip.ZipEntry @@ -16,43 +17,54 @@ import java.util.zip.ZipFile import java.util.zip.ZipOutputStream private val zeroFileTimestamp: FileTime = FileTime.fromMillis(0) +private const val ZIP_COMPRESSION_LEVEL = 5 internal fun File.zipDirAs(zipFile: File) { val sourceRoot = toPath().toRealPath() zipFile.outputStream().use { output -> ZipOutputStream(output).use { zip -> - zip.setLevel(5) - - Files.walk(sourceRoot).use { paths -> - paths.sorted().forEach { path -> - val realPath = path.toRealPath() - if (!realPath.startsWith(sourceRoot)) { - throw ZipException( - "An attempt to escape the source directory $sourceRoot in symlink $path" - ) - } - if (realPath == sourceRoot) return@forEach - - val entryName = sourceRoot.relativize(path) - .joinToString(separator = "/") { it.toString() } - val attributes = Files.readAttributes( - realPath, - BasicFileAttributes::class.java, - LinkOption.NOFOLLOW_LINKS - ) - - when { - attributes.isRegularFile -> zip.addFileEntry(entryName, realPath.toFile()) - attributes.isDirectory -> zip.addDirectoryEntry(entryName) - else -> error("Unsupported file type encountered: $path") - } - } - } + zip.setLevel(ZIP_COMPRESSION_LEVEL) + zip.addDirectoryEntries(sourceRoot) } } } +private fun ZipOutputStream.addDirectoryEntries(sourceRoot: Path) { + Files.walk(sourceRoot).use { paths -> + paths.sorted().forEach { path -> + addPathEntry(sourceRoot, path) + } + } +} + +private fun ZipOutputStream.addPathEntry( + sourceRoot: Path, + path: Path, +) { + val realPath = path.toRealPath() + if (!realPath.startsWith(sourceRoot)) { + throw ZipException( + "An attempt to escape the source directory $sourceRoot in symlink $path" + ) + } + if (realPath == sourceRoot) return + + val entryName = sourceRoot.relativize(path) + .joinToString(separator = "/") { it.toString() } + val attributes = Files.readAttributes( + realPath, + BasicFileAttributes::class.java, + LinkOption.NOFOLLOW_LINKS + ) + + when { + attributes.isRegularFile -> addFileEntry(entryName, realPath.toFile()) + attributes.isDirectory -> addDirectoryEntry(entryName) + else -> error("Unsupported file type encountered: $path") + } +} + private fun ZipOutputStream.addFileEntry(name: String, file: File) { addEntry(ZipEntry(name).apply { method = ZipEntry.DEFLATED }) { file.inputStream().use { it.copyTo(this) } From 3e1ce6b1042ccb31fbccd2b49fc39e9998f8585a Mon Sep 17 00:00:00 2001 From: Kolchurin Konstantin Date: Wed, 23 Sep 2026 12:10:30 +0700 Subject: [PATCH 4/6] #848 / #863 Apple KLib cross-compilation --- .../gradle/MultiplatformResourcesPlugin.kt | 74 ++++++++++++- .../apple/AppleAssetCatalogCompiler.kt | 104 ++++++++++++++++++ .../apple/CopyResourcesFromKLibsAction.kt | 66 +++-------- ...opyResourcesFromKLibsToExecutableAction.kt | 5 +- ...CopyResourcesFromKLibsToFrameworkAction.kt | 5 +- .../apple/PackAppleResourcesToKLibAction.kt | 49 --------- .../PopulateDummyFrameworkResourcesAction.kt | 61 ++++++++++ .../gradle/data/AppleBundleResources.kt | 44 ++++++++ .../data/AppleResourceBundleRegistry.kt | 12 ++ .../platform/apple/LoadableBundle.kt | 3 +- .../platform/apple/SetupAppleUtils.kt | 72 ++++++++++-- .../tasks/CopyExecutableResourcesToApp.kt | 23 +++- .../apple/AppleAssetCatalogCompilerTest.kt | 71 ++++++++++++ ...pulateDummyFrameworkResourcesActionTest.kt | 58 ++++++++++ .../gradle/data/AppleBundleResourcesTest.kt | 79 +++++++++++++ .../platform/apple/LoadableBundleTest.kt | 39 +++++++ .../shared/build.gradle.kts | 26 ----- samples/kotlin-2-tests/local-check.sh | 35 ++++++ samples/kotlin-2-tests/settings.gradle.kts | 4 +- .../kotlin-2-tests/shared/build.gradle.kts | 12 +- .../moko-resources/images/cross_compile.svg | 5 + 21 files changed, 700 insertions(+), 147 deletions(-) create mode 100644 resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/AppleAssetCatalogCompiler.kt create mode 100644 resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/PopulateDummyFrameworkResourcesAction.kt create mode 100644 resources-generator/src/main/kotlin/dev/icerock/gradle/data/AppleBundleResources.kt create mode 100644 resources-generator/src/main/kotlin/dev/icerock/gradle/data/AppleResourceBundleRegistry.kt create mode 100644 resources-generator/src/test/kotlin/dev/icerock/gradle/actions/apple/AppleAssetCatalogCompilerTest.kt create mode 100644 resources-generator/src/test/kotlin/dev/icerock/gradle/actions/apple/PopulateDummyFrameworkResourcesActionTest.kt create mode 100644 resources-generator/src/test/kotlin/dev/icerock/gradle/data/AppleBundleResourcesTest.kt create mode 100644 resources-generator/src/test/kotlin/dev/icerock/gradle/generator/platform/apple/LoadableBundleTest.kt create mode 100644 samples/kotlin-2-tests/shared/src/commonMain/moko-resources/images/cross_compile.svg diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/MultiplatformResourcesPlugin.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/MultiplatformResourcesPlugin.kt index 0eaae4c31..17e11ccd0 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/MultiplatformResourcesPlugin.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/MultiplatformResourcesPlugin.kt @@ -6,6 +6,7 @@ package dev.icerock.gradle import com.android.build.api.dsl.AndroidSourceSet import com.android.build.api.extension.impl.CurrentAndroidGradlePluginVersion +import dev.icerock.gradle.data.AppleResourceBundleRegistry import dev.icerock.gradle.extra.getOrRegisterGenerateResourcesTask import dev.icerock.gradle.generator.platform.android.AGP_8_11_0 import dev.icerock.gradle.generator.platform.android.AndroidPluginType @@ -14,6 +15,7 @@ import dev.icerock.gradle.generator.platform.android.setupAndroidTasks import dev.icerock.gradle.generator.platform.android.setupAndroidVariantsSync import dev.icerock.gradle.generator.platform.apple.registerCopyFrameworkResourcesToAppTask import dev.icerock.gradle.generator.platform.apple.setupAppleKLibResources +import dev.icerock.gradle.generator.platform.apple.setupCocoapodsDummyFrameworkResources import dev.icerock.gradle.generator.platform.apple.setupExecutableResources import dev.icerock.gradle.generator.platform.apple.setupFatFrameworkTasks import dev.icerock.gradle.generator.platform.apple.setupFrameworkResources @@ -25,6 +27,8 @@ import dev.icerock.gradle.utils.hasMinimalVersion import dev.icerock.gradle.utils.kotlinSourceSetsObservable import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.artifacts.ProjectDependency +import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.provider.Provider import org.gradle.api.tasks.TaskCollection import org.gradle.api.tasks.TaskProvider @@ -51,6 +55,12 @@ open class MultiplatformResourcesPlugin : Plugin { name = "multiplatformResources", type = MultiplatformResourcesPluginExtension::class ).apply { setupConvention(project) } + val appleResourceBundleRegistry: AppleResourceBundleRegistry = project.extensions.create( + name = APPLE_RESOURCE_BUNDLE_REGISTRY_EXTENSION_NAME, + type = AppleResourceBundleRegistry::class, + ).apply { + bundleIdentifiers.add(mrExtension.resourcesPackage.map { "$it.main" }) + } project.plugins.withType(KotlinMultiplatformPluginWrapper::class) { val kmpExtension: KotlinMultiplatformExtension = project.extensions.getByType() @@ -58,7 +68,8 @@ open class MultiplatformResourcesPlugin : Plugin { configureKotlinTargetGenerator( project = project, mrExtension = mrExtension, - kmpExtension = kmpExtension + kmpExtension = kmpExtension, + appleResourceBundleRegistry = appleResourceBundleRegistry, ) setupFatFrameworkTasks(project = project) @@ -93,15 +104,31 @@ open class MultiplatformResourcesPlugin : Plugin { project: Project, mrExtension: MultiplatformResourcesPluginExtension, kmpExtension: KotlinMultiplatformExtension, + appleResourceBundleRegistry: AppleResourceBundleRegistry, ) { + val appleFrameworkKlibs: ConfigurableFileCollection = project.objects.fileCollection() + setupCocoapodsDummyFrameworkResources( + project = project, + bundleIdentifiers = appleResourceBundleRegistry.bundleIdentifiers, + frameworkKlibs = appleFrameworkKlibs, + ) + val observedAppleDependencyConfigurations: MutableSet = mutableSetOf() + kmpExtension.sourceSets.configureEach { kotlinSourceSet: KotlinSourceSet -> kotlinSourceSet.getOrRegisterGenerateResourcesTask(mrExtension) } kmpExtension.targets.configureEach { target -> if (target is KotlinNativeTarget) { - setupExecutableResources(target = target) - setupFrameworkResources(target = target) + setupExecutableResources( + target = target, + iosMinimalDeploymentTarget = mrExtension.iosMinimalDeploymentTarget, + ) + setupFrameworkResources( + target = target, + iosMinimalDeploymentTarget = mrExtension.iosMinimalDeploymentTarget, + frameworkKlibs = appleFrameworkKlibs, + ) } if (target is KotlinJsIrTarget) { @@ -154,6 +181,13 @@ open class MultiplatformResourcesPlugin : Plugin { } if (target is KotlinNativeTarget && target.konanTarget.family.isAppleFamily) { + registerAppleProjectDependencies( + project = project, + sourceSet = sourceSet, + registry = appleResourceBundleRegistry, + observedConfigurations = observedAppleDependencyConfigurations, + ) + val appleIdentifier: Provider = mrExtension.resourcesPackage .map { it + "." + compilation.name } @@ -173,7 +207,6 @@ open class MultiplatformResourcesPlugin : Plugin { it.outputResourcesDir.asFile }, iosLocalizationRegion = mrExtension.iosBaseLocalizationRegion, - iosMinimalDeploymentTarget = mrExtension.iosMinimalDeploymentTarget, appleBundleIdentifier = appleIdentifier ) } @@ -183,6 +216,33 @@ open class MultiplatformResourcesPlugin : Plugin { } } + private fun registerAppleProjectDependencies( + project: Project, + sourceSet: KotlinSourceSet, + registry: AppleResourceBundleRegistry, + observedConfigurations: MutableSet, + ) { + listOf(sourceSet.apiConfigurationName, sourceSet.implementationConfigurationName) + .filter(observedConfigurations::add) + .forEach { configurationName -> + project.configurations.named(configurationName).configure { configuration -> + configuration.dependencies + .withType(ProjectDependency::class.java) + .all { dependency -> + val dependencyProject = project.project(dependency.path) + dependencyProject.pluginManager.withPlugin(PLUGIN_ID) { + val dependencyRegistry = dependencyProject.extensions.getByType( + AppleResourceBundleRegistry::class.java + ) + registry.bundleIdentifiers.addAll( + dependencyRegistry.bundleIdentifiers + ) + } + } + } + } + } + private fun registerGenerateAllResources(project: Project) { project.tasks.register("generateMR") { it.group = "moko-resources" @@ -190,6 +250,12 @@ open class MultiplatformResourcesPlugin : Plugin { } } + private companion object { + const val PLUGIN_ID = "dev.icerock.mobile.multiplatform-resources" + const val APPLE_RESOURCE_BUNDLE_REGISTRY_EXTENSION_NAME = + "mokoResourcesAppleResourceBundleRegistry" + } + @OptIn(ExperimentalKotlinGradlePluginApi::class) private fun setupSourceSets( target: KotlinTarget, diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/AppleAssetCatalogCompiler.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/AppleAssetCatalogCompiler.kt new file mode 100644 index 000000000..08177231f --- /dev/null +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/AppleAssetCatalogCompiler.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2026 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.gradle.actions.apple + +import dev.icerock.gradle.generator.Constants +import org.gradle.api.GradleException +import org.gradle.api.logging.Logger +import java.io.File + +internal class AppleAssetCatalogCompiler( + private val logger: Logger, + private val iosMinimalDeploymentTarget: String, +) { + fun compile(bundleDirectory: File, targetName: String) { + val resourcesDirectory = File(bundleDirectory, "Contents/Resources") + val assetCatalog = File(resourcesDirectory, Constants.Apple.assetsDirectoryName) + if (!assetCatalog.isDirectory) return + + val platform: AppleActoolPlatform = actoolPlatform(targetName) + val compiledAssetCatalog = File(resourcesDirectory, COMPILED_ASSET_CATALOG_NAME) + + compiledAssetCatalog.delete() + + val command: MutableList = mutableListOf( + "xcrun", + "actool", + assetCatalog.absolutePath, + "--compile", + resourcesDirectory.absolutePath, + "--platform", + platform.cliName, + ) + if (platform.requiresMinimumDeploymentTarget) { + command += listOf( + "--minimum-deployment-target", + iosMinimalDeploymentTarget, + ) + } + + logger.info( + "Compiling Apple asset catalog in {} for {}", + bundleDirectory, + platform.cliName, + ) + val process: Process = ProcessBuilder(command) + .redirectErrorStream(true) + .start() + val output: String = process.inputStream.bufferedReader().use { it.readText() } + val exitCode: Int = process.waitFor() + + if (exitCode != 0) { + throw GradleException( + "Apple asset catalog compilation failed for $bundleDirectory " + + "with exit code $exitCode:\n$output" + ) + } + if (!compiledAssetCatalog.isFile) { + throw GradleException( + "Apple asset catalog compiler did not create $compiledAssetCatalog:\n$output" + ) + } + + assetCatalog.deleteRecursively() + logger.info("Apple asset catalog compiled in {}", bundleDirectory) + } +} + +internal enum class AppleActoolPlatform( + val cliName: String, + val requiresMinimumDeploymentTarget: Boolean = false, +) { + IOS_DEVICE( + cliName = "iphoneos", + requiresMinimumDeploymentTarget = true, + ), + IOS_SIMULATOR( + cliName = "iphonesimulator", + requiresMinimumDeploymentTarget = true, + ), + MACOS(cliName = "macosx"), + TVOS_DEVICE(cliName = "appletvos"), + TVOS_SIMULATOR(cliName = "appletvsimulator"), + WATCHOS_DEVICE(cliName = "watchos"), + WATCHOS_SIMULATOR(cliName = "watchsimulator"), +} + +internal fun actoolPlatform(targetName: String): AppleActoolPlatform { + return when (targetName) { + "ios_arm64" -> AppleActoolPlatform.IOS_DEVICE + "ios_simulator_arm64", "ios_x64" -> AppleActoolPlatform.IOS_SIMULATOR + "macos_arm64", "macos_x64" -> AppleActoolPlatform.MACOS + "tvos_arm64" -> AppleActoolPlatform.TVOS_DEVICE + "tvos_simulator_arm64", "tvos_x64" -> AppleActoolPlatform.TVOS_SIMULATOR + "watchos_arm32", "watchos_arm64", "watchos_device_arm64" -> { + AppleActoolPlatform.WATCHOS_DEVICE + } + "watchos_simulator_arm64", "watchos_x64" -> AppleActoolPlatform.WATCHOS_SIMULATOR + else -> throw GradleException("Kotlin/Native target '$targetName' is not an Apple target") + } +} + +private const val COMPILED_ASSET_CATALOG_NAME = "Assets.car" diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/CopyResourcesFromKLibsAction.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/CopyResourcesFromKLibsAction.kt index 1a9d19992..5d612ed5f 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/CopyResourcesFromKLibsAction.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/CopyResourcesFromKLibsAction.kt @@ -4,71 +4,41 @@ package dev.icerock.gradle.actions.apple -import dev.icerock.gradle.data.getKlibResourcesDir +import dev.icerock.gradle.data.getAppleBundlesFromKlibSources import dev.icerock.gradle.utils.klibs import org.gradle.api.Action import org.gradle.api.Task import org.gradle.api.logging.Logger +import org.gradle.api.provider.Provider import org.jetbrains.kotlin.gradle.tasks.KotlinNativeLink import java.io.File -internal abstract class CopyResourcesFromKLibsAction : Action { +internal abstract class CopyResourcesFromKLibsAction( + private val iosMinimalDeploymentTarget: Provider, +) : Action { protected fun copyResourcesFromLibraries( linkTask: KotlinNativeLink, outputDir: File ) { val logger: Logger = linkTask.logger + val assetCatalogCompiler = AppleAssetCatalogCompiler( + logger = logger, + iosMinimalDeploymentTarget = iosMinimalDeploymentTarget.get(), + ) linkTask.klibs .onEach { logger.debug("found klib dependency {}", it) } - .flatMap { getBundlesFromSources(sourceFile = it, logger = logger) } + .let { getAppleBundlesFromKlibSources(sourceFiles = it, logger = logger) } .forEach { bundle -> - logger.info("copy $bundle to $outputDir") - bundle.copyRecursively(File(outputDir, bundle.name), overwrite = true) + val destinationBundle = File(outputDir, bundle.name) + logger.info("copy $bundle to $destinationBundle") + destinationBundle.deleteRecursively() + bundle.copyRecursively(destinationBundle, overwrite = false) + assetCatalogCompiler.compile( + bundleDirectory = destinationBundle, + targetName = linkTask.target, + ) } } - - /** - * Search bundles in klib different types. - * - * We know about 3 types of klib in filesystem: - * 1. packed klib - single file with .klib extension - * 2. unpacked klib directory - root directory with klib content (used for local project - * dependencies) - * 3. unpacked klib content - all files inside klib directory (used for current project - * compilation results) - * - * @param sourceFile file from linking task dependencies and sources list - * @param logger gradle logger - * - * @return list of .bundle directories founded in klibs - */ - private fun getBundlesFromSources(sourceFile: File, logger: Logger): List { - val isPackedKlib = sourceFile.isFile && sourceFile.extension == "klib" - val isUnpackedKlib = sourceFile.isDirectory - - return if (isPackedKlib || isUnpackedKlib) { - logger.info("found klib {}", sourceFile) - getBundlesFromKotlinLibrary(sourceFile) - } else if (sourceFile.name == "manifest" && sourceFile.parentFile.name == "default") { - // for unpacked klibs we can see content files instead of klib directory. - // try to check this case - logger.info("found manifest of klib {}", sourceFile) - val unpackedKlibRoot: File = sourceFile.parentFile.parentFile - getBundlesFromKotlinLibrary(unpackedKlibRoot) - } else { - logger.debug("found some file {}", sourceFile) - emptyList() - } - } - - private fun getBundlesFromKotlinLibrary( - klibFile: File - ): List { - val resourcesDir: File = getKlibResourcesDir(klibFile) ?: return emptyList() - return resourcesDir.listFiles() - ?.filter { it.isDirectory && it.extension == "bundle" } - ?: emptyList() - } } diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/CopyResourcesFromKLibsToExecutableAction.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/CopyResourcesFromKLibsToExecutableAction.kt index 45d806175..3e7c1a7d7 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/CopyResourcesFromKLibsToExecutableAction.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/CopyResourcesFromKLibsToExecutableAction.kt @@ -5,9 +5,12 @@ package dev.icerock.gradle.actions.apple import org.gradle.api.Task +import org.gradle.api.provider.Provider import org.jetbrains.kotlin.gradle.tasks.KotlinNativeLink -internal class CopyResourcesFromKLibsToExecutableAction : CopyResourcesFromKLibsAction() { +internal class CopyResourcesFromKLibsToExecutableAction( + iosMinimalDeploymentTarget: Provider, +) : CopyResourcesFromKLibsAction(iosMinimalDeploymentTarget) { override fun execute(task: Task) { task as KotlinNativeLink diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/CopyResourcesFromKLibsToFrameworkAction.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/CopyResourcesFromKLibsToFrameworkAction.kt index 8bb62e156..7798ae493 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/CopyResourcesFromKLibsToFrameworkAction.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/CopyResourcesFromKLibsToFrameworkAction.kt @@ -5,9 +5,12 @@ package dev.icerock.gradle.actions.apple import org.gradle.api.Task +import org.gradle.api.provider.Provider import org.jetbrains.kotlin.gradle.tasks.KotlinNativeLink -internal class CopyResourcesFromKLibsToFrameworkAction : CopyResourcesFromKLibsAction() { +internal class CopyResourcesFromKLibsToFrameworkAction( + iosMinimalDeploymentTarget: Provider, +) : CopyResourcesFromKLibsAction(iosMinimalDeploymentTarget) { override fun execute(task: Task) { task as KotlinNativeLink diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/PackAppleResourcesToKLibAction.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/PackAppleResourcesToKLibAction.kt index 4526fe826..db8d5f7b9 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/PackAppleResourcesToKLibAction.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/PackAppleResourcesToKLibAction.kt @@ -4,24 +4,20 @@ package dev.icerock.gradle.actions.apple -import dev.icerock.gradle.generator.Constants import dev.icerock.gradle.generator.platform.apple.LoadableBundle import dev.icerock.gradle.utils.unzipTo import dev.icerock.gradle.utils.zipDirAs import org.gradle.api.Action -import org.gradle.api.GradleException import org.gradle.api.Task import org.gradle.api.provider.Provider import org.jetbrains.kotlin.gradle.tasks.KotlinNativeCompile import java.io.File -import java.util.Properties internal class PackAppleResourcesToKLibAction( private val assetsDirectory: Provider, private val baseLocalizationRegion: Provider, private val bundleIdentifier: Provider, private val resourcesGenerationDir: Provider, - private val iosMinimalDeploymentTarget: Provider ) : Action { override fun execute(task: Task) { task as KotlinNativeCompile @@ -51,7 +47,6 @@ internal class PackAppleResourcesToKLibAction( klibDir = klibFile, resourcesGenerationDir = resourcesGenerationDir, assetsDirectory = assetsDirectory, - task = task ) } else { task.logger.info("Adding resources to packed klib directory `{}`", klibFile) @@ -62,7 +57,6 @@ internal class PackAppleResourcesToKLibAction( klibDir = repackDir, resourcesGenerationDir = resourcesGenerationDir, assetsDirectory = assetsDirectory, - task = task ) klibFile.delete() @@ -76,22 +70,14 @@ internal class PackAppleResourcesToKLibAction( klibDir: File, resourcesGenerationDir: File, assetsDirectory: File, - task: KotlinNativeCompile ) { assert(klibDir.isDirectory) { "should be used directory as KLib" } val defaultDir = File(klibDir, "default") val resRepackDir = File(defaultDir, "resources") - val manifestFile = File(defaultDir, "manifest") - val manifest = Properties() - manifest.load(manifestFile.inputStream()) - - val uniqueName: String = manifest["unique_name"] as String - val loadableBundle = LoadableBundle( directory = resRepackDir, - bundleName = uniqueName, developmentRegion = baseLocalizationRegion.get(), identifier = bundleIdentifier.get() ) @@ -110,40 +96,5 @@ internal class PackAppleResourcesToKLibAction( overwrite = true ) } - - val rawAssetsDir = File(loadableBundle.resourcesDir, Constants.Apple.assetsDirectoryName) - if (rawAssetsDir.exists()) { - compileAppleAssets(rawAssetsDir, task) - } else { - task.logger.info("assets not found, compilation not required") - } - } - - private fun compileAppleAssets( - rawAssetsDir: File, - task: KotlinNativeCompile - ) { - val process: Process = Runtime.getRuntime().exec( - buildString { - append("xcrun actool ") - append(rawAssetsDir.name) - append(" --compile . --platform iphoneos --minimum-deployment-target ") - append(iosMinimalDeploymentTarget.get()) - }, - emptyArray(), - rawAssetsDir.parentFile - ) - val errors: String = process.errorStream.bufferedReader().readText() - val input: String = process.inputStream.bufferedReader().readText() - val result: Int = process.waitFor() - if (result != 0) { - task.logger.error("can't compile assets - $result") - task.logger.error(input) - task.logger.error(errors) - throw GradleException("Assets compilation failed: $errors") - } else { - task.logger.info("assets compiled") - rawAssetsDir.deleteRecursively() - } } } diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/PopulateDummyFrameworkResourcesAction.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/PopulateDummyFrameworkResourcesAction.kt new file mode 100644 index 000000000..bf874fdc3 --- /dev/null +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/actions/apple/PopulateDummyFrameworkResourcesAction.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2026 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.gradle.actions.apple + +import dev.icerock.gradle.data.getAppleBundlesFromKlibSources +import org.gradle.api.Action +import org.gradle.api.Task +import org.gradle.api.file.FileCollection +import org.gradle.api.provider.Provider +import org.jetbrains.kotlin.gradle.tasks.DummyFrameworkTask +import java.io.File + +internal class PopulateDummyFrameworkResourcesAction( + private val bundleIdentifiers: Provider>, + private val klibs: FileCollection, +) : Action { + + override fun execute(task: Task) { + check(task is DummyFrameworkTask) + + val bundleNames: Set = buildSet { + bundleIdentifiers.get().mapTo(this) { "$it.bundle" } + getAppleBundlesFromKlibSources( + sourceFiles = klibs.filter(File::exists), + logger = task.logger, + ).mapTo(this) { it.name } + } + + populateDummyFrameworkBundles( + frameworkDirectory = task.outputFramework.get().asFile, + bundleNames = bundleNames, + ) + } +} + +internal fun populateDummyFrameworkBundles( + frameworkDirectory: File, + bundleNames: Set, +) { + frameworkDirectory.listFiles() + ?.filter { bundleDirectory -> + bundleDirectory.isDirectory && + bundleDirectory.extension == "bundle" && + File(bundleDirectory, DUMMY_BUNDLE_MARKER_FILE_NAME).isFile && + bundleDirectory.name !in bundleNames + } + ?.forEach(File::deleteRecursively) + + bundleNames.forEach { bundleName -> + val bundleDirectory = File(frameworkDirectory, bundleName) + bundleDirectory.mkdirs() + File(bundleDirectory, DUMMY_BUNDLE_MARKER_FILE_NAME) + .writeText(DUMMY_BUNDLE_MARKER_CONTENT) + } +} + +internal const val DUMMY_BUNDLE_MARKER_FILE_NAME = "moko-resources-dummy-bundle" +private const val DUMMY_BUNDLE_MARKER_CONTENT = + "This dummy resource bundle is managed by moko-resources." diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/data/AppleBundleResources.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/data/AppleBundleResources.kt new file mode 100644 index 000000000..012591e6f --- /dev/null +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/data/AppleBundleResources.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2026 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.gradle.data + +import org.gradle.api.logging.Logger +import java.io.File + +/** + * Searches for Apple resource bundles in all filesystem shapes exposed by Kotlin/Native tasks. + */ +internal fun getAppleBundlesFromKlibSources( + sourceFiles: Iterable, + logger: Logger, +): List = sourceFiles.flatMap { sourceFile -> + val isPackedKlib = sourceFile.isFile && sourceFile.extension == "klib" + val isUnpackedKlib = sourceFile.isDirectory + + when { + isPackedKlib || isUnpackedKlib -> { + logger.info("found klib {}", sourceFile) + getAppleBundlesFromKotlinLibrary(sourceFile) + } + + sourceFile.name == "manifest" && sourceFile.parentFile?.name == "default" -> { + // Kotlin/Native may expose the content files instead of the unpacked KLib directory. + logger.info("found manifest of klib {}", sourceFile) + getAppleBundlesFromKotlinLibrary(sourceFile.parentFile.parentFile) + } + + else -> { + logger.debug("found some file {}", sourceFile) + emptyList() + } + } +} + +private fun getAppleBundlesFromKotlinLibrary(klibFile: File): List { + val resourcesDir: File = getKlibResourcesDir(klibFile) ?: return emptyList() + return resourcesDir.listFiles() + ?.filter { it.isDirectory && it.extension == "bundle" } + ?: emptyList() +} diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/data/AppleResourceBundleRegistry.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/data/AppleResourceBundleRegistry.kt new file mode 100644 index 000000000..04160d3b7 --- /dev/null +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/data/AppleResourceBundleRegistry.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2026 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.gradle.data + +import org.gradle.api.provider.SetProperty + +@Suppress("UnnecessaryAbstractClass") +internal abstract class AppleResourceBundleRegistry { + abstract val bundleIdentifiers: SetProperty +} diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/platform/apple/LoadableBundle.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/platform/apple/LoadableBundle.kt index 8d34141df..119873b7e 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/platform/apple/LoadableBundle.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/platform/apple/LoadableBundle.kt @@ -8,11 +8,10 @@ import java.io.File internal class LoadableBundle( directory: File, - bundleName: String, private val developmentRegion: String, private val identifier: String ) { - val bundleDir: File = File(directory, "$bundleName.bundle") + val bundleDir: File = File(directory, "$identifier.bundle") val contentsDir: File = File(bundleDir, "Contents") val infoPListFile: File = File(contentsDir, "Info.plist") val resourcesDir: File = File(contentsDir, "Resources") diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/platform/apple/SetupAppleUtils.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/platform/apple/SetupAppleUtils.kt index b8206c3db..77c8c4647 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/platform/apple/SetupAppleUtils.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/platform/apple/SetupAppleUtils.kt @@ -8,6 +8,7 @@ import dev.icerock.gradle.actions.apple.CopyAppleResourcesFromFrameworkToFatActi import dev.icerock.gradle.actions.apple.CopyResourcesFromKLibsToExecutableAction import dev.icerock.gradle.actions.apple.CopyResourcesFromKLibsToFrameworkAction import dev.icerock.gradle.actions.apple.PackAppleResourcesToKLibAction +import dev.icerock.gradle.actions.apple.PopulateDummyFrameworkResourcesAction import dev.icerock.gradle.tasks.CopyExecutableResourcesToApp import dev.icerock.gradle.tasks.CopyFrameworkResourcesToAppTask import dev.icerock.gradle.tasks.CopyXCFrameworkResourcesToApp @@ -22,7 +23,9 @@ import org.gradle.api.Action import org.gradle.api.DomainObjectSet import org.gradle.api.Project import org.gradle.api.Task +import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.Directory +import org.gradle.api.file.FileCollection import org.gradle.api.plugins.ExtensionAware import org.gradle.api.provider.Provider import org.gradle.api.tasks.TaskProvider @@ -38,6 +41,7 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.Framework import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFrameworkTask +import org.jetbrains.kotlin.gradle.tasks.DummyFrameworkTask import org.jetbrains.kotlin.gradle.tasks.FatFrameworkTask import org.jetbrains.kotlin.gradle.tasks.KotlinNativeCompile import java.io.File @@ -49,7 +53,6 @@ internal fun setupAppleKLibResources( resourcesGenerationDir: Provider, iosLocalizationRegion: Provider, appleBundleIdentifier: Provider, - iosMinimalDeploymentTarget: Provider, ) { compileTask.doLast( PackAppleResourcesToKLibAction( @@ -57,17 +60,22 @@ internal fun setupAppleKLibResources( bundleIdentifier = appleBundleIdentifier, assetsDirectory = assetsDirectory, resourcesGenerationDir = resourcesGenerationDir, - iosMinimalDeploymentTarget = iosMinimalDeploymentTarget ) ) } internal fun setupFrameworkResources( target: KotlinNativeTarget, + iosMinimalDeploymentTarget: Provider, + frameworkKlibs: ConfigurableFileCollection, ) { target.binaries.withType().configureEach { framework -> + frameworkKlibs.from(framework.linkTaskProvider.map { it.klibs }) + framework.linkTaskProvider.configure { linkTask -> - linkTask.doLast(CopyResourcesFromKLibsToFrameworkAction()) + linkTask.doLast( + CopyResourcesFromKLibsToFrameworkAction(iosMinimalDeploymentTarget) + ) } val project: Project = framework.project @@ -88,6 +96,41 @@ internal fun setupFrameworkResources( } } +internal fun setupCocoapodsDummyFrameworkResources( + project: Project, + bundleIdentifiers: Provider>, + frameworkKlibs: FileCollection, +) { + project.plugins.withType(KotlinCocoapodsPlugin::class.java) { + project.afterEvaluate { + val kmpExtension = project.extensions.getByType() + val cocoapodsExtension = (kmpExtension as ExtensionAware) + .extensions + .getByType() + cocoapodsExtension.framework( + Action { framework -> + cocoapodsExtension.extraSpecAttributes.putIfAbsent( + "resource", + "'build/cocoapods/framework/${framework.baseName}.framework/*.bundle'", + ) + } + ) + } + } + + project.tasks.withType().configureEach { task -> + // Dependency KLibs are intentionally not declared as task inputs: doing so would make + // Gradle build their producer tasks before CocoaPods has installed native dependencies. + task.outputs.upToDateWhen { false } + task.doLast( + PopulateDummyFrameworkResourcesAction( + bundleIdentifiers = bundleIdentifiers, + klibs = frameworkKlibs, + ) + ) + } +} + internal fun createCopyFrameworkResourcesTask(framework: Framework) { val project: Project = framework.project val taskName: String = framework.linkTaskName.replace("link", "copyResources") @@ -220,14 +263,20 @@ internal fun registerCopyXCFrameworkResourcesToAppTask( } } -internal fun setupExecutableResources(target: KotlinNativeTarget) { +internal fun setupExecutableResources( + target: KotlinNativeTarget, + iosMinimalDeploymentTarget: Provider, +) { target.binaries.withType().configureEach { executable -> - setupExecutableGradleResources(executable) - setupExecutableXcodeResources(executable) + setupExecutableGradleResources(executable, iosMinimalDeploymentTarget) + setupExecutableXcodeResources(executable, iosMinimalDeploymentTarget) } } -internal fun setupExecutableXcodeResources(executable: AbstractExecutable) { +internal fun setupExecutableXcodeResources( + executable: AbstractExecutable, + iosMinimalDeploymentTarget: Provider, +) { val copyTaskName: String = executable.linkTaskProvider.name.replace("link", "copyResources") val project: Project = executable.project @@ -235,6 +284,8 @@ internal fun setupExecutableXcodeResources(executable: AbstractExecutable) { dependsOn(executable.linkTaskProvider) klibs.from(executable.linkTaskProvider.map { it.klibs }) + konanTarget.set(executable.compilation.konanTarget.name) + this.iosMinimalDeploymentTarget.set(iosMinimalDeploymentTarget) outputDirectory.set( project.layout.dir( @@ -251,9 +302,12 @@ internal fun setupExecutableXcodeResources(executable: AbstractExecutable) { } } -internal fun setupExecutableGradleResources(executable: AbstractExecutable) { +internal fun setupExecutableGradleResources( + executable: AbstractExecutable, + iosMinimalDeploymentTarget: Provider, +) { executable.linkTaskProvider.configure { link -> - link.doLast(CopyResourcesFromKLibsToExecutableAction()) + link.doLast(CopyResourcesFromKLibsToExecutableAction(iosMinimalDeploymentTarget)) } } diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/tasks/CopyExecutableResourcesToApp.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/tasks/CopyExecutableResourcesToApp.kt index d5812d119..47d569147 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/tasks/CopyExecutableResourcesToApp.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/tasks/CopyExecutableResourcesToApp.kt @@ -4,11 +4,14 @@ package dev.icerock.gradle.tasks +import dev.icerock.gradle.actions.apple.AppleAssetCatalogCompiler import dev.icerock.gradle.data.getKlibResourcesDir import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction @@ -24,6 +27,12 @@ abstract class CopyExecutableResourcesToApp : DefaultTask() { @get:OutputDirectory abstract val outputDirectory: DirectoryProperty + @get:Input + abstract val konanTarget: Property + + @get:Input + abstract val iosMinimalDeploymentTarget: Property + init { group = "moko-resources" } @@ -31,6 +40,10 @@ abstract class CopyExecutableResourcesToApp : DefaultTask() { @TaskAction fun copyResources() { val outputDir: File = outputDirectory.get().asFile + val assetCatalogCompiler = AppleAssetCatalogCompiler( + logger = logger, + iosMinimalDeploymentTarget = iosMinimalDeploymentTarget.get(), + ) klibs .filter { library -> library.extension == "klib" } @@ -43,8 +56,14 @@ abstract class CopyExecutableResourcesToApp : DefaultTask() { .listFiles(FileFilter { it.extension == "bundle" }) // copying bundles to app ?.forEach { - logger.info("${it.absolutePath} copying to $outputDir") - it.copyRecursively(target = File(outputDir, it.name), overwrite = true) + val destinationBundle = File(outputDir, it.name) + logger.info("${it.absolutePath} copying to $destinationBundle") + destinationBundle.deleteRecursively() + it.copyRecursively(target = destinationBundle, overwrite = false) + assetCatalogCompiler.compile( + bundleDirectory = destinationBundle, + targetName = konanTarget.get(), + ) } } } diff --git a/resources-generator/src/test/kotlin/dev/icerock/gradle/actions/apple/AppleAssetCatalogCompilerTest.kt b/resources-generator/src/test/kotlin/dev/icerock/gradle/actions/apple/AppleAssetCatalogCompilerTest.kt new file mode 100644 index 000000000..661c08eb6 --- /dev/null +++ b/resources-generator/src/test/kotlin/dev/icerock/gradle/actions/apple/AppleAssetCatalogCompilerTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2026 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.gradle.actions.apple + +import org.gradle.api.logging.Logging +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AppleAssetCatalogCompilerTest { + @Test + fun `maps Apple targets to actool platforms`() { + assertEquals(AppleActoolPlatform.IOS_DEVICE, actoolPlatform("ios_arm64")) + assertEquals(AppleActoolPlatform.IOS_SIMULATOR, actoolPlatform("ios_simulator_arm64")) + assertEquals(AppleActoolPlatform.IOS_SIMULATOR, actoolPlatform("ios_x64")) + assertEquals(AppleActoolPlatform.MACOS, actoolPlatform("macos_arm64")) + assertEquals(AppleActoolPlatform.MACOS, actoolPlatform("macos_x64")) + assertEquals(AppleActoolPlatform.TVOS_DEVICE, actoolPlatform("tvos_arm64")) + assertEquals( + AppleActoolPlatform.TVOS_SIMULATOR, + actoolPlatform("tvos_simulator_arm64"), + ) + assertEquals(AppleActoolPlatform.WATCHOS_DEVICE, actoolPlatform("watchos_arm64")) + assertEquals( + AppleActoolPlatform.WATCHOS_SIMULATOR, + actoolPlatform("watchos_simulator_arm64"), + ) + } + + @Test + fun `only iOS platforms require configured deployment target`() { + assertTrue(AppleActoolPlatform.IOS_DEVICE.requiresMinimumDeploymentTarget) + assertTrue(AppleActoolPlatform.IOS_SIMULATOR.requiresMinimumDeploymentTarget) + assertTrue( + AppleActoolPlatform.entries + .filterNot { + it == AppleActoolPlatform.IOS_DEVICE || + it == AppleActoolPlatform.IOS_SIMULATOR + } + .none(AppleActoolPlatform::requiresMinimumDeploymentTarget) + ) + } + + @Test + fun `keeps precompiled asset catalog without invoking actool`() { + val temporaryDirectory: File = createTempDirectory("apple-assets-test").toFile() + try { + val bundleDirectory = File(temporaryDirectory, "legacy.bundle") + val compiledAssetCatalog = File(bundleDirectory, "Contents/Resources/Assets.car") + compiledAssetCatalog.parentFile.mkdirs() + compiledAssetCatalog.writeText("precompiled") + + AppleAssetCatalogCompiler( + logger = Logging.getLogger(AppleAssetCatalogCompilerTest::class.java), + iosMinimalDeploymentTarget = "12.0", + ).compile( + bundleDirectory = bundleDirectory, + targetName = "invalid_target_proves_actool_was_not_used", + ) + + assertTrue(compiledAssetCatalog.isFile) + assertEquals("precompiled", compiledAssetCatalog.readText()) + } finally { + temporaryDirectory.deleteRecursively() + } + } +} diff --git a/resources-generator/src/test/kotlin/dev/icerock/gradle/actions/apple/PopulateDummyFrameworkResourcesActionTest.kt b/resources-generator/src/test/kotlin/dev/icerock/gradle/actions/apple/PopulateDummyFrameworkResourcesActionTest.kt new file mode 100644 index 000000000..fbc97b773 --- /dev/null +++ b/resources-generator/src/test/kotlin/dev/icerock/gradle/actions/apple/PopulateDummyFrameworkResourcesActionTest.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2026 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.gradle.actions.apple + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PopulateDummyFrameworkResourcesActionTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `creates requested dummy bundles with ownership markers`() { + val framework = temporaryFolder.newFolder("shared.framework") + + populateDummyFrameworkBundles( + frameworkDirectory = framework, + bundleNames = setOf("module.main.bundle", "dependency.main.bundle"), + ) + + assertTrue(File(framework, "module.main.bundle/$DUMMY_BUNDLE_MARKER_FILE_NAME").isFile) + assertTrue(File(framework, "dependency.main.bundle/$DUMMY_BUNDLE_MARKER_FILE_NAME").isFile) + } + + @Test + fun `removes only stale moko resource dummy bundles`() { + val framework = temporaryFolder.newFolder("shared.framework") + val staleBundle = createBundle(framework, "stale.main.bundle", withMarker = true) + val realBundle = createBundle(framework, "real.main.bundle", withMarker = false) + + populateDummyFrameworkBundles( + frameworkDirectory = framework, + bundleNames = setOf("current.main.bundle"), + ) + + assertFalse(staleBundle.exists()) + assertTrue(realBundle.isDirectory) + assertTrue(File(realBundle, "real-resource").isFile) + assertTrue(File(framework, "current.main.bundle/$DUMMY_BUNDLE_MARKER_FILE_NAME").isFile) + } + + private fun createBundle( + framework: File, + name: String, + withMarker: Boolean, + ): File = File(framework, name).apply { + mkdirs() + val fileName = if (withMarker) DUMMY_BUNDLE_MARKER_FILE_NAME else "real-resource" + File(this, fileName).writeText("content") + } +} diff --git a/resources-generator/src/test/kotlin/dev/icerock/gradle/data/AppleBundleResourcesTest.kt b/resources-generator/src/test/kotlin/dev/icerock/gradle/data/AppleBundleResourcesTest.kt new file mode 100644 index 000000000..93542e9bf --- /dev/null +++ b/resources-generator/src/test/kotlin/dev/icerock/gradle/data/AppleBundleResourcesTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2026 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.gradle.data + +import org.gradle.api.logging.Logging +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.test.assertEquals + +class AppleBundleResourcesTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `finds bundle in unpacked klib root`() { + val klib = temporaryFolder.newFolder("library") + createBundle(File(klib, "default/resources/example.main.bundle")) + + assertBundleNames( + sources = listOf(klib), + expected = setOf("example.main.bundle"), + ) + } + + @Test + fun `finds bundle from unpacked klib manifest`() { + val klib = temporaryFolder.newFolder("library") + val manifest = File(klib, "default/manifest").apply { + parentFile.mkdirs() + writeText("unique_name=example") + } + createBundle(File(klib, "default/resources/example.main.bundle")) + + assertBundleNames( + sources = listOf(manifest), + expected = setOf("example.main.bundle"), + ) + } + + @Test + fun `finds bundle in packed klib`() { + val klib = temporaryFolder.newFile("library.klib") + ZipOutputStream(klib.outputStream()).use { zip -> + zip.putNextEntry( + ZipEntry("default/resources/example.main.bundle/Contents/Info.plist") + ) + zip.write("plist".toByteArray()) + zip.closeEntry() + } + + assertBundleNames( + sources = listOf(klib), + expected = setOf("example.main.bundle"), + ) + } + + private fun assertBundleNames(sources: List, expected: Set) { + val actual = getAppleBundlesFromKlibSources( + sourceFiles = sources, + logger = Logging.getLogger(AppleBundleResourcesTest::class.java), + ).mapTo(mutableSetOf()) { it.name } + + assertEquals(expected, actual) + } + + private fun createBundle(bundleDirectory: File) { + File(bundleDirectory, "Contents/Info.plist").apply { + parentFile.mkdirs() + writeText("plist") + } + } +} diff --git a/resources-generator/src/test/kotlin/dev/icerock/gradle/generator/platform/apple/LoadableBundleTest.kt b/resources-generator/src/test/kotlin/dev/icerock/gradle/generator/platform/apple/LoadableBundleTest.kt new file mode 100644 index 000000000..35ac700e9 --- /dev/null +++ b/resources-generator/src/test/kotlin/dev/icerock/gradle/generator/platform/apple/LoadableBundleTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2026 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.gradle.generator.platform.apple + +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LoadableBundleTest { + @Test + fun `bundle directory uses public bundle identifier`() { + val temporaryDirectory: File = createTempDirectory("loadable-bundle-test").toFile() + try { + val loadableBundle = LoadableBundle( + directory = temporaryDirectory, + developmentRegion = "en", + identifier = "com.example.resources.main", + ) + + loadableBundle.write() + + assertEquals( + "com.example.resources.main.bundle", + loadableBundle.bundleDir.name, + ) + assertTrue(loadableBundle.infoPListFile.isFile) + assertTrue( + loadableBundle.infoPListFile.readText() + .contains("com.example.resources.main") + ) + } finally { + temporaryDirectory.deleteRecursively() + } + } +} diff --git a/samples/compose-resources-gallery/shared/build.gradle.kts b/samples/compose-resources-gallery/shared/build.gradle.kts index 0bea65ac7..058ddbc15 100644 --- a/samples/compose-resources-gallery/shared/build.gradle.kts +++ b/samples/compose-resources-gallery/shared/build.gradle.kts @@ -1,7 +1,6 @@ @file:OptIn(ExperimentalWasmDsl::class) import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl -import org.jetbrains.kotlin.gradle.tasks.DummyFrameworkTask plugins { kotlin("multiplatform") @@ -41,8 +40,6 @@ kotlin { baseName = "shared" isStatic = true } - // TODO move to gradle plugin - extraSpecAttributes["resource"] = "'build/cocoapods/framework/shared.framework/*.bundle'" } compilerOptions { @@ -102,26 +99,3 @@ android { multiplatformResources { resourcesPackage.set("com.icerockdev.library") } - -// TODO move to gradle plugin -tasks.withType().configureEach { - @Suppress("ObjectLiteralToLambda") - doLast(object : Action { - override fun execute(task: Task) { - task as DummyFrameworkTask - - val frameworkDir: File = task.outputFramework.get().asFile - - // TODO here we should fill list from local gradle modules - // AND from external dependencies with bundles - // to fill full list of bundles - listOf( - "compose-resources-gallery:shared.bundle" - ).forEach { bundleName -> - val bundleDir = File(frameworkDir, bundleName) - bundleDir.mkdir() - File(bundleDir, "dummyFile").writeText("dummy") - } - } - }) -} diff --git a/samples/kotlin-2-tests/local-check.sh b/samples/kotlin-2-tests/local-check.sh index a324b0060..affb3efb7 100755 --- a/samples/kotlin-2-tests/local-check.sh +++ b/samples/kotlin-2-tests/local-check.sh @@ -8,9 +8,40 @@ log() { echo "\033[0;32m> $1\033[0m" } +assert_apple_klib_resources() { + local bundle_dir + bundle_dir="$(find shared/build/classes/kotlin/iosArm64/main/klib -type d -name '*.bundle' -print -quit)" + + test -n "$bundle_dir" + case "$(basename "$bundle_dir")" in + *:*) + echo "Apple bundle name is not portable: $bundle_dir" >&2 + exit 1 + ;; + esac + + test -f "$bundle_dir/Contents/Info.plist" + test -d "$bundle_dir/Contents/Resources/Assets.xcassets" + test ! -f "$bundle_dir/Contents/Resources/Assets.car" +} + +assert_linked_apple_resources() { + local bundle_dir + bundle_dir="$(find shared/build/bin/iosArm64/debugFramework -type d -name '*.bundle' -print -quit)" + + test -n "$bundle_dir" + test -f "$bundle_dir/Contents/Info.plist" + test -f "$bundle_dir/Contents/Resources/Assets.car" + test ! -d "$bundle_dir/Contents/Resources/Assets.xcassets" +} + ./gradlew clean assembleDebug log "kotlin-2-tests mobile android success" +./gradlew :shared:compileKotlinIosArm64 +assert_apple_klib_resources +log "kotlin-2-tests Apple KLib cross-compilation success" + if ! command -v xcodebuild &> /dev/null then log "xcodebuild could not be found, skip ios checks" @@ -21,6 +52,10 @@ then ./gradlew assembleDebug assembleRelease jsJar jvmJar log "kotlin-2-tests build success" else + ./gradlew :shared:linkDebugFrameworkIosArm64 + assert_linked_apple_resources + log "kotlin-2-tests Apple resources finalization success" + ./gradlew build log "kotlin-2-tests success" fi diff --git a/samples/kotlin-2-tests/settings.gradle.kts b/samples/kotlin-2-tests/settings.gradle.kts index 4ead78bf3..9ec41d2a4 100644 --- a/samples/kotlin-2-tests/settings.gradle.kts +++ b/samples/kotlin-2-tests/settings.gradle.kts @@ -3,17 +3,17 @@ include(":shared") pluginManagement { repositories { + mavenLocal() google() gradlePluginPortal() - mavenLocal() } } dependencyResolutionManagement { repositories { + mavenLocal() google() mavenCentral() - mavenLocal() } versionCatalogs { diff --git a/samples/kotlin-2-tests/shared/build.gradle.kts b/samples/kotlin-2-tests/shared/build.gradle.kts index 640f9aa68..2453c4aa5 100644 --- a/samples/kotlin-2-tests/shared/build.gradle.kts +++ b/samples/kotlin-2-tests/shared/build.gradle.kts @@ -9,9 +9,15 @@ kotlin { androidTarget() jvm() - iosX64() - iosArm64() - iosSimulatorArm64() + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { target -> + target.binaries.framework { + baseName = "shared" + } + } js(IR) { browser { testTask { diff --git a/samples/kotlin-2-tests/shared/src/commonMain/moko-resources/images/cross_compile.svg b/samples/kotlin-2-tests/shared/src/commonMain/moko-resources/images/cross_compile.svg new file mode 100644 index 000000000..faecb1ec7 --- /dev/null +++ b/samples/kotlin-2-tests/shared/src/commonMain/moko-resources/images/cross_compile.svg @@ -0,0 +1,5 @@ + + + + + From 18a1907166cbc8aa93ec087fdb9c18522c98ff7d Mon Sep 17 00:00:00 2001 From: Kolchurin Konstantin Date: Wed, 23 Sep 2026 12:11:09 +0700 Subject: [PATCH 5/6] #850 Android KMP library resources --- .github/compilation-check-source.yml | 21 +++++++++ .github/workflows/compilation-check.yml | 47 +++++++++++++++++++ .../gradle/MultiplatformResourcesPlugin.kt | 3 +- .../platform/android/GeneratorPlatformType.kt | 35 ++++++++++++++ .../android/GeneratorPlatformTypeTest.kt | 31 ++++++++++++ samples/android-kmp-library/build.gradle.kts | 15 ++++++ .../android-kmp-library/core/build.gradle.kts | 30 ++++++++++++ .../moko-resources/base/strings.xml | 4 ++ samples/android-kmp-library/gradle.properties | 3 ++ samples/android-kmp-library/local-check.sh | 39 +++++++++++++++ .../android-kmp-library/settings.gradle.kts | 25 ++++++++++ 11 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 resources-generator/src/main/kotlin/dev/icerock/gradle/generator/platform/android/GeneratorPlatformType.kt create mode 100644 resources-generator/src/test/kotlin/dev/icerock/gradle/generator/platform/android/GeneratorPlatformTypeTest.kt create mode 100644 samples/android-kmp-library/build.gradle.kts create mode 100644 samples/android-kmp-library/core/build.gradle.kts create mode 100644 samples/android-kmp-library/core/src/commonMain/moko-resources/base/strings.xml create mode 100644 samples/android-kmp-library/gradle.properties create mode 100755 samples/android-kmp-library/local-check.sh create mode 100644 samples/android-kmp-library/settings.gradle.kts diff --git a/.github/compilation-check-source.yml b/.github/compilation-check-source.yml index 96df0b9fb..0d671ac56 100644 --- a/.github/compilation-check-source.yml +++ b/.github/compilation-check-source.yml @@ -311,6 +311,27 @@ jobs: - *publish_test_report - *upload_reports + check-android-kmp-library: + runs-on: ${{ matrix.os }} + strategy: + matrix: + <<: *runner_matrix + needs: build-library + + steps: + - *checkout + - *setup_jdk + - *setup_gradle + - *cache_konan + - *download_maven + + - name: Sample - android-kmp-library + run: cd samples/android-kmp-library && ./local-check.sh + shell: bash + + - *publish_test_report + - *upload_reports + check-kotlin-2-tests: runs-on: ${{ matrix.os }} strategy: diff --git a/.github/workflows/compilation-check.yml b/.github/workflows/compilation-check.yml index deea4ed37..4336279b1 100644 --- a/.github/workflows/compilation-check.yml +++ b/.github/workflows/compilation-check.yml @@ -556,6 +556,53 @@ jobs: name: code-coverage-report-${{ github.job }}-${{ matrix.os }} path: "**/build/reports/**/*" + check-android-kmp-library: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: + - macOS-latest + - windows-latest + - ubuntu-latest + needs: build-library + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: zulu + - name: Setup Gradle + uses: gradle/gradle-build-action@v3 + with: + cache-read-only: ${{ github.ref != 'refs/heads/master' && github.ref != + 'refs/heads/develop' }} + - name: Cache .konan + uses: actions/cache@v4 + with: + path: ~/.konan + key: ${{ matrix.os }}-konan-${{ hashFiles('**/*.gradle*', 'gradle/**/*') }} + - name: Download maven artifacts + uses: actions/download-artifact@v4 + with: + name: maven + path: ~/.m2/repository/dev/icerock + - name: Sample - android-kmp-library + run: cd samples/android-kmp-library && ./local-check.sh + shell: bash + - name: Publish Test Report + uses: mikepenz/action-junit-report@v4 + if: ${{ always() }} + with: + report_paths: "**/build/test-results/**/TEST-*.xml" + github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Archive reports + uses: actions/upload-artifact@v4 + if: ${{ always() }} + with: + name: code-coverage-report-${{ github.job }}-${{ matrix.os }} + path: "**/build/reports/**/*" + check-kotlin-2-tests: runs-on: ${{ matrix.os }} strategy: diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/MultiplatformResourcesPlugin.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/MultiplatformResourcesPlugin.kt index 17e11ccd0..f2ba53889 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/MultiplatformResourcesPlugin.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/MultiplatformResourcesPlugin.kt @@ -11,6 +11,7 @@ import dev.icerock.gradle.extra.getOrRegisterGenerateResourcesTask import dev.icerock.gradle.generator.platform.android.AGP_8_11_0 import dev.icerock.gradle.generator.platform.android.AndroidPluginType import dev.icerock.gradle.generator.platform.android.getAndroidSourceSetOrNull +import dev.icerock.gradle.generator.platform.android.resourcesPlatformTypeName import dev.icerock.gradle.generator.platform.android.setupAndroidTasks import dev.icerock.gradle.generator.platform.android.setupAndroidVariantsSync import dev.icerock.gradle.generator.platform.apple.registerCopyFrameworkResourcesToAppTask @@ -141,7 +142,7 @@ open class MultiplatformResourcesPlugin : Plugin { sourceSet.getOrRegisterGenerateResourcesTask(mrExtension) genTaskProvider.configure { - it.platformType.set(target.platformType.name) + it.platformType.set(target.resourcesPlatformTypeName(project)) if (target is KotlinNativeTarget) { it.konanTarget.set(target.konanTarget.name) diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/platform/android/GeneratorPlatformType.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/platform/android/GeneratorPlatformType.kt new file mode 100644 index 000000000..b48b0e422 --- /dev/null +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/platform/android/GeneratorPlatformType.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.gradle.generator.platform.android + +import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget +import org.gradle.api.Project +import org.jetbrains.kotlin.gradle.plugin.KotlinTarget + +/** + * Resolves the platform used by moko-resources generators. + * + * AGP's Kotlin Multiplatform Android target reports `jvm`, even though its + * resources must be generated using Android resource generators. The concrete target type is + * used to distinguish it from a regular Kotlin JVM target in the same project. + */ +internal fun KotlinTarget.resourcesPlatformTypeName(project: Project): String { + val isKmpAndroidTarget: Boolean = project.hasAndroidKmpLibraryPlugin() && + this is KotlinMultiplatformAndroidLibraryTarget + + return normalizeResourcesPlatformTypeName( + reportedPlatformType = platformType.name, + isKmpAndroidTarget = isKmpAndroidTarget, + ) +} + +internal fun normalizeResourcesPlatformTypeName( + reportedPlatformType: String, + isKmpAndroidTarget: Boolean, +): String { + return if (isKmpAndroidTarget) ANDROID_PLATFORM_TYPE else reportedPlatformType +} + +private const val ANDROID_PLATFORM_TYPE = "androidJvm" diff --git a/resources-generator/src/test/kotlin/dev/icerock/gradle/generator/platform/android/GeneratorPlatformTypeTest.kt b/resources-generator/src/test/kotlin/dev/icerock/gradle/generator/platform/android/GeneratorPlatformTypeTest.kt new file mode 100644 index 000000000..cd2888975 --- /dev/null +++ b/resources-generator/src/test/kotlin/dev/icerock/gradle/generator/platform/android/GeneratorPlatformTypeTest.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2026 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.gradle.generator.platform.android + +import org.junit.Test +import kotlin.test.assertEquals + +class GeneratorPlatformTypeTest { + + @Test + fun `kmp android target reported as jvm uses android generators`() { + val actual = normalizeResourcesPlatformTypeName( + reportedPlatformType = "jvm", + isKmpAndroidTarget = true, + ) + + assertEquals("androidJvm", actual) + } + + @Test + fun `regular jvm target remains jvm`() { + val actual = normalizeResourcesPlatformTypeName( + reportedPlatformType = "jvm", + isKmpAndroidTarget = false, + ) + + assertEquals("jvm", actual) + } +} diff --git a/samples/android-kmp-library/build.gradle.kts b/samples/android-kmp-library/build.gradle.kts new file mode 100644 index 000000000..f5116f7bf --- /dev/null +++ b/samples/android-kmp-library/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("org.jetbrains.kotlin.multiplatform") version "2.2.20" apply false + id("com.android.kotlin.multiplatform.library") version "8.13.0" apply false +} + +buildscript { + repositories { + mavenLocal() + google() + mavenCentral() + } + dependencies { + classpath(moko.resourcesGradlePlugin) + } +} diff --git a/samples/android-kmp-library/core/build.gradle.kts b/samples/android-kmp-library/core/build.gradle.kts new file mode 100644 index 000000000..cc2197258 --- /dev/null +++ b/samples/android-kmp-library/core/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("com.android.kotlin.multiplatform.library") + id("dev.icerock.mobile.multiplatform-resources") +} + +kotlin { + jvmToolchain(17) + + androidLibrary { + namespace = "dev.icerock.moko.resources.androidkmp" + compileSdk = 35 + minSdk = 21 + } + + jvm() + iosX64() + iosArm64() + iosSimulatorArm64() + + sourceSets { + commonMain.dependencies { + api(moko.resources) + } + } +} + +multiplatformResources { + resourcesPackage.set("dev.icerock.moko.resources.androidkmp") +} diff --git a/samples/android-kmp-library/core/src/commonMain/moko-resources/base/strings.xml b/samples/android-kmp-library/core/src/commonMain/moko-resources/base/strings.xml new file mode 100644 index 000000000..ff6a3f1a2 --- /dev/null +++ b/samples/android-kmp-library/core/src/commonMain/moko-resources/base/strings.xml @@ -0,0 +1,4 @@ + + + Hello + diff --git a/samples/android-kmp-library/gradle.properties b/samples/android-kmp-library/gradle.properties new file mode 100644 index 000000000..8499b0279 --- /dev/null +++ b/samples/android-kmp-library/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx3G -Dfile.encoding=UTF-8 +kotlin.code.style=official +android.useAndroidX=true diff --git a/samples/android-kmp-library/local-check.sh b/samples/android-kmp-library/local-check.sh new file mode 100755 index 000000000..5a2e82902 --- /dev/null +++ b/samples/android-kmp-library/local-check.sh @@ -0,0 +1,39 @@ +# +# Copyright 2026 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. +# + +set -e + +log() { + echo "\033[0;32m> $1\033[0m" +} + +assert_generated_resources() { + local sample_dir="core/build/generated/moko-resources" + local android_sources="$sample_dir/androidMain/src" + local jvm_sources="$sample_dir/jvmMain/src" + local jvm_properties + local unexpected_properties + + test -f "$sample_dir/androidMain/res/values/multiplatform_strings.xml" + + unexpected_properties="$( + find "$sample_dir/androidMain/res" -type f -name '*.properties' -print -quit + )" + if test -n "$unexpected_properties" + then + echo "JVM resource generated in Android res directory: $unexpected_properties" >&2 + exit 1 + fi + + jvm_properties="$( + find "$sample_dir/jvmMain/res" -type f -name '*.properties' -print -quit + )" + test -n "$jvm_properties" + grep -R -q 'R.string.hello' "$android_sources" + grep -R -q 'ClassLoader' "$jvm_sources" +} + +../../gradlew -p . clean :core:assemble +assert_generated_resources +log "Android KMP library resources success" diff --git a/samples/android-kmp-library/settings.gradle.kts b/samples/android-kmp-library/settings.gradle.kts new file mode 100644 index 000000000..b6bb4ff04 --- /dev/null +++ b/samples/android-kmp-library/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + repositories { + mavenLocal() + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + mavenLocal() + google() + mavenCentral() + } + + versionCatalogs { + create("moko") { + from(files("../../gradle/moko.versions.toml")) + } + } +} + +rootProject.name = "android-kmp-library" +include(":core") From 6caaa7c5a8c001ab93461edab4a1b3d7e2b5a325 Mon Sep 17 00:00:00 2001 From: Kolchurin Konstantin Date: Wed, 23 Sep 2026 12:15:59 +0700 Subject: [PATCH 6/6] Document platform compatibility --- COMPATIBILITY.md | 23 +++++++++++++++++++++++ README.md | 22 ++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index f89305976..fed71646d 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -40,3 +40,26 @@ AGP 8.13.2 and Gradle 8.14.2 are unrelated version lines. The latter is a Gradle version used by compatibility samples, not a newer AGP 8 release. See the [AGP 8.13 release notes](https://developer.android.com/build/releases/agp-8-13-0-release-notes). + +## Apple KLib resources: 0.28.0 breaking KLib format change + +Starting with moko-resources 0.28.0, Apple KLibs with image or color resources +contain raw `Assets.xcassets` instead of a precompiled `Assets.car`. This makes +the KLib portable across Windows, Linux, and macOS, but moves asset catalog +compilation to the macOS project that links the final Apple framework or +executable. + +This is a breaking change to the KLib resource format, not to the runtime API. +Gradle plugins are not inherited transitively from library dependencies, so the +project that links the final Apple binary must choose a compatible plugin +version itself. + +| Produced Apple KLib | Final Apple consumer Gradle plugin | Result | +| --- | --- | --- | +| `< 0.28.0`, contains `Assets.car` | Any version | Supported | +| `>= 0.28.0`, no image/color resources | `< 0.28.0` | Supported; no asset catalog compilation is required | +| `>= 0.28.0`, contains raw `Assets.xcassets` | `< 0.28.0` | Not supported for images and colors; the raw catalog is copied but not compiled | +| `>= 0.28.0`, contains raw `Assets.xcassets` | `>= 0.28.0` | Supported; `actool` runs while linking the final Apple binary | + +Newer plugin versions remain compatible with older KLibs that already contain +`Assets.car`. diff --git a/README.md b/README.md index f9f053f13..601539c2a 100755 --- a/README.md +++ b/README.md @@ -55,6 +55,19 @@ implement all your UI in Kotlin with Jetpack Compose and MOKO resources. The consumer compatibility policy and generator toolchain constraints are documented in [COMPATIBILITY.md](COMPATIBILITY.md). +### Apple KLib cross-compilation + +With Kotlin 2.2.20 or newer, an Apple-target KLib can be compiled on Windows, +Linux, and macOS when neither the library nor its dependencies use cinterop or +CocoaPods. Linking, testing, and packaging a final Apple binary still require +macOS and Xcode. + +Starting with moko-resources 0.28.0, Apple KLibs with image or color resources +use a new raw asset catalog format. The macOS project that links the final Apple +binary must use moko-resources Gradle plugin 0.28.0 or newer. See +[COMPATIBILITY.md](COMPATIBILITY.md#apple-klib-resources-0280-breaking-klib-format-change) +for the producer/consumer compatibility matrix. + ## Installation ### Gradle setup @@ -185,7 +198,8 @@ You should enable moko-resources gradle plugin in `resources` module, that conta #### Android Host Tests (Unit Tests) If you use the new Android Multiplatform Library plugin (`com.android.kotlin.multiplatform.library`), -enabling Android resources for host tests (Unit tests) depends on your AGP version. +moko-resources generates Android resources and `R` accessors for its Android target. +For host tests (Unit tests), enabling Android resources depends on your AGP version. This is required for moko-resources to access generated R classes during testing. For AGP 8.8.0 and higher @@ -288,6 +302,10 @@ In Xcode add `Build Phase` (at end of list) with script: `YourFrameworkName` is name of your project framework. Please, see on a static framework warning for get correct task name. +moko-resources automatically adds the framework bundle glob to the generated +podspec. Do not add the same resource glob manually; an explicit +`extraSpecAttributes["resource"]` value remains unchanged. + #### Without org.jetbrains.kotlin.native.cocoapods In Xcode add `Build Phase` (at end of list) with script: @@ -970,7 +988,7 @@ val assetContent: String? by MR.assets.test.readTextAsState() ### iOS shows key instead of localized text -1. check that generated `Localizable.strings` file is valid - open it by Xcode (located in `shared/shared/build/bin/iosSimulatorArm64/debugFramework/shared.framework/:shared.bundle/Contents/Resources/Base.lproj/Localizable.strings` and in other `.lproj` directories. If Xcode show error in file - you should fix content of strings.xml (for example you use some special character that broke file). +1. check that generated `Localizable.strings` file is valid - open it by Xcode (located in `shared/shared/build/bin/iosSimulatorArm64/debugFramework/shared.framework/.main.bundle/Contents/Resources/Base.lproj/Localizable.strings` and in other `.lproj` directories. If Xcode show error in file - you should fix content of strings.xml (for example you use some special character that broke file). 2. check that your generated `.bundle` exist inside application at runtime. In Xcode inside group `Products` select your application and click `Show in Finder`. Then click `Show Package Contents`. Inside `.app` you should see `.bundle` in root directory if you use static framework. And in `Frameworks/shared.framework` if you use dynamic framework. If `bundle` missed - check installation guide. Specifically xcode build phase part if you use static framework. And check that you apply moko-resources plugin in `shared` gradle module.