Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
@@ -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).
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
```

Expand Down
20 changes: 10 additions & 10 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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"


Expand All @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion resources-generator/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ kotlin {
tasks.withType<KotlinJvmCompile>().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"))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<File>,
Expand Down Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -231,6 +233,7 @@ internal class ResourcesGenerator(
.map { it.contentHash() }
.calculateHash()

objectSpec.addResourceContainersKdoc(generatedObjects)
objectSpec.addContentHashProperty(contentHash)

objectSpec.also { builder ->
Expand All @@ -243,4 +246,14 @@ internal class ResourcesGenerator(

fileSpec.addType(objectSpec.build())
}

private fun TypeSpec.Builder.addResourceContainersKdoc(
generatedObjects: List<GenerationResult>,
) {
addKdoc("Entry point for generated resources.\n\n")
addKdoc("Available resource containers:\n")
generatedObjects.forEach { result ->
addKdoc("- [%L]\n", result.metadata.name)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,94 @@ 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.Path
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)
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(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) }
}
}

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 ->
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Loading