diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/ResourceTypeGenerator.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/ResourceTypeGenerator.kt index 4b50f1a0..d0ffd2bd 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/ResourceTypeGenerator.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/generator/ResourceTypeGenerator.kt @@ -14,6 +14,7 @@ import dev.icerock.gradle.metadata.container.ResourceType import dev.icerock.gradle.metadata.resource.ResourceMetadata import dev.icerock.gradle.utils.filterClass import org.gradle.api.tasks.util.PatternFilterable +import java.io.File import kotlin.reflect.KClass @Suppress("LongParameterList", "TooManyFunctions", "UnusedPrivateMember") @@ -28,7 +29,11 @@ internal class ResourceTypeGenerator( private val filter: PatternFilterable.() -> Unit, ) { fun generateMetadata(files: ResourcesFiles): List { - return generator.generateMetadata(files.matching(filter).ownSourceSet.fileTree.files) + // Sort for deterministic output; FileTree.files iteration is filesystem-dependent. + val sortedFiles: Set = files.matching(filter).ownSourceSet.fileTree.files + .sortedBy { it.absolutePath } + .toCollection(LinkedHashSet()) + return generator.generateMetadata(sortedFiles) } fun getImports(): List = platformResourceGenerator.imports() diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/metadata/resource/ResourceMetadata.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/metadata/resource/ResourceMetadata.kt index 06bf63e1..fdc59a89 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/metadata/resource/ResourceMetadata.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/metadata/resource/ResourceMetadata.kt @@ -61,7 +61,7 @@ internal data class StringMetadata( ) @Suppress("MagicNumber") - override fun contentHash(): String = values.hashCode().toString(16) + override fun contentHash(): String = values.toString().hashCode().toString(16) } @Serializable @@ -94,7 +94,7 @@ internal data class PluralMetadata( } @Suppress("MagicNumber") - override fun contentHash(): String = values.hashCode().toString(16) + override fun contentHash(): String = values.toString().hashCode().toString(16) } @Serializable diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/tasks/GenerateMultiplatformResourcesTask.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/tasks/GenerateMultiplatformResourcesTask.kt index edbdc11f..7f5ff2b1 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/tasks/GenerateMultiplatformResourcesTask.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/tasks/GenerateMultiplatformResourcesTask.kt @@ -155,9 +155,12 @@ abstract class GenerateMultiplatformResourcesTask : DefaultTask() { ) val serializer: KSerializer> = ListSerializer(ContainerMetadata.serializer()) - val inputMetadata: List = inputMetadataFiles.files.flatMap { file -> - json.decodeFromString(serializer, file.readText()) - } + // Sort for deterministic output. + val inputMetadata: List = inputMetadataFiles.files + .sortedBy { it.absolutePath } + .flatMap { file -> + json.decodeFromString(serializer, file.readText()) + } val outputMetadata: List = if (kotlinPlatformType.isCommon) { generator.generateCommonKotlin(files, inputMetadata) diff --git a/resources-generator/src/main/kotlin/dev/icerock/gradle/utils/calcHash.kt b/resources-generator/src/main/kotlin/dev/icerock/gradle/utils/calcHash.kt index 5df62d2a..b0d2351d 100644 --- a/resources-generator/src/main/kotlin/dev/icerock/gradle/utils/calcHash.kt +++ b/resources-generator/src/main/kotlin/dev/icerock/gradle/utils/calcHash.kt @@ -12,8 +12,11 @@ import java.io.InputStream import java.io.SequenceInputStream internal fun File.calculateResourcesHash(): String { + val root: File = this + // Sort for deterministic output; walkTopDown() order is filesystem-dependent. val inputStreams: List = walkTopDown() .filterNot { it.isDirectory } + .sortedBy { it.relativeTo(root).path } .map { it.inputStream() }.toList() val singleInputStream: InputStream = SequenceInputStream(inputStreams.toEnumeration()) diff --git a/resources-generator/src/test/kotlin/dev/icerock/gradle/generator/DeterministicOutputTest.kt b/resources-generator/src/test/kotlin/dev/icerock/gradle/generator/DeterministicOutputTest.kt new file mode 100644 index 00000000..592acc11 --- /dev/null +++ b/resources-generator/src/test/kotlin/dev/icerock/gradle/generator/DeterministicOutputTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2026 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.gradle.generator + +import dev.icerock.gradle.metadata.resource.PluralMetadata +import dev.icerock.gradle.metadata.resource.PluralMetadata.PluralItem +import dev.icerock.gradle.metadata.resource.PluralMetadata.PluralItem.Quantity +import dev.icerock.gradle.metadata.resource.StringMetadata +import dev.icerock.gradle.utils.calculateResourcesHash +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals + +class DeterministicOutputTest { + + private val plural = PluralMetadata( + key = "apples", + values = listOf( + PluralMetadata.LocaleItem( + locale = "base", + values = listOf( + PluralItem(Quantity.ONE, "one apple"), + PluralItem(Quantity.OTHER, "%d apples"), + ), + ), + PluralMetadata.LocaleItem( + locale = "ru", + values = listOf( + PluralItem(Quantity.ONE, "яблоко"), + PluralItem(Quantity.FEW, "яблока"), + PluralItem(Quantity.MANY, "яблок"), + ), + ), + ), + ) + + private val string = StringMetadata( + key = "hello", + values = listOf( + StringMetadata.LocaleItem(locale = "base", value = "Hello, world"), + StringMetadata.LocaleItem(locale = "ru", value = "Привет, мир"), + ), + ) + + @Test + fun pluralContentHashIsStable() { + assertEquals(expected = "-9167ca0", actual = plural.contentHash()) + } + + @Test + fun stringContentHashIsStable() { + assertEquals(expected = "367b4e74", actual = string.contentHash()) + } + + @Test + fun equivalentMetadataProduceEqualContentHash() { + assertEquals(expected = plural.contentHash(), actual = plural.copy().contentHash()) + } + + @Test + fun directoryHashIgnoresFilesystemOrder() { + val forward = createTree(listOf("a.txt" to "A", "b.txt" to "B", "c.txt" to "C")) + val reverse = createTree(listOf("c.txt" to "C", "b.txt" to "B", "a.txt" to "A")) + try { + assertEquals(expected = forward.calculateResourcesHash(), actual = reverse.calculateResourcesHash()) + } finally { + forward.deleteRecursively() + reverse.deleteRecursively() + } + } + + private fun createTree(entries: List>): File { + val dir = Files.createTempDirectory("moko-hash-test").toFile() + entries.forEach { (name, content) -> File(dir, name).writeText(content) } + return dir + } +}