From b7fc640a0db444a6a2fdf791c1038a9a82f9023a Mon Sep 17 00:00:00 2001 From: "Sergey.Shanshin" Date: Fri, 11 Jul 2025 11:31:28 +0200 Subject: [PATCH 1/5] Revised the ProGuard rules and added tests on R8 - The warning message about unusual symbols in field type is eliminated. - Added correct rules for named companions - Changed code for the locating name companion by annotation. Since the list of nested classes is not saved by R8, static fields are analyzed - added tests on the rules using R8 in full mode and ProGuard compatibility mode Fixes #3033 --- build.gradle.kts | 3 +- .../main/kotlin/kover-conventions.gradle.kts | 2 +- .../SerializersLookupNamedCompanionTest.kt | 41 ++- .../serialization/internal/Platform.kt | 15 +- rules/build.gradle.kts | 253 ++++++++++++++++++ rules/common.pro | 13 +- rules/r8.pro | 4 + .../serialization/r8/DeletionChecks.kt | 249 +++++++++++++++++ .../kotlin/kotlinx/serialization/r8/Model.kt | 81 ++++++ .../kotlinx/serialization/r8/R8Tests.kt | 119 ++++++++ settings.gradle.kts | 3 + 11 files changed, 744 insertions(+), 39 deletions(-) create mode 100644 rules/build.gradle.kts create mode 100644 rules/src/common/kotlin/kotlinx/serialization/r8/DeletionChecks.kt create mode 100644 rules/src/common/kotlin/kotlinx/serialization/r8/Model.kt create mode 100644 rules/src/common/kotlin/kotlinx/serialization/r8/R8Tests.kt diff --git a/build.gradle.kts b/build.gradle.kts index 678d1cd829..ccec74c3fa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -59,7 +59,7 @@ allprojects { // == BCV setup == apiValidation { - ignoredProjects.addAll(listOf("benchmark", "guide", "kotlinx-serialization", "kotlinx-serialization-json-tests")) + ignoredProjects.addAll(listOf("benchmark", "guide", "kotlinx-serialization", "kotlinx-serialization-json-tests", "proguard-rules")) @OptIn(ExperimentalBCVApi::class) klib { enabled = true @@ -155,6 +155,7 @@ val unpublishedProjects "guide", "kotlinx-serialization-json-tests", "proto-test-model", + "proguard-rules", ) val excludedFromBomProjects get() = unpublishedProjects + "kotlinx-serialization-bom" diff --git a/buildSrc/src/main/kotlin/kover-conventions.gradle.kts b/buildSrc/src/main/kotlin/kover-conventions.gradle.kts index cc64d45724..0892166208 100644 --- a/buildSrc/src/main/kotlin/kover-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/kover-conventions.gradle.kts @@ -47,6 +47,6 @@ kover { } -val uncoveredProjects get() = setOf(":kotlinx-serialization-bom", ":benchmark", ":guide") +val uncoveredProjects get() = setOf(":kotlinx-serialization-bom", ":benchmark", ":guide", ":proguard-rules") // map: variant name -> project path val projectsForCoverageVerification get() = mapOf("core" to ":kotlinx-serialization-core", "json" to ":kotlinx-serialization-json", "jsonOkio" to ":kotlinx-serialization-json-okio", "cbor" to ":kotlinx-serialization-cbor", "hocon" to ":kotlinx-serialization-hocon", "properties" to ":kotlinx-serialization-properties", "protobuf" to ":kotlinx-serialization-protobuf", "io" to ":kotlinx-serialization-json-io") diff --git a/core/commonTest/src/kotlinx/serialization/SerializersLookupNamedCompanionTest.kt b/core/commonTest/src/kotlinx/serialization/SerializersLookupNamedCompanionTest.kt index 8611d34d2e..2b6382a347 100644 --- a/core/commonTest/src/kotlinx/serialization/SerializersLookupNamedCompanionTest.kt +++ b/core/commonTest/src/kotlinx/serialization/SerializersLookupNamedCompanionTest.kt @@ -63,31 +63,22 @@ class SerializersLookupNamedCompanionTest { @Test fun test() { assertSame>(Plain.serializer(), serializer(typeOf())) - - shouldFail(beforeKotlin = "1.9.20", onJs = false, onNative = false, onWasm = false) { - assertSame>(PlainSerializer, serializer(typeOf())) - } - - shouldFail(beforeKotlin = "1.9.20", onJs = false, onNative = false, onWasm = false) { - assertEquals( - Parametrized.serializer(Int.serializer()).descriptor.toString(), - serializer(typeOf>()).descriptor.toString() - ) - } - - shouldFail(beforeKotlin = "1.9.20", onJs = false, onNative = false, onWasm = false) { - assertEquals( - ParametrizedWithCustom.serializer(Int.serializer()).descriptor.toString(), - serializer(typeOf>()).descriptor.toString() - ) - } - - shouldFail(beforeKotlin = "1.9.20", onJs = false, onNative = false, onWasm = false) { - assertEquals( - SealedInterface.serializer().descriptor.toString(), - serializer(typeOf()).descriptor.toString() - ) - } + assertSame>(PlainSerializer, serializer(typeOf())) + + assertEquals( + Parametrized.serializer(Int.serializer()).descriptor.toString(), + serializer(typeOf>()).descriptor.toString() + ) + + assertEquals( + ParametrizedWithCustom.serializer(Int.serializer()).descriptor.toString(), + serializer(typeOf>()).descriptor.toString() + ) + + assertEquals( + SealedInterface.serializer().descriptor.toString(), + serializer(typeOf()).descriptor.toString() + ) } diff --git a/core/jvmMain/src/kotlinx/serialization/internal/Platform.kt b/core/jvmMain/src/kotlinx/serialization/internal/Platform.kt index 65d38fba70..c47a309b2f 100644 --- a/core/jvmMain/src/kotlinx/serialization/internal/Platform.kt +++ b/core/jvmMain/src/kotlinx/serialization/internal/Platform.kt @@ -77,12 +77,19 @@ private fun Class.findInNamedCompanion(vararg args: KSerializer Class.findNamedCompanionByAnnotation(): Any? { - val companionClass = declaredClasses.firstOrNull { clazz -> - clazz.getAnnotation(NamedCompanion::class.java) != null +private fun Class.findNamedCompanionByAnnotation(): Any? { + // search static field with type marked by kotlinx.serialization.internal.NamedCompanion - it's the companion + val field = declaredFields.firstOrNull { field -> + Modifier.isStatic(field.modifiers) && field.type.getAnnotation(NamedCompanion::class.java) != null } ?: return null - return companionOrNull(companionClass.simpleName) + // short version of companionOrNull() + return try { + field.isAccessible = true + field.get(null) + } catch (e: Throwable) { + null + } } private fun Class.isNotAnnotated(): Boolean { diff --git a/rules/build.gradle.kts b/rules/build.gradle.kts new file mode 100644 index 0000000000..77ee479b26 --- /dev/null +++ b/rules/build.gradle.kts @@ -0,0 +1,253 @@ +import com.android.tools.r8.* +import com.android.tools.r8.origin.* +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +/* + * Copyright 2017-2025 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ +buildscript { + repositories { + mavenCentral() + // Using Google Cloud Storage, see: https://r8.googlesource.com/r8#obtaining-prebuilts + maven("https://storage.googleapis.com/r8-releases/raw") + } + + dependencies { + // `8.10` corresponds to Kotlin `2.2`, see: https://developer.android.com/build/kotlin-support + classpath("com.android.tools:r8:8.10.21") + } +} + +plugins { + kotlin("jvm") + alias(libs.plugins.serialization) +} + +kotlin { + // use toolchain from settings + jvmToolchain(jdkToolchainVersion) +} + +sourceSets { + // create the source set for storing sources and using dependency configuration + val common by creating + + val test by getting { + kotlin.srcDirs(common.kotlin.srcDirs) + } + + // extra source set is created for shrinking and obfuscating in compatibility mode + val testCompatible by creating { + kotlin.srcDirs(common.kotlin.srcDirs) + } +} + +// extend commonImplementation by all test compilation tasks +configurations.testImplementation { + extendsFrom(configurations.getByName("commonImplementation")) +} +val testCompatibleImplementation by configurations.getting { + extendsFrom(configurations.getByName("commonImplementation")) +} + +dependencies { + "commonImplementation"(project(":kotlinx-serialization-core")) + "commonImplementation"("org.jetbrains.kotlin:kotlin-test") + "commonImplementation"("org.jetbrains.kotlin:kotlin-test-junit") + "commonImplementation"(libs.junit.junit4) + "commonImplementation"(kotlin("test-junit")) +} + +tasks.compileTestKotlin { + configureCompilation(true) +} + +tasks.withType().named("compileTestCompatibleKotlin") { + configureCompilation(false) +} + +tasks.test { + configureTest(true) +} +val testCompatibleTask = tasks.register("testCompatible", Test::class) { + dependsOn("compileTestCompatibleKotlin") + configureTest(false) +} + +tasks.check { + dependsOn(testCompatibleTask) +} + +// +// R8 actions +// + +val baseJar = layout.buildDirectory.file("jdk/java.base.jar") + + +/** + * Get jar with standard Java classes. + * For JDK > 9 these classes are located in the `base` module. + * The module has the special format `jmod` and it isn't supported in R8, so we should convert content of jmod to jar. + */ +val extractBaseJarTask = tasks.register("extractBaseJar") { + inputs.property("jdkVersion", jdkToolchainVersion) + outputs.file(baseJar) + + doLast { + val javaLauncher = javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(jdkToolchainVersion)) + } + val javaHomeDir = javaLauncher.get().metadata.installationPath.asFile + val baseJmod = javaHomeDir.resolve("jmods").resolve("java.base.jmod") + + val extractDir = temporaryDir.resolve("java-base") + + extractDir.deleteRecursively() + extractDir.mkdirs() + // unpack jmod file + exec { + commandLine("jmod", "extract", baseJmod.absolutePath, "--dir", extractDir.absolutePath) + } + // pack class-files into jar + exec { + commandLine( + "jar", + "--create", + "--file", + baseJar.get().asFile.absolutePath, + "-C", + File(extractDir, "classes").absolutePath, + "." + ) + } + } +} + +// Serialization ProGuard/R8 rules +val ruleFiles = setOf(projectDir.resolve("common.pro"), projectDir.resolve("r8.pro")) + +/** + * Configure replacing original class-files with classes processed by R8 + */ +fun KotlinCompile.configureCompilation(r8FullMode: Boolean) { + // R8 output files + val mode = if (r8FullMode) "full" else "compatible" + val mapFile = layout.buildDirectory.file("r8/$mode/mapping.txt") + val usageFile = layout.buildDirectory.file("r8/$mode/usage.txt") + + dependsOn(extractBaseJarTask) + + inputs.files(ruleFiles) + + outputs.file(mapFile) + outputs.file(usageFile) + + // disable incremental compilation because previously compiled classes may be deleted or renamed by R8 + incremental = false + + doLast { + val intermediateDir = temporaryDir.resolve("original") + + val dependencies = configurations.runtimeClasspath.get().files + dependencies += configurations.testRuntimeClasspath.get().files + + val kotlinOutput = this@configureCompilation.destinationDirectory.get().asFile + + intermediateDir.deleteRecursively() + // copy original class-files to temp dir + kotlinOutput.walk() + .filter { file -> file.isFile && file.extension == "class" } + .forEach { file -> + val relative = file.toRelativeString(kotlinOutput) + val targetFile = intermediateDir.resolve(relative) + + targetFile.parentFile.mkdirs() + file.copyTo(targetFile) + file.delete() + } + + val classFiles = intermediateDir.walk().filter { it.isFile }.toList() + + runR8( + kotlinOutput, + classFiles, + (dependencies + baseJar.get().asFile), + ruleFiles, + mapFile.get().asFile, + usageFile.get().asFile, + r8FullMode + ) + } +} + +fun Test.configureTest(r8FullMode: Boolean) { + doFirst { + // R8 output files + val mode = if (r8FullMode) "full" else "compatible" + val mapFile = layout.buildDirectory.file("r8/$mode/mapping.txt") + val usageFile = layout.buildDirectory.file("r8/$mode/usage.txt") + + systemProperty("r8.output.map", mapFile.get().asFile.absolutePath) + systemProperty("r8.output.usage", usageFile.get().asFile.absolutePath) + } +} + +fun runR8( + outputDir: File, + originalClasses: List, + libraries: Set, + ruleFiles: Set, + mapFile: File, + usageFile: File, + fullMode: Boolean = true +) { + val r8Command = R8Command.builder(DiagnosticLogger()) + .addProgramFiles(originalClasses.map { it.toPath() }) + .addLibraryFiles(libraries.map { it.toPath() }) + .addProguardConfigurationFiles(ruleFiles.map { file -> file.toPath() }) + .addProguardConfiguration( + listOf( + "-keep class **.*Tests { *; }", + // widespread rule in AGP + "-allowaccessmodification", + // on some OS mixed classnames may lead to problems due classes like a/a and a/A cannot be stored simultaneously in their file system + "-dontusemixedcaseclassnames", + // uncomment to show reason of keeping specified class + //"-whyareyoukeeping class YourClassName", + ), + object : Origin(root()) { + override fun part() = "EntryPoint" + }) + + .setDisableTreeShaking(false) + .setDisableMinification(false) + .setProguardCompatibility(!fullMode) + + .setProgramConsumer(ClassFileConsumer.DirectoryConsumer(outputDir.toPath())) + + .setProguardMapConsumer(StringConsumer.FileConsumer(mapFile.toPath())) + .setProguardUsageConsumer(StringConsumer.FileConsumer(usageFile.toPath())) + .build() + + R8.run(r8Command) +} + +class DiagnosticLogger : DiagnosticsHandler { + override fun warning(diagnostic: Diagnostic) { + // we shouldn't ignore any warning in R8 + throw GradleException("Warning in R8: ${diagnostic.format()}") + } + + override fun error(diagnostic: Diagnostic) { + throw GradleException("Error in R8: ${diagnostic.format()}") + } + + override fun info(diagnostic: Diagnostic) { + logger.info("Info in R8: ${diagnostic.format()}") + } + + fun Diagnostic.format(): String { + return "$diagnosticMessage\nIn: $position\nFrom: ${this.origin}" + } +} diff --git a/rules/common.pro b/rules/common.pro index 7f830691df..f8b3cdaa42 100644 --- a/rules/common.pro +++ b/rules/common.pro @@ -1,15 +1,13 @@ -# Keep `Companion` object fields of serializable classes. +# Keep `Companion` object field of serializable classes. # This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. --if @kotlinx.serialization.Serializable class ** --keepclassmembers class <1> { - static <1>$* Companion; -} + -keepclassmembers @kotlinx.serialization.Serializable class ** { + static ** Companion; + } # Keep names for named companion object from obfuscation # Names of a class and of a field are important in lookup of named companion in runtime --keepnames @kotlinx.serialization.internal.NamedCompanion class * -if @kotlinx.serialization.internal.NamedCompanion class * --keepclassmembernames class * { +-keepclassmembers class * { static <1> *; } @@ -36,7 +34,6 @@ # Don't print notes about potential mistakes or omissions in the configuration for kotlinx-serialization classes # See also https://github.com/Kotlin/kotlinx.serialization/issues/1900 -dontnote kotlinx.serialization.** - # Serialization core uses `java.lang.ClassValue` for caching inside these specified classes. # If there is no `java.lang.ClassValue` (for example, in Android), then R8/ProGuard will print a warning. # However, since in this case they will not be used, we can disable these warnings diff --git a/rules/r8.pro b/rules/r8.pro index 879917c118..b74fe6973f 100644 --- a/rules/r8.pro +++ b/rules/r8.pro @@ -11,6 +11,10 @@ -if @kotlinx.serialization.Serializable class ** -keep, allowshrinking, allowoptimization, allowobfuscation, allowaccessmodification class <1> +# Rule to save runtime annotations on named companion class. +# If the R8 full mode is used, annotations are removed from classes-files. +-if @kotlinx.serialization.internal.NamedCompanion class * +-keep, allowshrinking, allowoptimization, allowobfuscation, allowaccessmodification class <1> # Rule to save INSTANCE field and serializer function for Kotlin serializable objects. # diff --git a/rules/src/common/kotlin/kotlinx/serialization/r8/DeletionChecks.kt b/rules/src/common/kotlin/kotlinx/serialization/r8/DeletionChecks.kt new file mode 100644 index 0000000000..04661ecf9b --- /dev/null +++ b/rules/src/common/kotlin/kotlinx/serialization/r8/DeletionChecks.kt @@ -0,0 +1,249 @@ +/* + * Copyright 2017-2025 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.serialization.r8 + +import java.io.File +import kotlin.reflect.KClass + +fun getR8Checker(): R8Checker { + val usageFile = File(System.getProperty("r8.output.usage")) + val mapFile = File(System.getProperty("r8.output.map")) + + return R8CheckerImpl(parseR8Output(mapFile, usageFile)) +} + +interface R8Checker { + /** + * Find R8 information about class by its binary name. + */ + fun findClass(binaryName: String): ClassChecker + + /** + * Find R8 information about class by given [clazz]. + */ + fun findClass(clazz: KClass<*>): ClassChecker +} + +interface ClassChecker : R8Result { + val originalName: String + + /** + * Find R8 information about a field by its name. + * + * If there is no field with name [name], [IllegalArgumentException] will be thrown. + */ + fun findField(name: String): R8Result + + /** + * Find R8 information about a method by its name. + * If there are several methods with name [name], [IllegalArgumentException] will be thrown. + * If there is no method with name [name], [IllegalArgumentException] will be thrown. + */ + fun findMethod(name: String): R8Result + + /** + * Find R8 information about a method by its name and descriptor. + * If there is no method with given [name] and [descriptor], [IllegalArgumentException] will be thrown. + */ + fun findMethod(name: String, descriptor: String): R8Result +} + +interface R8Result { + val isObfuscated: Boolean + val isShrunk: Boolean +} + + + +private class R8CheckerImpl(private val classes: Map) : R8Checker { + override fun findClass(binaryName: String): ClassChecker { + return classes[binaryName]?.let { ClassCheckerImpl(it) } + ?: throw IllegalArgumentException("Class with binary name '$binaryName' not found") + } + + override fun findClass(clazz: KClass<*>): ClassChecker { + val obfuscatedName = clazz.java.name + val found = classes.values.singleOrNull { it.obfuscatedName == obfuscatedName } ?: throw IllegalArgumentException("Class with obfuscated name '$obfuscatedName' not found") + return ClassCheckerImpl(found) + } +} + +private class ClassCheckerImpl(private val clazz: ClassEntry) : ClassChecker { + override val isObfuscated: Boolean = clazz.originalName != clazz.obfuscatedName + + override val isShrunk: Boolean = clazz.removed + + override val originalName: String = clazz.originalName + + override fun findField(name: String): R8Result { + return clazz.fields[name] + ?.let { R8ResultImpl(it.name != it.obfuscatedName, it.removed) } + ?: throw IllegalArgumentException("Field with name '$name' not found in class '${clazz.originalName}'") + } + + override fun findMethod(name: String): R8Result { + val methods = clazz.methods.filter { it.name == name } + if (methods.isEmpty()) { + throw IllegalArgumentException("Method with name '$name' not found in class '${clazz.originalName}'") + } else if (methods.size > 1) { + throw IllegalArgumentException("Several methods with name '$name' found in class '${clazz.originalName}'") + } else { + return methods.single().let { R8ResultImpl(it.name != it.obfuscatedName, it.removed) } + } + } + + override fun findMethod(name: String, descriptor: String): R8Result { + return clazz.methods.singleOrNull { it.name == name && it.descriptor == descriptor } + ?.let { R8ResultImpl(it.name != it.obfuscatedName, it.removed) } + ?: throw IllegalArgumentException("Method '$name$descriptor' not found") + } +} + + +private data class ClassEntry( + val originalName: String, + val obfuscatedName: String, + var removed: Boolean = false, + val fields: MutableMap = mutableMapOf(), + val methods: MutableList = mutableListOf() +) + +private data class FieldEntry( + val name: String, + val type: String, + val obfuscatedName: String, + var removed: Boolean = false +) + +private data class MethodEntry( + val name: String, + val returnType: String, + val descriptor: String, + val obfuscatedName: String, + var removed: Boolean = false +) + + +private class R8ResultImpl( + override val isObfuscated: Boolean, + override val isShrunk: Boolean +) : R8Result + +private fun parseR8Output(mappingFile: File, usageFile: File): Map { + val classMap = mutableMapOf() + var currentClass: ClassEntry? = null + + // process mapping.txt + val classRegex = Regex("""^(\S+) -> (\S+):$""") + val methodRegex = Regex("""^(?:(\d+):\d+:)?(\S+)\s+([^\s\\(]+)(\(.*\))?:\d+(:\d+)? -> (\S+)$""") + val fieldRegex = Regex("""^(\S+)\s+(\S+)\s+->\s+(\S+)$""") + + mappingFile.forEachLine { raw -> + val line = raw.trim() + if (line.startsWith("#") || line.isEmpty()) return@forEachLine + + classRegex.matchEntire(line)?.let { + val (original, obfuscated) = it.destructured + currentClass = ClassEntry(originalName = original, obfuscatedName = obfuscated) + classMap[original] = currentClass + return@forEachLine + } + + methodRegex.matchEntire(line)?.let { + val current = currentClass ?: throw IllegalStateException("No current class") + + val (num, returnType, name, desc, _, obfuscated) = it.destructured + if (num.isNotEmpty()) { + val existed = current.methods.singleOrNull { clazz -> clazz.name == name && clazz.descriptor == desc } + if (existed == null) { + current.methods += MethodEntry(name, returnType, desc, obfuscated) + } else { + if (existed.obfuscatedName != obfuscated) { + if (obfuscated == name) { + current.methods.remove(existed) + current.methods += existed.copy(obfuscatedName = obfuscated) + } + } + } + } else { + current.fields.put(name, FieldEntry(name, returnType, obfuscated)) + } + return@forEachLine + } + + fieldRegex.matchEntire(line)?.let { + val current = currentClass ?: throw IllegalStateException("No current class") + + val (type, name, obfuscated) = it.destructured + current.fields.put(name, FieldEntry(name, type, obfuscated)) + return@forEachLine + } + + // Special handling for Companion fields + if (line.contains(" Companion -> ")) { + val current = currentClass ?: throw IllegalStateException("No current class") + val parts = line.split(" -> ") + if (parts.size == 2) { + val typeName = parts[0].trim() + val obfuscatedName = parts[1].trim() + val name = "Companion" + current.fields.put(name, FieldEntry(name, typeName, obfuscatedName)) + } + } + } + + // process usage.txt + var currentUsageClass: ClassEntry? = null + + usageFile.forEachLine { raw -> + val line = raw.trimEnd() + if (line.isBlank()) return@forEachLine + + if (line.startsWith(" ")) { + // member + val memberLine = line.trim() + val current = currentUsageClass ?: throw IllegalStateException("No current class") + if (memberLine.endsWith(")")) { + val methodName = memberLine.substringBefore("(").substringAfterLast(" ") + val desc = "(" + memberLine.substringAfter("(").removeSuffix(")") + ")" + // Skip modifiers like static, public, final + val parts = memberLine.substringBefore("(").split(" ") + val returnType = parts.dropWhile { it in listOf("static", "public", "private", "protected", "final", "abstract", "synthetic") }.first() + current.methods += MethodEntry( + name = methodName, + returnType = returnType, + descriptor = desc, + obfuscatedName = methodName, + removed = true + ) + } else { + val parts = memberLine.split(" ") + val fieldType = parts.dropLast(1).joinToString(" ") + val fieldName = parts.last() + current.fields.put( + fieldName, + FieldEntry( + name = fieldName, + type = fieldType, + obfuscatedName = fieldName, + removed = true + ) + ) + } + } else { + // class + if (line.endsWith(":")) { + // remove class members + val className = line.removeSuffix(":").trim() + currentUsageClass = classMap[className] + } else { + // remove whole class + val className = line.trim() + classMap[className] = ClassEntry(className, className, removed = true) + } + } + } + return classMap +} diff --git a/rules/src/common/kotlin/kotlinx/serialization/r8/Model.kt b/rules/src/common/kotlin/kotlinx/serialization/r8/Model.kt new file mode 100644 index 0000000000..41c8a235fe --- /dev/null +++ b/rules/src/common/kotlin/kotlinx/serialization/r8/Model.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2017-2025 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.serialization.r8 + +import kotlinx.serialization.* +import kotlin.reflect.* + +@Serializable +class ObfuscatedClass(val name: String) { + fun used() { + println("Hello $name!") + } + + fun unused() { + println("Hello $name!") + } +} + +@Serializable +class UnusedClass(val name: String) + +@Serializable +class AccessSerializer(val name: String) + +@Serializable +class SerializableSimple(val name: String) + +class Container { + @Serializable + class SerializableNested(val name: String) +} + + + +@Serializable +object SerializableObject + +@Serializable +enum class SerializableEnum { + A, B +} + +@Serializable +class SerializableWithNamedCompanion(val i: Int) { + companion object CustomName { + fun method() { + println("Hello") + } + } +} + +@Serializable +sealed interface SealedInterface { + val body: String +} + +// and this class too has implicit @Polymorphic +@Serializable +abstract class AbstractClass : SealedInterface { + abstract override val body: String +} + +@Polymorphic +@Serializable +open class OpenPolymorphicClass : AbstractClass() { + override var body: String = "Simple" +} + +@Serializable +open class OpenClass : AbstractClass() { + override var body: String = "Simple" +} + +annotation class MyAnnotation + +@MyAnnotation +object ExampleObject + +val type = typeOf() \ No newline at end of file diff --git a/rules/src/common/kotlin/kotlinx/serialization/r8/R8Tests.kt b/rules/src/common/kotlin/kotlinx/serialization/r8/R8Tests.kt new file mode 100644 index 0000000000..007ec71401 --- /dev/null +++ b/rules/src/common/kotlin/kotlinx/serialization/r8/R8Tests.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2017-2025 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.serialization.r8 + +import kotlinx.serialization.* +import java.lang.annotation.Annotation +import kotlin.reflect.* +import kotlin.test.* + +class R8Tests { + val checker: R8Checker = getR8Checker() + + /** + * Test classes renames and deleted as long as methods. + */ + @Test + fun testOptimisation() { + val unusedClass = checker.findClass("kotlinx.serialization.r8.UnusedClass") + assertTrue(unusedClass.isShrunk) + + ObfuscatedClass("World").used() + val obfuscated = checker.findClass(ObfuscatedClass::class) + assertTrue(obfuscated.isObfuscated) + + val used = obfuscated.findMethod("used") + assertTrue(used.isObfuscated) + assertFalse(used.isShrunk) + + val unused = obfuscated.findMethod("unused") + assertTrue(unused.isShrunk) + } + + @Test + fun testCompanions() { + assertSerializerWithCompanion() + assertSerializerWithCompanion() + assertSerializerWithCompanion() + + assertSerializerWithCompanion() + assertSerializerWithCompanion() + assertSerializerWithCompanion() + assertSerializerWithCompanion() + } + + @Test + fun testNamedCompanions() { + assertSerializerWithNamedCompanion("CustomName") + } + + @Test + fun testSerializableObject() { + assertSerializerForObject() + } + + /** + * Test serialization annotations are saved and aren't renamed. + */ + @Test + fun testAnnotations() { + assertTrue(SerializableSimple::class.java.hasAnnotation("kotlinx.serialization.Serializable")) + assertTrue(OpenPolymorphicClass::class.java.hasAnnotation("kotlinx.serialization.Serializable")) + assertTrue(OpenPolymorphicClass::class.java.hasAnnotation("kotlinx.serialization.Polymorphic")) + } + + /** + * Descriptor field should present. + * Using reflection here because the private field `descriptor` isn't present in the mapping file. + */ + @Test + fun testDescriptorField() { + assertTrue(AccessSerializer.serializer()::class.java.declaredFields.any { it.name == "descriptor" }) + } + + + + private inline fun assertSerializerWithCompanion() { + val companionName = "Companion" + + val serializable = checker.findClass(T::class) + val field = serializable.findField(companionName) + + assertFalse(field.isObfuscated, "Companion field '${serializable.originalName}#$companionName' should not be obfuscated") + assertFalse(field.isShrunk, "Companion field '${serializable.originalName}#$companionName' should not be shrunk") + + val companion = checker.findClass(serializable.originalName + "$" + companionName) + val serializer = companion.findMethod("serializer") + assertFalse(serializer.isShrunk) + assertFalse(serializer.isObfuscated) + + serializer(typeOf()) + } + + private inline fun assertSerializerWithNamedCompanion(companionName: String) { + // somewhy R8 doesn't print field for named companion in mapping.txt, so we check it by reflection + T::class.java.getDeclaredField(companionName) + serializer(typeOf()) + } + + private inline fun assertSerializerForObject() { + val serializable = checker.findClass(T::class) + val field = serializable.findField("INSTANCE") + + assertFalse(field.isObfuscated, "Field 'INSTANCE' should not be obfuscated") + assertFalse(field.isShrunk, "Field 'INSTANCE' should not be shrunk") + + val serializer = serializable.findMethod("serializer") + assertFalse(serializer.isObfuscated, "Method 'serializer()' should not be obfuscated") + assertFalse(serializer.isShrunk, "Method 'serializer()' should not be shrunk") + + serializer(typeOf()) + } + + fun Class<*>.hasAnnotation(annotationName: String): Boolean { + return annotations.any { (it as Annotation).annotationType().name == annotationName } + } + +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 6b2166dadf..5bba58dccb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -77,6 +77,9 @@ project(":benchmark").projectDir = file("./benchmark") include(":guide") project(":guide").projectDir = file("./guide") +include(":proguard-rules") +project(":proguard-rules").projectDir = file("./rules") + dependencyResolutionManagement { versionCatalogs { From fefc94bd4d99915f110f945f5eb718ebe7a8cbeb Mon Sep 17 00:00:00 2001 From: "Sergey.Shanshin" Date: Fri, 11 Jul 2025 13:18:46 +0200 Subject: [PATCH 2/5] ~fix: use jmod from toolchain --- rules/build.gradle.kts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/rules/build.gradle.kts b/rules/build.gradle.kts index 77ee479b26..01997b4e25 100644 --- a/rules/build.gradle.kts +++ b/rules/build.gradle.kts @@ -97,8 +97,8 @@ val extractBaseJarTask = tasks.register("extractBaseJar") { doLast { val javaLauncher = javaToolchains.launcherFor { languageVersion.set(JavaLanguageVersion.of(jdkToolchainVersion)) - } - val javaHomeDir = javaLauncher.get().metadata.installationPath.asFile + }.get() + val javaHomeDir = javaLauncher.metadata.installationPath.asFile val baseJmod = javaHomeDir.resolve("jmods").resolve("java.base.jmod") val extractDir = temporaryDir.resolve("java-base") @@ -106,8 +106,16 @@ val extractBaseJarTask = tasks.register("extractBaseJar") { extractDir.deleteRecursively() extractDir.mkdirs() // unpack jmod file + val jdkBinDir = javaHomeDir.resolve("bin") + + val jmodFile = if (System.getProperty("os.name").startsWith("Windows")) { + jdkBinDir.resolve("jmod.exe") + } else { + jdkBinDir.resolve("jmod") + } + exec { - commandLine("jmod", "extract", baseJmod.absolutePath, "--dir", extractDir.absolutePath) + commandLine(jmodFile.absolutePath, "extract", baseJmod.absolutePath, "--dir", extractDir.absolutePath) } // pack class-files into jar exec { From 8334576c4c71c2efd24e0a14334b48743e0667d2 Mon Sep 17 00:00:00 2001 From: "Sergey.Shanshin" Date: Tue, 29 Jul 2025 20:56:38 +0200 Subject: [PATCH 3/5] ~fixup --- build.gradle.kts | 4 +- .../main/kotlin/kover-conventions.gradle.kts | 2 +- .../SerializersLookupNamedCompanionTest.kt | 41 ++++--- .../serialization/internal/Platform.kt | 1 + .../build.gradle.kts | 78 +++++++------ .../serialization/r8/DeletionChecks.kt | 110 ++++++------------ .../shared}/kotlinx/serialization/r8/Model.kt | 0 .../kotlinx/serialization/r8/R8Tests.kt | 8 +- settings.gradle.kts | 4 +- 9 files changed, 117 insertions(+), 131 deletions(-) rename rules/{ => rules-integration-tests}/build.gradle.kts (76%) rename rules/{src/common/kotlin => rules-integration-tests/src/shared}/kotlinx/serialization/r8/DeletionChecks.kt (70%) rename rules/{src/common/kotlin => rules-integration-tests/src/shared}/kotlinx/serialization/r8/Model.kt (100%) rename rules/{src/common/kotlin => rules-integration-tests/src/shared}/kotlinx/serialization/r8/R8Tests.kt (97%) diff --git a/build.gradle.kts b/build.gradle.kts index ccec74c3fa..d501eaf198 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -59,7 +59,7 @@ allprojects { // == BCV setup == apiValidation { - ignoredProjects.addAll(listOf("benchmark", "guide", "kotlinx-serialization", "kotlinx-serialization-json-tests", "proguard-rules")) + ignoredProjects.addAll(listOf("benchmark", "guide", "kotlinx-serialization", "kotlinx-serialization-json-tests", "proguard-rules-test")) @OptIn(ExperimentalBCVApi::class) klib { enabled = true @@ -155,7 +155,7 @@ val unpublishedProjects "guide", "kotlinx-serialization-json-tests", "proto-test-model", - "proguard-rules", + "proguard-rules-test", ) val excludedFromBomProjects get() = unpublishedProjects + "kotlinx-serialization-bom" diff --git a/buildSrc/src/main/kotlin/kover-conventions.gradle.kts b/buildSrc/src/main/kotlin/kover-conventions.gradle.kts index 0892166208..a9c6c21e2b 100644 --- a/buildSrc/src/main/kotlin/kover-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/kover-conventions.gradle.kts @@ -47,6 +47,6 @@ kover { } -val uncoveredProjects get() = setOf(":kotlinx-serialization-bom", ":benchmark", ":guide", ":proguard-rules") +val uncoveredProjects get() = setOf(":kotlinx-serialization-bom", ":benchmark", ":guide", ":proguard-rules-test") // map: variant name -> project path val projectsForCoverageVerification get() = mapOf("core" to ":kotlinx-serialization-core", "json" to ":kotlinx-serialization-json", "jsonOkio" to ":kotlinx-serialization-json-okio", "cbor" to ":kotlinx-serialization-cbor", "hocon" to ":kotlinx-serialization-hocon", "properties" to ":kotlinx-serialization-properties", "protobuf" to ":kotlinx-serialization-protobuf", "io" to ":kotlinx-serialization-json-io") diff --git a/core/commonTest/src/kotlinx/serialization/SerializersLookupNamedCompanionTest.kt b/core/commonTest/src/kotlinx/serialization/SerializersLookupNamedCompanionTest.kt index 2b6382a347..8611d34d2e 100644 --- a/core/commonTest/src/kotlinx/serialization/SerializersLookupNamedCompanionTest.kt +++ b/core/commonTest/src/kotlinx/serialization/SerializersLookupNamedCompanionTest.kt @@ -63,22 +63,31 @@ class SerializersLookupNamedCompanionTest { @Test fun test() { assertSame>(Plain.serializer(), serializer(typeOf())) - assertSame>(PlainSerializer, serializer(typeOf())) - - assertEquals( - Parametrized.serializer(Int.serializer()).descriptor.toString(), - serializer(typeOf>()).descriptor.toString() - ) - - assertEquals( - ParametrizedWithCustom.serializer(Int.serializer()).descriptor.toString(), - serializer(typeOf>()).descriptor.toString() - ) - - assertEquals( - SealedInterface.serializer().descriptor.toString(), - serializer(typeOf()).descriptor.toString() - ) + + shouldFail(beforeKotlin = "1.9.20", onJs = false, onNative = false, onWasm = false) { + assertSame>(PlainSerializer, serializer(typeOf())) + } + + shouldFail(beforeKotlin = "1.9.20", onJs = false, onNative = false, onWasm = false) { + assertEquals( + Parametrized.serializer(Int.serializer()).descriptor.toString(), + serializer(typeOf>()).descriptor.toString() + ) + } + + shouldFail(beforeKotlin = "1.9.20", onJs = false, onNative = false, onWasm = false) { + assertEquals( + ParametrizedWithCustom.serializer(Int.serializer()).descriptor.toString(), + serializer(typeOf>()).descriptor.toString() + ) + } + + shouldFail(beforeKotlin = "1.9.20", onJs = false, onNative = false, onWasm = false) { + assertEquals( + SealedInterface.serializer().descriptor.toString(), + serializer(typeOf()).descriptor.toString() + ) + } } diff --git a/core/jvmMain/src/kotlinx/serialization/internal/Platform.kt b/core/jvmMain/src/kotlinx/serialization/internal/Platform.kt index c47a309b2f..5703c83cdb 100644 --- a/core/jvmMain/src/kotlinx/serialization/internal/Platform.kt +++ b/core/jvmMain/src/kotlinx/serialization/internal/Platform.kt @@ -79,6 +79,7 @@ private fun Class.findInNamedCompanion(vararg args: KSerializer Class.findNamedCompanionByAnnotation(): Any? { // search static field with type marked by kotlinx.serialization.internal.NamedCompanion - it's the companion + // declaredClasses after R8 even if `-keepattributes InnerClasses, EnclosingMethod` is specified, so we use declaredFields val field = declaredFields.firstOrNull { field -> Modifier.isStatic(field.modifiers) && field.type.getAnnotation(NamedCompanion::class.java) != null } ?: return null diff --git a/rules/build.gradle.kts b/rules/rules-integration-tests/build.gradle.kts similarity index 76% rename from rules/build.gradle.kts rename to rules/rules-integration-tests/build.gradle.kts index 01997b4e25..89f87d500f 100644 --- a/rules/build.gradle.kts +++ b/rules/rules-integration-tests/build.gradle.kts @@ -28,54 +28,61 @@ kotlin { jvmToolchain(jdkToolchainVersion) } -sourceSets { - // create the source set for storing sources and using dependency configuration - val common by creating +val sharedSourceSet = sourceSets.create("shared") { + kotlin.srcDirs("src/shared") +} - val test by getting { - kotlin.srcDirs(common.kotlin.srcDirs) - } +val r8FullModeSourceSet = sourceSets.create("testR8FullMode") { + kotlin.srcDirs(sharedSourceSet.kotlin.srcDirs) +} - // extra source set is created for shrinking and obfuscating in compatibility mode - val testCompatible by creating { - kotlin.srcDirs(common.kotlin.srcDirs) - } +val proguardCompatibleSourceSet = sourceSets.create("testProguardCompatible") { + kotlin.srcDirs(sharedSourceSet.kotlin.srcDirs) +} + +val sharedImplementation = configurations.getByName("sharedImplementation") + +dependencies { + sharedImplementation(project(":kotlinx-serialization-core")) + sharedImplementation("org.jetbrains.kotlin:kotlin-test") + sharedImplementation("org.jetbrains.kotlin:kotlin-test-junit") + sharedImplementation(libs.junit.junit4) + sharedImplementation(kotlin("test-junit")) } // extend commonImplementation by all test compilation tasks -configurations.testImplementation { - extendsFrom(configurations.getByName("commonImplementation")) +val testR8FullModeImplementation by configurations.getting { + extendsFrom(sharedImplementation) } -val testCompatibleImplementation by configurations.getting { - extendsFrom(configurations.getByName("commonImplementation")) +val testProguardCompatibleImplementation by configurations.getting { + extendsFrom(sharedImplementation) } -dependencies { - "commonImplementation"(project(":kotlinx-serialization-core")) - "commonImplementation"("org.jetbrains.kotlin:kotlin-test") - "commonImplementation"("org.jetbrains.kotlin:kotlin-test-junit") - "commonImplementation"(libs.junit.junit4) - "commonImplementation"(kotlin("test-junit")) +tasks.withType().named("compileTestR8FullModeKotlin") { + configureCompilation(r8FullMode = true) } -tasks.compileTestKotlin { - configureCompilation(true) +tasks.withType().named("compileTestProguardCompatibleKotlin") { + configureCompilation(r8FullMode = false) } -tasks.withType().named("compileTestCompatibleKotlin") { - configureCompilation(false) +val testR8FullMode = tasks.register("testR8FullMode", Test::class) { + group = "verification" + testClassesDirs = r8FullModeSourceSet.output.classesDirs + classpath = r8FullModeSourceSet.runtimeClasspath + configureTest(r8FullMode = true) } -tasks.test { - configureTest(true) -} -val testCompatibleTask = tasks.register("testCompatible", Test::class) { - dependsOn("compileTestCompatibleKotlin") - configureTest(false) +val testProguardCompatible = tasks.register("testProguardCompatible", Test::class) { + group = "verification" + testClassesDirs = proguardCompatibleSourceSet.output.classesDirs + classpath = proguardCompatibleSourceSet.runtimeClasspath + configureTest(r8FullMode = false) } tasks.check { - dependsOn(testCompatibleTask) + dependsOn(testR8FullMode) + dependsOn(testProguardCompatible) } // @@ -100,6 +107,9 @@ val extractBaseJarTask = tasks.register("extractBaseJar") { }.get() val javaHomeDir = javaLauncher.metadata.installationPath.asFile val baseJmod = javaHomeDir.resolve("jmods").resolve("java.base.jmod") + if (!baseJmod.exists()) { + throw GradleException("Cannot find file with base java module, make sure that specified jdk_toolchain_version is 9 or higher") + } val extractDir = temporaryDir.resolve("java-base") @@ -133,7 +143,7 @@ val extractBaseJarTask = tasks.register("extractBaseJar") { } // Serialization ProGuard/R8 rules -val ruleFiles = setOf(projectDir.resolve("common.pro"), projectDir.resolve("r8.pro")) +val ruleFiles = setOf(projectDir.resolve("../common.pro"), projectDir.resolve("../r8.pro")) /** * Configure replacing original class-files with classes processed by R8 @@ -145,7 +155,7 @@ fun KotlinCompile.configureCompilation(r8FullMode: Boolean) { val usageFile = layout.buildDirectory.file("r8/$mode/usage.txt") dependsOn(extractBaseJarTask) - + inputs.files(baseJar) inputs.files(ruleFiles) outputs.file(mapFile) @@ -158,7 +168,7 @@ fun KotlinCompile.configureCompilation(r8FullMode: Boolean) { val intermediateDir = temporaryDir.resolve("original") val dependencies = configurations.runtimeClasspath.get().files - dependencies += configurations.testRuntimeClasspath.get().files + dependencies += configurations.getByName("sharedRuntimeClasspath").files val kotlinOutput = this@configureCompilation.destinationDirectory.get().asFile diff --git a/rules/src/common/kotlin/kotlinx/serialization/r8/DeletionChecks.kt b/rules/rules-integration-tests/src/shared/kotlinx/serialization/r8/DeletionChecks.kt similarity index 70% rename from rules/src/common/kotlin/kotlinx/serialization/r8/DeletionChecks.kt rename to rules/rules-integration-tests/src/shared/kotlinx/serialization/r8/DeletionChecks.kt index 04661ecf9b..960154b505 100644 --- a/rules/src/common/kotlin/kotlinx/serialization/r8/DeletionChecks.kt +++ b/rules/rules-integration-tests/src/shared/kotlinx/serialization/r8/DeletionChecks.kt @@ -11,113 +11,76 @@ fun getR8Checker(): R8Checker { val usageFile = File(System.getProperty("r8.output.usage")) val mapFile = File(System.getProperty("r8.output.map")) - return R8CheckerImpl(parseR8Output(mapFile, usageFile)) + return R8Checker(parseR8Output(mapFile, usageFile)) } -interface R8Checker { +class R8Checker(private val classes: Map) { /** * Find R8 information about class by its binary name. */ - fun findClass(binaryName: String): ClassChecker + fun findClass(binaryName: String): ClassChecker { + return classes[binaryName]?.let { ClassChecker(it) } + ?: throw IllegalArgumentException("Class with binary name '$binaryName' not found") + } /** * Find R8 information about class by given [clazz]. */ - fun findClass(clazz: KClass<*>): ClassChecker + fun findClass(clazz: KClass<*>): ClassChecker { + val obfuscatedName = clazz.java.name + val found = classes.values.singleOrNull { it.obfuscatedName == obfuscatedName } ?: throw IllegalArgumentException("Class with obfuscated name '$obfuscatedName' not found") + return ClassChecker(found) + } } -interface ClassChecker : R8Result { - val originalName: String +class ClassChecker(private val clazz: ClassEntry) : R8Result(clazz.originalName != clazz.obfuscatedName, clazz.removed) { + val originalName: String = clazz.originalName /** * Find R8 information about a field by its name. * * If there is no field with name [name], [IllegalArgumentException] will be thrown. */ - fun findField(name: String): R8Result + fun findField(name: String): R8Result { + return clazz.fields[name] + ?.let { R8Result(it.name != it.obfuscatedName, it.removed) } + ?: throw IllegalArgumentException("Field with name '$name' not found in class '${clazz.originalName}'") + } /** * Find R8 information about a method by its name. * If there are several methods with name [name], [IllegalArgumentException] will be thrown. * If there is no method with name [name], [IllegalArgumentException] will be thrown. */ - fun findMethod(name: String): R8Result - - /** - * Find R8 information about a method by its name and descriptor. - * If there is no method with given [name] and [descriptor], [IllegalArgumentException] will be thrown. - */ - fun findMethod(name: String, descriptor: String): R8Result -} - -interface R8Result { - val isObfuscated: Boolean - val isShrunk: Boolean -} - - - -private class R8CheckerImpl(private val classes: Map) : R8Checker { - override fun findClass(binaryName: String): ClassChecker { - return classes[binaryName]?.let { ClassCheckerImpl(it) } - ?: throw IllegalArgumentException("Class with binary name '$binaryName' not found") - } - - override fun findClass(clazz: KClass<*>): ClassChecker { - val obfuscatedName = clazz.java.name - val found = classes.values.singleOrNull { it.obfuscatedName == obfuscatedName } ?: throw IllegalArgumentException("Class with obfuscated name '$obfuscatedName' not found") - return ClassCheckerImpl(found) - } -} - -private class ClassCheckerImpl(private val clazz: ClassEntry) : ClassChecker { - override val isObfuscated: Boolean = clazz.originalName != clazz.obfuscatedName - - override val isShrunk: Boolean = clazz.removed - - override val originalName: String = clazz.originalName - - override fun findField(name: String): R8Result { - return clazz.fields[name] - ?.let { R8ResultImpl(it.name != it.obfuscatedName, it.removed) } - ?: throw IllegalArgumentException("Field with name '$name' not found in class '${clazz.originalName}'") - } - - override fun findMethod(name: String): R8Result { - val methods = clazz.methods.filter { it.name == name } + fun findMethod(name: String): R8Result { + val methods = clazz.methods.values.filter { it.name == name } if (methods.isEmpty()) { throw IllegalArgumentException("Method with name '$name' not found in class '${clazz.originalName}'") } else if (methods.size > 1) { throw IllegalArgumentException("Several methods with name '$name' found in class '${clazz.originalName}'") } else { - return methods.single().let { R8ResultImpl(it.name != it.obfuscatedName, it.removed) } + return methods.single().let { R8Result(it.name != it.obfuscatedName, it.removed) } } } - - override fun findMethod(name: String, descriptor: String): R8Result { - return clazz.methods.singleOrNull { it.name == name && it.descriptor == descriptor } - ?.let { R8ResultImpl(it.name != it.obfuscatedName, it.removed) } - ?: throw IllegalArgumentException("Method '$name$descriptor' not found") - } } -private data class ClassEntry( +data class ClassEntry( val originalName: String, val obfuscatedName: String, var removed: Boolean = false, val fields: MutableMap = mutableMapOf(), - val methods: MutableList = mutableListOf() + val methods: MutableMap = mutableMapOf() ) -private data class FieldEntry( +data class FieldEntry( val name: String, val type: String, val obfuscatedName: String, var removed: Boolean = false ) -private data class MethodEntry( +data class MethodEntry( val name: String, val returnType: String, val descriptor: String, @@ -126,10 +89,10 @@ private data class MethodEntry( ) -private class R8ResultImpl( - override val isObfuscated: Boolean, - override val isShrunk: Boolean -) : R8Result +open class R8Result( + val isObfuscated: Boolean, + val isShrunk: Boolean +) private fun parseR8Output(mappingFile: File, usageFile: File): Map { val classMap = mutableMapOf() @@ -156,19 +119,18 @@ private fun parseR8Output(mappingFile: File, usageFile: File): Map clazz.name == name && clazz.descriptor == desc } + val signature = name + desc + val existed = current.methods[signature] if (existed == null) { - current.methods += MethodEntry(name, returnType, desc, obfuscated) + current.methods[signature] = MethodEntry(name, returnType, desc, obfuscated) } else { if (existed.obfuscatedName != obfuscated) { if (obfuscated == name) { - current.methods.remove(existed) - current.methods += existed.copy(obfuscatedName = obfuscated) + current.methods.remove(signature) + current.methods[signature] = existed.copy(obfuscatedName = obfuscated) } } } - } else { - current.fields.put(name, FieldEntry(name, returnType, obfuscated)) } return@forEachLine } @@ -211,7 +173,9 @@ private fun parseR8Output(mappingFile: File, usageFile: File): Map assertSerializerWithCompanion() { val companionName = "Companion" diff --git a/settings.gradle.kts b/settings.gradle.kts index 5bba58dccb..64d86bb48d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -77,8 +77,8 @@ project(":benchmark").projectDir = file("./benchmark") include(":guide") project(":guide").projectDir = file("./guide") -include(":proguard-rules") -project(":proguard-rules").projectDir = file("./rules") +include(":proguard-rules-test") +project(":proguard-rules-test").projectDir = file("./rules/rules-integration-tests") dependencyResolutionManagement { From b22a42021cee328ab917f13b09226e2c3143b249 Mon Sep 17 00:00:00 2001 From: Sergey Shanshin Date: Tue, 5 Aug 2025 21:02:45 +0200 Subject: [PATCH 4/5] Apply suggestions from code review Co-authored-by: Leonid Startsev --- core/jvmMain/src/kotlinx/serialization/internal/Platform.kt | 2 +- rules/rules-integration-tests/build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/jvmMain/src/kotlinx/serialization/internal/Platform.kt b/core/jvmMain/src/kotlinx/serialization/internal/Platform.kt index 5703c83cdb..27502b88af 100644 --- a/core/jvmMain/src/kotlinx/serialization/internal/Platform.kt +++ b/core/jvmMain/src/kotlinx/serialization/internal/Platform.kt @@ -79,7 +79,7 @@ private fun Class.findInNamedCompanion(vararg args: KSerializer Class.findNamedCompanionByAnnotation(): Any? { // search static field with type marked by kotlinx.serialization.internal.NamedCompanion - it's the companion - // declaredClasses after R8 even if `-keepattributes InnerClasses, EnclosingMethod` is specified, so we use declaredFields + // declaredClasses are erased after R8 even if `-keepattributes InnerClasses, EnclosingMethod` is specified, so we use declaredFields val field = declaredFields.firstOrNull { field -> Modifier.isStatic(field.modifiers) && field.type.getAnnotation(NamedCompanion::class.java) != null } ?: return null diff --git a/rules/rules-integration-tests/build.gradle.kts b/rules/rules-integration-tests/build.gradle.kts index 89f87d500f..44edcf4d31 100644 --- a/rules/rules-integration-tests/build.gradle.kts +++ b/rules/rules-integration-tests/build.gradle.kts @@ -50,7 +50,7 @@ dependencies { sharedImplementation(kotlin("test-junit")) } -// extend commonImplementation by all test compilation tasks +// extend sharedImplementation by all test compilation tasks val testR8FullModeImplementation by configurations.getting { extendsFrom(sharedImplementation) } From 8c6786255bc7b24654fc9fc50364fe884709c799 Mon Sep 17 00:00:00 2001 From: "Sergey.Shanshin" Date: Tue, 5 Aug 2025 21:04:35 +0200 Subject: [PATCH 5/5] ~fixup --- .../src/shared/kotlinx/serialization/r8/DeletionChecks.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/rules/rules-integration-tests/src/shared/kotlinx/serialization/r8/DeletionChecks.kt b/rules/rules-integration-tests/src/shared/kotlinx/serialization/r8/DeletionChecks.kt index 960154b505..9ec3024139 100644 --- a/rules/rules-integration-tests/src/shared/kotlinx/serialization/r8/DeletionChecks.kt +++ b/rules/rules-integration-tests/src/shared/kotlinx/serialization/r8/DeletionChecks.kt @@ -125,6 +125,7 @@ private fun parseR8Output(mappingFile: File, usageFile: File): Map