diff --git a/build.gradle.kts b/build.gradle.kts index 678d1cd82..d501eaf19 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-test")) @OptIn(ExperimentalBCVApi::class) klib { enabled = true @@ -155,6 +155,7 @@ val unpublishedProjects "guide", "kotlinx-serialization-json-tests", "proto-test-model", + "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 cc64d4572..a9c6c21e2 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-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/jvmMain/src/kotlinx/serialization/internal/Platform.kt b/core/jvmMain/src/kotlinx/serialization/internal/Platform.kt index 65d38fba7..27502b88a 100644 --- a/core/jvmMain/src/kotlinx/serialization/internal/Platform.kt +++ b/core/jvmMain/src/kotlinx/serialization/internal/Platform.kt @@ -77,12 +77,20 @@ 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 + // 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 - 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/common.pro b/rules/common.pro index 7f830691d..f8b3cdaa4 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 879917c11..b74fe6973 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/rules-integration-tests/build.gradle.kts b/rules/rules-integration-tests/build.gradle.kts new file mode 100644 index 000000000..44edcf4d3 --- /dev/null +++ b/rules/rules-integration-tests/build.gradle.kts @@ -0,0 +1,271 @@ +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) +} + +val sharedSourceSet = sourceSets.create("shared") { + kotlin.srcDirs("src/shared") +} + +val r8FullModeSourceSet = sourceSets.create("testR8FullMode") { + kotlin.srcDirs(sharedSourceSet.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 sharedImplementation by all test compilation tasks +val testR8FullModeImplementation by configurations.getting { + extendsFrom(sharedImplementation) +} +val testProguardCompatibleImplementation by configurations.getting { + extendsFrom(sharedImplementation) +} + +tasks.withType().named("compileTestR8FullModeKotlin") { + configureCompilation(r8FullMode = true) +} + +tasks.withType().named("compileTestProguardCompatibleKotlin") { + configureCompilation(r8FullMode = false) +} + +val testR8FullMode = tasks.register("testR8FullMode", Test::class) { + group = "verification" + testClassesDirs = r8FullModeSourceSet.output.classesDirs + classpath = r8FullModeSourceSet.runtimeClasspath + configureTest(r8FullMode = true) +} + +val testProguardCompatible = tasks.register("testProguardCompatible", Test::class) { + group = "verification" + testClassesDirs = proguardCompatibleSourceSet.output.classesDirs + classpath = proguardCompatibleSourceSet.runtimeClasspath + configureTest(r8FullMode = false) +} + +tasks.check { + dependsOn(testR8FullMode) + dependsOn(testProguardCompatible) +} + +// +// 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)) + }.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") + + 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(jmodFile.absolutePath, "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(baseJar) + 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.getByName("sharedRuntimeClasspath").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/rules-integration-tests/src/shared/kotlinx/serialization/r8/DeletionChecks.kt b/rules/rules-integration-tests/src/shared/kotlinx/serialization/r8/DeletionChecks.kt new file mode 100644 index 000000000..9ec302413 --- /dev/null +++ b/rules/rules-integration-tests/src/shared/kotlinx/serialization/r8/DeletionChecks.kt @@ -0,0 +1,214 @@ +/* + * 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 R8Checker(parseR8Output(mapFile, usageFile)) +} + +class R8Checker(private val classes: Map) { + /** + * Find R8 information about class by its binary name. + */ + 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 { + 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) + } +} + +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 { + 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 { + 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 { R8Result(it.name != it.obfuscatedName, it.removed) } + } + } +} + + +data class ClassEntry( + val originalName: String, + val obfuscatedName: String, + var removed: Boolean = false, + val fields: MutableMap = mutableMapOf(), + val methods: MutableMap = mutableMapOf() +) + +data class FieldEntry( + val name: String, + val type: String, + val obfuscatedName: String, + var removed: Boolean = false +) + +data class MethodEntry( + val name: String, + val returnType: String, + val descriptor: String, + val obfuscatedName: String, + var removed: Boolean = false +) + + +open class R8Result( + val isObfuscated: Boolean, + val isShrunk: Boolean +) + +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 signature = name + desc + val existed = current.methods[signature] + if (existed == null) { + current.methods[signature] = MethodEntry(name, returnType, desc, obfuscated) + } else { + if (existed.obfuscatedName != obfuscated) { + // If the method saved its original name in one of the obfuscations, we use the original, otherwise we use either of obfuscated + if (obfuscated == name) { + current.methods.remove(signature) + current.methods[signature] = existed.copy(obfuscatedName = 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() + val signature = methodName + desc + + current.methods[signature] = 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/rules-integration-tests/src/shared/kotlinx/serialization/r8/Model.kt b/rules/rules-integration-tests/src/shared/kotlinx/serialization/r8/Model.kt new file mode 100644 index 000000000..41c8a235f --- /dev/null +++ b/rules/rules-integration-tests/src/shared/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/rules-integration-tests/src/shared/kotlinx/serialization/r8/R8Tests.kt b/rules/rules-integration-tests/src/shared/kotlinx/serialization/r8/R8Tests.kt new file mode 100644 index 000000000..83e17b18c --- /dev/null +++ b/rules/rules-integration-tests/src/shared/kotlinx/serialization/r8/R8Tests.kt @@ -0,0 +1,121 @@ +/* + * 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() + + private fun prepareTests() { + // save method via calling it in kept class + ObfuscatedClass("World").used() + } + + /** + * Test classes renames and deleted as long as methods. + */ + @Test + fun testOptimisation() { + val unusedClass = checker.findClass("kotlinx.serialization.r8.UnusedClass") + assertTrue(unusedClass.isShrunk) + + 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 6b2166dad..64d86bb48 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-test") +project(":proguard-rules-test").projectDir = file("./rules/rules-integration-tests") + dependencyResolutionManagement { versionCatalogs {