Skip to content

Revised the ProGuard rules and added tests on R8 #3041

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Aug 6, 2025
Merged
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
3 changes: 2 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -155,6 +155,7 @@ val unpublishedProjects
"guide",
"kotlinx-serialization-json-tests",
"proto-test-model",
"proguard-rules-test",
)
val excludedFromBomProjects get() = unpublishedProjects + "kotlinx-serialization-bom"

Expand Down
2 changes: 1 addition & 1 deletion buildSrc/src/main/kotlin/kover-conventions.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
16 changes: 12 additions & 4 deletions core/jvmMain/src/kotlinx/serialization/internal/Platform.kt
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,20 @@ private fun <T: Any> Class<T>.findInNamedCompanion(vararg args: KSerializer<Any?
}
}

private fun <T: Any> Class<T>.findNamedCompanionByAnnotation(): Any? {
val companionClass = declaredClasses.firstOrNull { clazz ->
clazz.getAnnotation(NamedCompanion::class.java) != null
private fun <T : Any> Class<T>.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 <T: Any> Class<T>.isNotAnnotated(): Boolean {
Expand Down
13 changes: 5 additions & 8 deletions rules/common.pro
Original file line number Diff line number Diff line change
@@ -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> *;
}

Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions rules/r8.pro
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down
271 changes: 271 additions & 0 deletions rules/rules-integration-tests/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<KotlinCompile>().named("compileTestR8FullModeKotlin") {
configureCompilation(r8FullMode = true)
}

tasks.withType<KotlinCompile>().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<Task>("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<File>,
libraries: Set<File>,
ruleFiles: Set<File>,
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}"
}
}
Loading