Skip to content

Commit d8a4a74

Browse files
committed
build: add R8 shrink-survival module and ship consumer keep-rules
A library has to survive its consumers shrinking their own apps: any class or member the SDK reaches reflectively or through an SPI can be tree-shaken or renamed by R8/ProGuard unless the SDK tells the shrinker to keep it. Nothing in the build verified that, and the SDK shipped no consumer rules at all. Ship consumer keep-rules from each module that has a reflective or runtime-wired surface, packaged under META-INF/proguard so a downstream R8/AGP build applies them automatically: - sdk-core: the Io seam and IoProvider, the HttpClient/AsyncHttpClient and Serde SPIs, the immutable HTTP models and their builders, and the Tristate hierarchy plus the kotlin.Metadata needed for reflective binding. - sdk-io-okio3: the OkioIoProvider entry point. - sdk-transport-okhttp: the OkHttpTransport entry point and Builder, with dontwarn for OkHttp's optional TLS providers. - sdk-serde-jackson: the JacksonSerde entry point and the custom Tristate module, plus the wholesale Jackson databind/core/annotation keeps that reflection-heavy library needs (a stripped annotation enum otherwise fails Jackson's config initialiser). Add a test-only, unpublished sdk-shrink-test module that turns those rules into a regression guard. It bundles a small consumer program with the SDK and its real runtime dependencies (Okio, OkHttp, Jackson, Kotlin stdlib, an SLF4J binding) into one jar, runs R8 in full mode over it using the SHIPPED keep-rules extracted straight from the SDK jars, then runs the shrunk program. The program performs a real in-process HTTP round-trip through OkHttpTransport and a full Tristate JSON round-trip through JacksonSerde, so the check proves the kept members still function after shrinking rather than merely that the classes remain. The R8 run is wired into check, so a plain build enforces it. The module is excluded from the binary-compatibility snapshot (apiValidation.ignoredProjects) and from the Kover coverage aggregate, since it publishes nothing and contributes no coverage; ktlint, detekt, and explicit-API remain on. R8 is pinned in the version catalog and fetched from a group-restricted Google Maven repo, never entering a published artifact. CI wiring is deferred until the CI workflow itself lands.
1 parent ea0cc81 commit d8a4a74

10 files changed

Lines changed: 574 additions & 0 deletions

File tree

build.gradle.kts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,28 @@ tasks.named("check") {
8282
dependsOn(tasks.named("koverVerify"))
8383
}
8484

