From 1e985d3cdd84ce4ee8dc10d0e0d0727a20afd7d5 Mon Sep 17 00:00:00 2001 From: Kolchurin Konstantin Date: Sat, 19 Sep 2026 21:58:49 +0700 Subject: [PATCH 1/3] 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/3] 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/3] 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) }