Skip to content

Commit 51fa6ad

Browse files
Revised the ProGuard rules and added tests on R8 (#3041)
- 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 Co-authored-by: Leonid Startsev <[email protected]>
1 parent e7578dc commit 51fa6ad

File tree

10 files changed

+714
-14
lines changed

10 files changed

+714
-14
lines changed

build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ allprojects {
5959

6060
// == BCV setup ==
6161
apiValidation {
62-
ignoredProjects.addAll(listOf("benchmark", "guide", "kotlinx-serialization", "kotlinx-serialization-json-tests"))
62+
ignoredProjects.addAll(listOf("benchmark", "guide", "kotlinx-serialization", "kotlinx-serialization-json-tests", "proguard-rules-test"))
6363
@OptIn(ExperimentalBCVApi::class)
6464
klib {
6565
enabled = true
@@ -155,6 +155,7 @@ val unpublishedProjects
155155
"guide",
156156
"kotlinx-serialization-json-tests",
157157
"proto-test-model",
158+
"proguard-rules-test",
158159
)
159160
val excludedFromBomProjects get() = unpublishedProjects + "kotlinx-serialization-bom"
160161

buildSrc/src/main/kotlin/kover-conventions.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,6 @@ kover {
4747
}
4848

4949

50-
val uncoveredProjects get() = setOf(":kotlinx-serialization-bom", ":benchmark", ":guide")
50+
val uncoveredProjects get() = setOf(":kotlinx-serialization-bom", ":benchmark", ":guide", ":proguard-rules-test")
5151
// map: variant name -> project path
5252
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")

core/jvmMain/src/kotlinx/serialization/internal/Platform.kt

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,20 @@ private fun <T: Any> Class<T>.findInNamedCompanion(vararg args: KSerializer<Any?
7777
}
7878
}
7979

80-
private fun <T: Any> Class<T>.findNamedCompanionByAnnotation(): Any? {
81-
val companionClass = declaredClasses.firstOrNull { clazz ->
82-
clazz.getAnnotation(NamedCompanion::class.java) != null
80+
private fun <T : Any> Class<T>.findNamedCompanionByAnnotation(): Any? {
81+
// search static field with type marked by kotlinx.serialization.internal.NamedCompanion - it's the companion
82+
// declaredClasses are erased after R8 even if `-keepattributes InnerClasses, EnclosingMethod` is specified, so we use declaredFields
83+
val field = declaredFields.firstOrNull { field ->
84+
Modifier.isStatic(field.modifiers) && field.type.getAnnotation(NamedCompanion::class.java) != null
8385
} ?: return null
8486

85-
return companionOrNull(companionClass.simpleName)
87+
// short version of companionOrNull()
88+
return try {
89+
field.isAccessible = true
90+
field.get(null)
91+
} catch (e: Throwable) {
92+
null
93+
}
8694
}
8795

8896
private fun <T: Any> Class<T>.isNotAnnotated(): Boolean {

rules/common.pro

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
# Keep `Companion` object fields of serializable classes.
1+
# Keep `Companion` object field of serializable classes.
22
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
3-
-if @kotlinx.serialization.Serializable class **
4-
-keepclassmembers class <1> {
5-
static <1>$* Companion;
6-
}
3+
-keepclassmembers @kotlinx.serialization.Serializable class ** {
4+
static ** Companion;
5+
}
76

87
# Keep names for named companion object from obfuscation
98
# Names of a class and of a field are important in lookup of named companion in runtime
10-
-keepnames @kotlinx.serialization.internal.NamedCompanion class *
119
-if @kotlinx.serialization.internal.NamedCompanion class *
12-
-keepclassmembernames class * {
10+
-keepclassmembers class * {
1311
static <1> *;
1412
}
1513

@@ -36,7 +34,6 @@
3634
# Don't print notes about potential mistakes or omissions in the configuration for kotlinx-serialization classes
3735
# See also https://github.com/Kotlin/kotlinx.serialization/issues/1900
3836
-dontnote kotlinx.serialization.**
39-
4037
# Serialization core uses `java.lang.ClassValue` for caching inside these specified classes.
4138
# If there is no `java.lang.ClassValue` (for example, in Android), then R8/ProGuard will print a warning.
4239
# However, since in this case they will not be used, we can disable these warnings

rules/r8.pro

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
-if @kotlinx.serialization.Serializable class **
1212
-keep, allowshrinking, allowoptimization, allowobfuscation, allowaccessmodification class <1>
1313

14+
# Rule to save runtime annotations on named companion class.
15+
# If the R8 full mode is used, annotations are removed from classes-files.
16+
-if @kotlinx.serialization.internal.NamedCompanion class *
17+
-keep, allowshrinking, allowoptimization, allowobfuscation, allowaccessmodification class <1>
1418

1519
# Rule to save INSTANCE field and serializer function for Kotlin serializable objects.
1620
#
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import com.android.tools.r8.*
2+
import com.android.tools.r8.origin.*
3+
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
4+
5+
/*
6+
* Copyright 2017-2025 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
7+
*/
8+
buildscript {
9+
repositories {
10+
mavenCentral()
11+
// Using Google Cloud Storage, see: https://r8.googlesource.com/r8#obtaining-prebuilts
12+
maven("https://storage.googleapis.com/r8-releases/raw")
13+
}
14+
15+
dependencies {
16+
// `8.10` corresponds to Kotlin `2.2`, see: https://developer.android.com/build/kotlin-support
17+
classpath("com.android.tools:r8:8.10.21")
18+
}
19+
}
20+
21+
plugins {
22+
kotlin("jvm")
23+
alias(libs.plugins.serialization)
24+
}
25+
26+
kotlin {
27+
// use toolchain from settings
28+
jvmToolchain(jdkToolchainVersion)
29+
}
30+
31+
val sharedSourceSet = sourceSets.create("shared") {
32+
kotlin.srcDirs("src/shared")
33+
}
34+
35+
val r8FullModeSourceSet = sourceSets.create("testR8FullMode") {
36+
kotlin.srcDirs(sharedSourceSet.kotlin.srcDirs)
37+
}
38+
39+
val proguardCompatibleSourceSet = sourceSets.create("testProguardCompatible") {
40+
kotlin.srcDirs(sharedSourceSet.kotlin.srcDirs)
41+
}
42+
43+
val sharedImplementation = configurations.getByName("sharedImplementation")
44+
45+
dependencies {
46+
sharedImplementation(project(":kotlinx-serialization-core"))
47+
sharedImplementation("org.jetbrains.kotlin:kotlin-test")
48+
sharedImplementation("org.jetbrains.kotlin:kotlin-test-junit")
49+
sharedImplementation(libs.junit.junit4)
50+
sharedImplementation(kotlin("test-junit"))
51+
}
52+
53+
// extend sharedImplementation by all test compilation tasks
54+
val testR8FullModeImplementation by configurations.getting {
55+
extendsFrom(sharedImplementation)
56+
}
57+
val testProguardCompatibleImplementation by configurations.getting {
58+
extendsFrom(sharedImplementation)
59+
}
60+
61+
tasks.withType<KotlinCompile>().named("compileTestR8FullModeKotlin") {
62+
configureCompilation(r8FullMode = true)
63+
}
64+
65+
tasks.withType<KotlinCompile>().named("compileTestProguardCompatibleKotlin") {
66+
configureCompilation(r8FullMode = false)
67+
}
68+
69+
val testR8FullMode = tasks.register("testR8FullMode", Test::class) {
70+
group = "verification"
71+
testClassesDirs = r8FullModeSourceSet.output.classesDirs
72+
classpath = r8FullModeSourceSet.runtimeClasspath
73+
configureTest(r8FullMode = true)
74+
}
75+
76+
val testProguardCompatible = tasks.register("testProguardCompatible", Test::class) {
77+
group = "verification"
78+
testClassesDirs = proguardCompatibleSourceSet.output.classesDirs
79+
classpath = proguardCompatibleSourceSet.runtimeClasspath
80+
configureTest(r8FullMode = false)
81+
}
82+
83+
tasks.check {
84+
dependsOn(testR8FullMode)
85+
dependsOn(testProguardCompatible)
86+
}
87+
88+
//
89+
// R8 actions
90+
//
91+
92+
val baseJar = layout.buildDirectory.file("jdk/java.base.jar")
93+
94+
95+
/**
96+
* Get jar with standard Java classes.
97+
* For JDK > 9 these classes are located in the `base` module.
98+
* The module has the special format `jmod` and it isn't supported in R8, so we should convert content of jmod to jar.
99+
*/
100+
val extractBaseJarTask = tasks.register<Task>("extractBaseJar") {
101+
inputs.property("jdkVersion", jdkToolchainVersion)
102+
outputs.file(baseJar)
103+
104+
doLast {
105+
val javaLauncher = javaToolchains.launcherFor {
106+
languageVersion.set(JavaLanguageVersion.of(jdkToolchainVersion))
107+
}.get()
108+
val javaHomeDir = javaLauncher.metadata.installationPath.asFile
109+
val baseJmod = javaHomeDir.resolve("jmods").resolve("java.base.jmod")
110+
if (!baseJmod.exists()) {
111+
throw GradleException("Cannot find file with base java module, make sure that specified jdk_toolchain_version is 9 or higher")
112+
}
113+
114+
val extractDir = temporaryDir.resolve("java-base")
115+
116+
extractDir.deleteRecursively()
117+
extractDir.mkdirs()
118+
// unpack jmod file
119+
val jdkBinDir = javaHomeDir.resolve("bin")
120+
121+
val jmodFile = if (System.getProperty("os.name").startsWith("Windows")) {
122+
jdkBinDir.resolve("jmod.exe")
123+
} else {
124+
jdkBinDir.resolve("jmod")
125+
}
126+
127+
exec {
128+
commandLine(jmodFile.absolutePath, "extract", baseJmod.absolutePath, "--dir", extractDir.absolutePath)
129+
}
130+
// pack class-files into jar
131+
exec {
132+
commandLine(
133+
"jar",
134+
"--create",
135+
"--file",
136+
baseJar.get().asFile.absolutePath,
137+
"-C",
138+
File(extractDir, "classes").absolutePath,
139+
"."
140+
)
141+
}
142+
}
143+
}
144+
145+
// Serialization ProGuard/R8 rules
146+
val ruleFiles = setOf(projectDir.resolve("../common.pro"), projectDir.resolve("../r8.pro"))
147+
148+
/**
149+
* Configure replacing original class-files with classes processed by R8
150+
*/
151+
fun KotlinCompile.configureCompilation(r8FullMode: Boolean) {
152+
// R8 output files
153+
val mode = if (r8FullMode) "full" else "compatible"
154+
val mapFile = layout.buildDirectory.file("r8/$mode/mapping.txt")
155+
val usageFile = layout.buildDirectory.file("r8/$mode/usage.txt")
156+
157+
dependsOn(extractBaseJarTask)
158+
inputs.files(baseJar)
159+
inputs.files(ruleFiles)
160+
161+
outputs.file(mapFile)
162+
outputs.file(usageFile)
163+
164+
// disable incremental compilation because previously compiled classes may be deleted or renamed by R8
165+
incremental = false
166+
167+
doLast {
168+
val intermediateDir = temporaryDir.resolve("original")
169+
170+
val dependencies = configurations.runtimeClasspath.get().files
171+
dependencies += configurations.getByName("sharedRuntimeClasspath").files
172+
173+
val kotlinOutput = this@configureCompilation.destinationDirectory.get().asFile
174+
175+
intermediateDir.deleteRecursively()
176+
// copy original class-files to temp dir
177+
kotlinOutput.walk()
178+
.filter { file -> file.isFile && file.extension == "class" }
179+
.forEach { file ->
180+
val relative = file.toRelativeString(kotlinOutput)
181+
val targetFile = intermediateDir.resolve(relative)
182+
183+
targetFile.parentFile.mkdirs()
184+
file.copyTo(targetFile)
185+
file.delete()
186+
}
187+
188+
val classFiles = intermediateDir.walk().filter { it.isFile }.toList()
189+
190+
runR8(
191+
kotlinOutput,
192+
classFiles,
193+
(dependencies + baseJar.get().asFile),
194+
ruleFiles,
195+
mapFile.get().asFile,
196+
usageFile.get().asFile,
197+
r8FullMode
198+
)
199+
}
200+
}
201+
202+
fun Test.configureTest(r8FullMode: Boolean) {
203+
doFirst {
204+
// R8 output files
205+
val mode = if (r8FullMode) "full" else "compatible"
206+
val mapFile = layout.buildDirectory.file("r8/$mode/mapping.txt")
207+
val usageFile = layout.buildDirectory.file("r8/$mode/usage.txt")
208+
209+
systemProperty("r8.output.map", mapFile.get().asFile.absolutePath)
210+
systemProperty("r8.output.usage", usageFile.get().asFile.absolutePath)
211+
}
212+
}
213+
214+
fun runR8(
215+
outputDir: File,
216+
originalClasses: List<File>,
217+
libraries: Set<File>,
218+
ruleFiles: Set<File>,
219+
mapFile: File,
220+
usageFile: File,
221+
fullMode: Boolean = true
222+
) {
223+
val r8Command = R8Command.builder(DiagnosticLogger())
224+
.addProgramFiles(originalClasses.map { it.toPath() })
225+
.addLibraryFiles(libraries.map { it.toPath() })
226+
.addProguardConfigurationFiles(ruleFiles.map { file -> file.toPath() })
227+
.addProguardConfiguration(
228+
listOf(
229+
"-keep class **.*Tests { *; }",
230+
// widespread rule in AGP
231+
"-allowaccessmodification",
232+
// 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
233+
"-dontusemixedcaseclassnames",
234+
// uncomment to show reason of keeping specified class
235+
//"-whyareyoukeeping class YourClassName",
236+
),
237+
object : Origin(root()) {
238+
override fun part() = "EntryPoint"
239+
})
240+
241+
.setDisableTreeShaking(false)
242+
.setDisableMinification(false)
243+
.setProguardCompatibility(!fullMode)
244+
245+
.setProgramConsumer(ClassFileConsumer.DirectoryConsumer(outputDir.toPath()))
246+
247+
.setProguardMapConsumer(StringConsumer.FileConsumer(mapFile.toPath()))
248+
.setProguardUsageConsumer(StringConsumer.FileConsumer(usageFile.toPath()))
249+
.build()
250+
251+
R8.run(r8Command)
252+
}
253+
254+
class DiagnosticLogger : DiagnosticsHandler {
255+
override fun warning(diagnostic: Diagnostic) {
256+
// we shouldn't ignore any warning in R8
257+
throw GradleException("Warning in R8: ${diagnostic.format()}")
258+
}
259+
260+
override fun error(diagnostic: Diagnostic) {
261+
throw GradleException("Error in R8: ${diagnostic.format()}")
262+
}
263+
264+
override fun info(diagnostic: Diagnostic) {
265+
logger.info("Info in R8: ${diagnostic.format()}")
266+
}
267+
268+
fun Diagnostic.format(): String {
269+
return "$diagnosticMessage\nIn: $position\nFrom: ${this.origin}"
270+
}
271+
}

0 commit comments

Comments
 (0)