85+
// Keep the test-only shrink-survival module out of the binary-compatibility snapshot. It ships no
86+
// public artifact, so it needs no committed `.api` file; without this exclusion apiCheck would
87+
// demand one (and apiDump would generate a spurious snapshot for an unpublished module). Mirrors
88+
// how the module is also left out of the kover aggregate below.
89+
apiValidation {
90+
ignoredProjects += "sdk-shrink-test"
91+
}
92+
8593
allprojects {
8694
repositories {
8795
mavenCentral()
8896
maven {
8997
// For maven snapshots
9098
url = URI.create("https://oss.sonatype.org/content/repositories/snapshots/")
9199
}
100+
// Google's Maven repo hosts R8 (com.android.tools:r8), used only by the test-only
101+
// sdk-shrink-test module. Restricted to that group so it is not consulted for anything else.
102+
google {
103+
content {
104+
includeModule("com.android.tools", "r8")
105+
}
106+
}
92107
}
93108

94109
// Plugin application lives in each subproject's own `plugins {}` block — the old

gradle/libs.versions.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ reactor = "3.8.5"
99
netty = "4.2.13.Final"
1010
jackson = "2.18.2"
1111
junit-jupiter = "5.10.2"
12+
# R8 is used only by the test-only sdk-shrink-test module to verify the SDK survives consumer-side
13+
# shrinking. It is fetched from Google's Maven repo and never enters a published artifact.
14+
r8 = "8.9.35"
1215
kover = "0.9.8"
1316
binary-compatibility-validator = "0.16.3"
1417
ktlint-plugin = "12.1.1"
@@ -32,6 +35,7 @@ jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-
3235
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" }
3336
jackson-datatype-jdk8 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jdk8", version.ref = "jackson" }
3437
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" }
38+
r8 = { module = "com.android.tools:r8", version.ref = "r8" }
3539

3640
[plugins]
3741
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Copyright (c) 2026 dexpace and Omar Aljarrah
2+
#
3+
# Licensed under the MIT License. See LICENSE in the project root.
4+
# SPDX-License-Identifier: MIT
5+
6+
# Consumer ProGuard/R8 keep rules for sdk-core.
7+
#
8+
# R8 and the Android Gradle Plugin automatically apply any rules packaged under
9+
# META-INF/proguard/ in a dependency jar, so a downstream application that shrinks its
10+
# build inherits these without extra configuration. They protect the parts of the toolkit
11+
# that a shrinker cannot prove are reachable on its own:
12+
#
13+
# * the SPI seams that callers wire at runtime (the I/O provider, the transport clients,
14+
# the serde), whose implementations live in separate modules and are referenced only
15+
# through interfaces; and
16+
# * the immutable HTTP models and the Tristate type, which Jackson and other reflective
17+
# serializers bind by walking constructors, accessors, and Kotlin metadata rather than
18+
# through direct call sites the shrinker can see.
19+
20+
# --- SPI contracts wired at runtime --------------------------------------------------
21+
22+
# The single I/O seam. Io.installProvider(...) is the documented entry point and IoProvider
23+
# is implemented in an adapter module, so keep both surfaces intact.
24+
-keep class org.dexpace.sdk.core.io.Io { *; }
25+
-keep class org.dexpace.sdk.core.io.IoProvider { *; }
26+
27+
# Transport SPIs. Concrete transports (e.g. OkHttpTransport) are reached only through these
28+
# interfaces, so the methods a caller invokes must survive.
29+
-keep class org.dexpace.sdk.core.client.HttpClient { *; }
30+
-keep class org.dexpace.sdk.core.client.AsyncHttpClient { *; }
31+
32+
# Serde SPI. JacksonSerde and any other implementation are reached through these interfaces.
33+
-keep class org.dexpace.sdk.core.serde.Serde { *; }
34+
-keep class org.dexpace.sdk.core.serde.Serializer { *; }
35+
-keep class org.dexpace.sdk.core.serde.Deserializer { *; }
36+
37+
# --- Immutable HTTP models and their builders ----------------------------------------
38+
39+
# Request / Response and their nested builders are constructed and read reflectively by
40+
# serializers and assertion frameworks; preserving every member keeps the public surface
41+
# (factories, builder fluents, component accessors) callable after shrinking.
42+
-keep class org.dexpace.sdk.core.http.request.Request { *; }
43+
-keep class org.dexpace.sdk.core.http.request.Request$RequestBuilder { *; }
44+
-keep class org.dexpace.sdk.core.http.request.RequestBody { *; }
45+
-keep class org.dexpace.sdk.core.http.request.Method { *; }
46+
-keep class org.dexpace.sdk.core.http.response.Response { *; }
47+
-keep class org.dexpace.sdk.core.http.response.Response$ResponseBuilder { *; }
48+
-keep class org.dexpace.sdk.core.http.response.ResponseBody { *; }
49+
-keep class org.dexpace.sdk.core.http.response.Status { *; }
50+
-keep class org.dexpace.sdk.core.http.common.Headers { *; }
51+
-keep class org.dexpace.sdk.core.http.common.Headers$Builder { *; }
52+
-keep class org.dexpace.sdk.core.http.common.MediaType { *; }
53+
-keep class org.dexpace.sdk.core.http.common.CommonMediaTypes { *; }
54+
-keep class org.dexpace.sdk.core.http.common.Protocol { *; }
55+
56+
# --- Tristate ------------------------------------------------------------------------
57+
58+
# Tristate models the absent / null / present distinction a serializer must reconstruct from
59+
# the wire. The custom Jackson binding (shipped by sdk-serde-jackson) checks the runtime type
60+
# of each variant, so the sealed hierarchy and the Present payload accessor must remain.
61+
-keep class org.dexpace.sdk.core.serde.Tristate { *; }
62+
-keep class org.dexpace.sdk.core.serde.Tristate$Absent { *; }
63+
-keep class org.dexpace.sdk.core.serde.Tristate$Null { *; }
64+
-keep class org.dexpace.sdk.core.serde.Tristate$Present { *; }
65+
66+
# Kotlin emits @Metadata on every class; reflective Kotlin tooling (including Jackson's Kotlin
67+
# module) reads it to recover constructor parameter names and nullability. Strip it and
68+
# data-class binding silently degrades, so keep the annotation across the toolkit.
69+
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
70+
-keep class kotlin.Metadata { *; }
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Copyright (c) 2026 dexpace and Omar Aljarrah
2+
#
3+
# Licensed under the MIT License. See LICENSE in the project root.
4+
# SPDX-License-Identifier: MIT
5+
6+
# Consumer ProGuard/R8 keep rules for sdk-io-okio3.
7+
#
8+
# This module's only public surface is the OkioIoProvider singleton, installed at startup
9+
# via Io.installProvider(OkioIoProvider). A shrinker following the application from its own
10+
# entry points cannot always see that wiring, so keep the provider and its INSTANCE field.
11+
-keep class org.dexpace.sdk.io.OkioIoProvider { *; }
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Copyright (c) 2026 dexpace and Omar Aljarrah
2+
#
3+
# Licensed under the MIT License. See LICENSE in the project root.
4+
# SPDX-License-Identifier: MIT
5+
6+
# Consumer ProGuard/R8 keep rules for sdk-serde-jackson.
7+
#
8+
# JacksonSerde is the public entry point (withDefaults() / from(ObjectMapper)). The module also
9+
# registers a custom module that teaches Jackson how to (de)serialize Tristate; both the entry
10+
# point and that module are reached reflectively through Jackson's module-registration and
11+
# bean-introspection machinery, so they must survive shrinking.
12+
-keep class org.dexpace.sdk.serde.jackson.JacksonSerde { *; }
13+
-keep class org.dexpace.sdk.serde.jackson.JacksonObjectMappers { *; }
14+
-keep class org.dexpace.sdk.serde.jackson.TristateModule { *; }
15+
16+
# Jackson databind is reflection-heavy: it reads annotations, walks bean members, and resolves
17+
# parametric types at runtime, and its own config classes initialise from annotation enum
18+
# singletons (a stripped or renamed enum value surfaces as an NPE in SerializationConfig's static
19+
# initialiser). It is not meaningfully shrinkable without a hand-curated configuration, so the
20+
# conventional — and the only safe — consumer recommendation is to keep the databind, core, and
21+
# annotation packages wholesale, retain the attributes Jackson reflects over, and keep every
22+
# annotation enum intact.
23+
-keepattributes Signature,*Annotation*,EnclosingMethod,InnerClasses
24+
-keep class com.fasterxml.jackson.databind.** { *; }
25+
-keep class com.fasterxml.jackson.core.** { *; }
26+
-keep class com.fasterxml.jackson.annotation.** { *; }
27+
-keep enum com.fasterxml.jackson.** { *; }
28+
-dontwarn com.fasterxml.jackson.databind.ext.**

sdk-shrink-test/build.gradle.kts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/*
2+
* Copyright (c) 2026 dexpace and Omar Aljarrah
3+
*
4+
* Licensed under the MIT License. See LICENSE in the project root.
5+
* SPDX-License-Identifier: MIT
6+
*/
7+
8+
import java.io.ByteArrayOutputStream
9+
import java.io.File
10+
import java.util.zip.ZipFile
11+
12+
plugins {
13+
// Kotlin only — no kover, no maven-publish, no signing. This module produces no published
14+
// artifact and contributes no coverage; it is a build-time regression guard. The root build
15+
// therefore does NOT add it to the kover aggregate, and `apiValidation.ignoredProjects`
16+
// (root build.gradle.kts) keeps it out of the binary-compatibility snapshot. Java-8 bytecode,
17+
// explicit-API strict mode, ktlint, and detekt are all inherited from the root build and left
18+
// ON — the shrink harness honours the same conventions as the published modules.
19+
kotlin("jvm")
20+
}
21+
22+
group = "org.dexpace"
23+
version = "0.0.1-alpha.1"
24+
25+
// The shrink harness exercises the SDK exactly as a downstream consumer would: it depends on the
26+
// published modules (core, the Okio I/O adapter, the OkHttp transport, the Jackson serde) and
27+
// their real transitive runtime dependencies, then bundles the lot into a single program for R8
28+
// to shrink. MockWebServer drives a genuine in-process HTTP round-trip so the shrunk program
29+
// proves the transport still works end-to-end, not merely that its classes survived.
30+
dependencies {
31+
implementation(project(":sdk-core"))
32+
implementation(project(":sdk-io-okio3"))
33+
implementation(project(":sdk-transport-okhttp"))
34+
implementation(project(":sdk-serde-jackson"))
35+
implementation(libs.okhttp.mockwebserver.junit5)
36+
37+
// SLF4J is compileOnly in the SDK, so a real consumer supplies the API plus a binding at
38+
// runtime. The no-op binding (which transitively brings slf4j-api) is the lightest choice and
39+
// matches what every transport test runtime already uses; it must be bundled into the shrink
40+
// input jar or the shrunk program fails with NoClassDefFoundError: org/slf4j/LoggerFactory.
41+
runtimeOnly(libs.slf4j.nop)
42+
43+
// R8 itself runs as an external tool (see the r8Shrink task), so it lives in its own resolvable
44+
// configuration rather than on the program classpath.
45+
}
46+
47+
// ---------------------------------------------------------------------------------------------
48+
// R8 shrink-survival pipeline
49+
//
50+
// buildShrinkInputJar -> fat jar: consumer program + SDK + okio + okhttp + jackson + stdlib
51+
// r8Shrink -> runs R8 in full mode over that jar with the SHIPPED consumer rules
52+
// r8Run -> runs the shrunk program and asserts it prints the success sentinel
53+
//
54+
// r8Run is wired into `check`, so a plain `./gradlew build` proves shrink-survival.
55+
// ---------------------------------------------------------------------------------------------
56+
57+
// Isolated configuration holding only the R8 tool jar, fetched from Google's Maven repo (added
58+
// to the root allprojects { repositories } block). Keeping it separate means R8's own (large)
59+
// dependency closure never leaks onto the program classpath that gets shrunk.
60+
val r8Tool: Configuration by configurations.creating {
61+
isCanBeConsumed = false
62+
isCanBeResolved = true
63+
}
64+
65+
dependencies {
66+
r8Tool(libs.r8)
67+
}
68+
69+
val shrinkBuildDir: Provider<Directory> = layout.buildDirectory.dir("r8")
70+
val shrinkInputJar: Provider<RegularFile> = shrinkBuildDir.map { it.file("consumer-all.jar") }
71+
val shrunkJar: Provider<RegularFile> = shrinkBuildDir.map { it.file("consumer-shrunk.jar") }
72+
73+
// Kept in sync with ShrinkSurvivalApp.SUCCESS_SENTINEL. The build script cannot reference the
74+
// project's own compiled classes, so the literal is duplicated here; the app prints it and the
75+
// harness greps for it.
76+
val successSentinel = "SHRINK-SURVIVAL-OK"
77+
78+
// The consumer rules each SDK module ships under META-INF/proguard. The harness feeds these to R8
79+
// explicitly (rather than relying on R8 to auto-discover them) so the run fails loudly if a module
80+
// ever stops shipping its rules — that is the regression this module guards.
81+
val shippedConsumerRulePaths: List<String> =
82+
listOf(
83+
"META-INF/proguard/sdk-core.pro",
84+
"META-INF/proguard/sdk-io-okio3.pro",
85+
"META-INF/proguard/sdk-transport-okhttp.pro",
86+
"META-INF/proguard/sdk-serde-jackson.pro",
87+
)
88+
89+
// Bundle the consumer program and its entire runtime classpath into one jar — the program R8 will
90+
// shrink. Service-loader manifests are concatenated; other duplicates (e.g. repeated module
91+
// metadata, signature files) are dropped so the merged jar stays valid.
92+
val buildShrinkInputJar by tasks.registering(Jar::class) {
93+
group = "shrink"
94+
description = "Bundles the consumer program and its runtime classpath into the R8 input jar."
95+
96+
dependsOn(tasks.named("classes"))
97+
destinationDirectory.set(shrinkBuildDir)
98+
archiveFileName.set("consumer-all.jar")
99+
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
100+
101+
val runtimeClasspath = configurations.named("runtimeClasspath")
102+
from(sourceSets.main.get().output)
103+
from(runtimeClasspath.map { cfg -> cfg.map { if (it.isDirectory) it else zipTree(it) } })
104+
105+
// Drop signed-jar metadata that would otherwise make the merged jar fail verification, and
106+
// module descriptors that are meaningless once everything is flattened onto the classpath.
107+
exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
108+
exclude("module-info.class", "META-INF/versions/**/module-info.class")
109+
110+
manifest {
111+
attributes("Main-Class" to "org.dexpace.sdk.shrinktest.ShrinkSurvivalApp")
112+
}
113+
}
114+
115+
// Java toolchain used to RUN R8 (the tool is Java-11 bytecode) and as R8's `--lib` runtime image.
116+
// Java 8 program bytecode runs fine against an 11 boot image, so this does not change the program's
117+
// target. Resolved lazily so configuration does not force toolchain provisioning.
118+
val r8Launcher =
119+
javaToolchains.launcherFor {
120+
languageVersion.set(JavaLanguageVersion.of(11))
121+
}
122+
123+
val r8Shrink by tasks.registering(JavaExec::class) {
124+
group = "shrink"
125+
description = "Runs R8 in full mode over the consumer jar using the SDK's shipped keep-rules."
126+
127+
dependsOn(buildShrinkInputJar)
128+
inputs.files(r8Tool)
129+
inputs.file(shrinkInputJar)
130+
inputs.file(layout.projectDirectory.file("src/r8/app-rules.pro"))
131+
outputs.file(shrunkJar)
132+
133+
classpath = r8Tool
134+
mainClass.set("com.android.tools.r8.R8")
135+
javaLauncher.set(r8Launcher)
136+
137+
doFirst {
138+
val inputJar = shrinkInputJar.get().asFile
139+
val outputJar = shrunkJar.get().asFile
140+
outputJar.parentFile.mkdirs()
141+
outputJar.delete()
142+
143+
// Extract each shipped consumer-rules file out of the input jar and assert it is present.
144+
// A missing file here is exactly the shrink-survival regression we want to catch.
145+
val extractedRulesDir = shrinkBuildDir.get().dir("shipped-rules").asFile
146+
extractedRulesDir.mkdirs()
147+
val extractedRuleFiles = mutableListOf<File>()
148+
ZipFile(inputJar).use { zip ->
149+
shippedConsumerRulePaths.forEach { entryPath ->
150+
val entry =
151+
zip.getEntry(entryPath)
152+
?: error(
153+
"Shipped consumer keep-rules missing from the SDK on the classpath: " +
154+
"$entryPath. Every SDK module that has reflectively/SPI-reached " +
155+
"surface must ship its rules under META-INF/proguard.",
156+
)
157+
val dest = File(extractedRulesDir, entryPath.substringAfterLast('/'))
158+
zip.getInputStream(entry).use { input -> dest.outputStream().use { input.copyTo(it) } }
159+
extractedRuleFiles += dest
160+
}
161+
}
162+
163+
// R8 needs a boot image to resolve java.* references; the launcher's JDK home serves as the
164+
// `--lib` runtime image.
165+
val jdkHome = r8Launcher.get().metadata.installationPath.asFile.absolutePath
166+
val appRules = layout.projectDirectory.file("src/r8/app-rules.pro").asFile
167+
168+
val r8Args = mutableListOf("--release", "--classfile", "--output", outputJar.absolutePath)
169+
r8Args += listOf("--lib", jdkHome)
170+
extractedRuleFiles.forEach { r8Args += listOf("--pg-conf", it.absolutePath) }
171+
r8Args += listOf("--pg-conf", appRules.absolutePath)
172+
r8Args += inputJar.absolutePath
173+
args = r8Args
174+
175+
logger.lifecycle(
176+
"Running R8 over ${inputJar.name} with ${extractedRuleFiles.size} shipped rule " +
177+
"file(s) + app rules",
178+
)
179+
}
180+
}
181+
182+
val r8Run by tasks.registering(JavaExec::class) {
183+
group = "shrink"
184+
description = "Runs the R8-shrunk consumer program and asserts the SDK survived shrinking."
185+
186+
dependsOn(r8Shrink)
187+
inputs.file(shrunkJar)
188+
// A marker output so the task is up-to-date when nothing changed.
189+
val resultMarker = shrinkBuildDir.map { it.file("r8-run-ok.txt") }
190+
outputs.file(resultMarker)
191+
192+
// Run the shrunk classfiles directly; an 11 launcher executes the Java-8 program fine.
193+
javaLauncher.set(r8Launcher)
194+
mainClass.set("org.dexpace.sdk.shrinktest.ShrinkSurvivalApp")
195+
// `files(...)` is lazy, so referencing the not-yet-produced shrunk jar at configuration time is
196+
// fine — it is resolved when the task runs, after r8Shrink has created it.
197+
classpath = files(shrunkJar)
198+
199+
val captured = ByteArrayOutputStream()
200+
standardOutput = captured
201+
202+
doLast {
203+
val output = captured.toString("UTF-8")
204+
logger.lifecycle(output.trim())
205+
check(output.contains(successSentinel)) {
206+
"The R8-shrunk consumer did not print the success sentinel. The SDK did not survive " +
207+
"shrinking with its shipped keep-rules. Program output was:\n$output"
208+
}
209+
resultMarker.get().asFile.writeText("ok\n")
210+
}
211+
}
212+
213+
// Make the shrink-survival check part of the normal build: `./gradlew build` (which runs `check`)
214+
// now bundles, shrinks, and runs the consumer, failing if the SDK does not survive R8.
215+
tasks.named("check") {
216+
dependsOn(r8Run)
217+
}

0 commit comments

Comments
 (0)