From d8a4a74dfcb4de3d722ef97aa8ad30f9fd5c8cd2 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 17:19:24 +0300 Subject: [PATCH 1/3] 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. --- build.gradle.kts | 15 ++ gradle/libs.versions.toml | 4 + .../resources/META-INF/proguard/sdk-core.pro | 70 ++++++ .../META-INF/proguard/sdk-io-okio3.pro | 11 + .../META-INF/proguard/sdk-serde-jackson.pro | 28 +++ sdk-shrink-test/build.gradle.kts | 217 ++++++++++++++++++ .../sdk/shrinktest/ShrinkSurvivalApp.kt | 151 ++++++++++++ sdk-shrink-test/src/r8/app-rules.pro | 53 +++++ .../proguard/sdk-transport-okhttp.pro | 20 ++ settings.gradle.kts | 5 + 10 files changed, 574 insertions(+) create mode 100644 sdk-core/src/main/resources/META-INF/proguard/sdk-core.pro create mode 100644 sdk-io-okio3/src/main/resources/META-INF/proguard/sdk-io-okio3.pro create mode 100644 sdk-serde-jackson/src/main/resources/META-INF/proguard/sdk-serde-jackson.pro create mode 100644 sdk-shrink-test/build.gradle.kts create mode 100644 sdk-shrink-test/src/main/kotlin/org/dexpace/sdk/shrinktest/ShrinkSurvivalApp.kt create mode 100644 sdk-shrink-test/src/r8/app-rules.pro create mode 100644 sdk-transport-okhttp/src/main/resources/META-INF/proguard/sdk-transport-okhttp.pro diff --git a/build.gradle.kts b/build.gradle.kts index 23501757..653fca23 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -82,6 +82,14 @@ tasks.named("check") { dependsOn(tasks.named("koverVerify")) } +// Keep the test-only shrink-survival module out of the binary-compatibility snapshot. It ships no +// public artifact, so it needs no committed `.api` file; without this exclusion apiCheck would +// demand one (and apiDump would generate a spurious snapshot for an unpublished module). Mirrors +// how the module is also left out of the kover aggregate below. +apiValidation { + ignoredProjects += "sdk-shrink-test" +} + allprojects { repositories { mavenCentral() @@ -89,6 +97,13 @@ allprojects { // For maven snapshots url = URI.create("https://oss.sonatype.org/content/repositories/snapshots/") } + // Google's Maven repo hosts R8 (com.android.tools:r8), used only by the test-only + // sdk-shrink-test module. Restricted to that group so it is not consulted for anything else. + google { + content { + includeModule("com.android.tools", "r8") + } + } } // Plugin application lives in each subproject's own `plugins {}` block — the old diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0af912d6..68aaba7b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,9 @@ reactor = "3.8.5" netty = "4.2.13.Final" jackson = "2.18.2" junit-jupiter = "5.10.2" +# R8 is used only by the test-only sdk-shrink-test module to verify the SDK survives consumer-side +# shrinking. It is fetched from Google's Maven repo and never enters a published artifact. +r8 = "8.9.35" kover = "0.9.8" binary-compatibility-validator = "0.16.3" ktlint-plugin = "12.1.1" @@ -32,6 +35,7 @@ jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module- jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } jackson-datatype-jdk8 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jdk8", version.ref = "jackson" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } +r8 = { module = "com.android.tools:r8", version.ref = "r8" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/sdk-core/src/main/resources/META-INF/proguard/sdk-core.pro b/sdk-core/src/main/resources/META-INF/proguard/sdk-core.pro new file mode 100644 index 00000000..514f443d --- /dev/null +++ b/sdk-core/src/main/resources/META-INF/proguard/sdk-core.pro @@ -0,0 +1,70 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah +# +# Licensed under the MIT License. See LICENSE in the project root. +# SPDX-License-Identifier: MIT + +# Consumer ProGuard/R8 keep rules for sdk-core. +# +# R8 and the Android Gradle Plugin automatically apply any rules packaged under +# META-INF/proguard/ in a dependency jar, so a downstream application that shrinks its +# build inherits these without extra configuration. They protect the parts of the toolkit +# that a shrinker cannot prove are reachable on its own: +# +# * the SPI seams that callers wire at runtime (the I/O provider, the transport clients, +# the serde), whose implementations live in separate modules and are referenced only +# through interfaces; and +# * the immutable HTTP models and the Tristate type, which Jackson and other reflective +# serializers bind by walking constructors, accessors, and Kotlin metadata rather than +# through direct call sites the shrinker can see. + +# --- SPI contracts wired at runtime -------------------------------------------------- + +# The single I/O seam. Io.installProvider(...) is the documented entry point and IoProvider +# is implemented in an adapter module, so keep both surfaces intact. +-keep class org.dexpace.sdk.core.io.Io { *; } +-keep class org.dexpace.sdk.core.io.IoProvider { *; } + +# Transport SPIs. Concrete transports (e.g. OkHttpTransport) are reached only through these +# interfaces, so the methods a caller invokes must survive. +-keep class org.dexpace.sdk.core.client.HttpClient { *; } +-keep class org.dexpace.sdk.core.client.AsyncHttpClient { *; } + +# Serde SPI. JacksonSerde and any other implementation are reached through these interfaces. +-keep class org.dexpace.sdk.core.serde.Serde { *; } +-keep class org.dexpace.sdk.core.serde.Serializer { *; } +-keep class org.dexpace.sdk.core.serde.Deserializer { *; } + +# --- Immutable HTTP models and their builders ---------------------------------------- + +# Request / Response and their nested builders are constructed and read reflectively by +# serializers and assertion frameworks; preserving every member keeps the public surface +# (factories, builder fluents, component accessors) callable after shrinking. +-keep class org.dexpace.sdk.core.http.request.Request { *; } +-keep class org.dexpace.sdk.core.http.request.Request$RequestBuilder { *; } +-keep class org.dexpace.sdk.core.http.request.RequestBody { *; } +-keep class org.dexpace.sdk.core.http.request.Method { *; } +-keep class org.dexpace.sdk.core.http.response.Response { *; } +-keep class org.dexpace.sdk.core.http.response.Response$ResponseBuilder { *; } +-keep class org.dexpace.sdk.core.http.response.ResponseBody { *; } +-keep class org.dexpace.sdk.core.http.response.Status { *; } +-keep class org.dexpace.sdk.core.http.common.Headers { *; } +-keep class org.dexpace.sdk.core.http.common.Headers$Builder { *; } +-keep class org.dexpace.sdk.core.http.common.MediaType { *; } +-keep class org.dexpace.sdk.core.http.common.CommonMediaTypes { *; } +-keep class org.dexpace.sdk.core.http.common.Protocol { *; } + +# --- Tristate ------------------------------------------------------------------------ + +# Tristate models the absent / null / present distinction a serializer must reconstruct from +# the wire. The custom Jackson binding (shipped by sdk-serde-jackson) checks the runtime type +# of each variant, so the sealed hierarchy and the Present payload accessor must remain. +-keep class org.dexpace.sdk.core.serde.Tristate { *; } +-keep class org.dexpace.sdk.core.serde.Tristate$Absent { *; } +-keep class org.dexpace.sdk.core.serde.Tristate$Null { *; } +-keep class org.dexpace.sdk.core.serde.Tristate$Present { *; } + +# Kotlin emits @Metadata on every class; reflective Kotlin tooling (including Jackson's Kotlin +# module) reads it to recover constructor parameter names and nullability. Strip it and +# data-class binding silently degrades, so keep the annotation across the toolkit. +-keepattributes RuntimeVisibleAnnotations,AnnotationDefault +-keep class kotlin.Metadata { *; } diff --git a/sdk-io-okio3/src/main/resources/META-INF/proguard/sdk-io-okio3.pro b/sdk-io-okio3/src/main/resources/META-INF/proguard/sdk-io-okio3.pro new file mode 100644 index 00000000..ce06b60a --- /dev/null +++ b/sdk-io-okio3/src/main/resources/META-INF/proguard/sdk-io-okio3.pro @@ -0,0 +1,11 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah +# +# Licensed under the MIT License. See LICENSE in the project root. +# SPDX-License-Identifier: MIT + +# Consumer ProGuard/R8 keep rules for sdk-io-okio3. +# +# This module's only public surface is the OkioIoProvider singleton, installed at startup +# via Io.installProvider(OkioIoProvider). A shrinker following the application from its own +# entry points cannot always see that wiring, so keep the provider and its INSTANCE field. +-keep class org.dexpace.sdk.io.OkioIoProvider { *; } diff --git a/sdk-serde-jackson/src/main/resources/META-INF/proguard/sdk-serde-jackson.pro b/sdk-serde-jackson/src/main/resources/META-INF/proguard/sdk-serde-jackson.pro new file mode 100644 index 00000000..2707f732 --- /dev/null +++ b/sdk-serde-jackson/src/main/resources/META-INF/proguard/sdk-serde-jackson.pro @@ -0,0 +1,28 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah +# +# Licensed under the MIT License. See LICENSE in the project root. +# SPDX-License-Identifier: MIT + +# Consumer ProGuard/R8 keep rules for sdk-serde-jackson. +# +# JacksonSerde is the public entry point (withDefaults() / from(ObjectMapper)). The module also +# registers a custom module that teaches Jackson how to (de)serialize Tristate; both the entry +# point and that module are reached reflectively through Jackson's module-registration and +# bean-introspection machinery, so they must survive shrinking. +-keep class org.dexpace.sdk.serde.jackson.JacksonSerde { *; } +-keep class org.dexpace.sdk.serde.jackson.JacksonObjectMappers { *; } +-keep class org.dexpace.sdk.serde.jackson.TristateModule { *; } + +# Jackson databind is reflection-heavy: it reads annotations, walks bean members, and resolves +# parametric types at runtime, and its own config classes initialise from annotation enum +# singletons (a stripped or renamed enum value surfaces as an NPE in SerializationConfig's static +# initialiser). It is not meaningfully shrinkable without a hand-curated configuration, so the +# conventional — and the only safe — consumer recommendation is to keep the databind, core, and +# annotation packages wholesale, retain the attributes Jackson reflects over, and keep every +# annotation enum intact. +-keepattributes Signature,*Annotation*,EnclosingMethod,InnerClasses +-keep class com.fasterxml.jackson.databind.** { *; } +-keep class com.fasterxml.jackson.core.** { *; } +-keep class com.fasterxml.jackson.annotation.** { *; } +-keep enum com.fasterxml.jackson.** { *; } +-dontwarn com.fasterxml.jackson.databind.ext.** diff --git a/sdk-shrink-test/build.gradle.kts b/sdk-shrink-test/build.gradle.kts new file mode 100644 index 00000000..fa1f7ca3 --- /dev/null +++ b/sdk-shrink-test/build.gradle.kts @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.zip.ZipFile + +plugins { + // Kotlin only — no kover, no maven-publish, no signing. This module produces no published + // artifact and contributes no coverage; it is a build-time regression guard. The root build + // therefore does NOT add it to the kover aggregate, and `apiValidation.ignoredProjects` + // (root build.gradle.kts) keeps it out of the binary-compatibility snapshot. Java-8 bytecode, + // explicit-API strict mode, ktlint, and detekt are all inherited from the root build and left + // ON — the shrink harness honours the same conventions as the published modules. + kotlin("jvm") +} + +group = "org.dexpace" +version = "0.0.1-alpha.1" + +// The shrink harness exercises the SDK exactly as a downstream consumer would: it depends on the +// published modules (core, the Okio I/O adapter, the OkHttp transport, the Jackson serde) and +// their real transitive runtime dependencies, then bundles the lot into a single program for R8 +// to shrink. MockWebServer drives a genuine in-process HTTP round-trip so the shrunk program +// proves the transport still works end-to-end, not merely that its classes survived. +dependencies { + implementation(project(":sdk-core")) + implementation(project(":sdk-io-okio3")) + implementation(project(":sdk-transport-okhttp")) + implementation(project(":sdk-serde-jackson")) + implementation(libs.okhttp.mockwebserver.junit5) + + // SLF4J is compileOnly in the SDK, so a real consumer supplies the API plus a binding at + // runtime. The no-op binding (which transitively brings slf4j-api) is the lightest choice and + // matches what every transport test runtime already uses; it must be bundled into the shrink + // input jar or the shrunk program fails with NoClassDefFoundError: org/slf4j/LoggerFactory. + runtimeOnly(libs.slf4j.nop) + + // R8 itself runs as an external tool (see the r8Shrink task), so it lives in its own resolvable + // configuration rather than on the program classpath. +} + +// --------------------------------------------------------------------------------------------- +// R8 shrink-survival pipeline +// +// buildShrinkInputJar -> fat jar: consumer program + SDK + okio + okhttp + jackson + stdlib +// r8Shrink -> runs R8 in full mode over that jar with the SHIPPED consumer rules +// r8Run -> runs the shrunk program and asserts it prints the success sentinel +// +// r8Run is wired into `check`, so a plain `./gradlew build` proves shrink-survival. +// --------------------------------------------------------------------------------------------- + +// Isolated configuration holding only the R8 tool jar, fetched from Google's Maven repo (added +// to the root allprojects { repositories } block). Keeping it separate means R8's own (large) +// dependency closure never leaks onto the program classpath that gets shrunk. +val r8Tool: Configuration by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + r8Tool(libs.r8) +} + +val shrinkBuildDir: Provider = layout.buildDirectory.dir("r8") +val shrinkInputJar: Provider = shrinkBuildDir.map { it.file("consumer-all.jar") } +val shrunkJar: Provider = shrinkBuildDir.map { it.file("consumer-shrunk.jar") } + +// Kept in sync with ShrinkSurvivalApp.SUCCESS_SENTINEL. The build script cannot reference the +// project's own compiled classes, so the literal is duplicated here; the app prints it and the +// harness greps for it. +val successSentinel = "SHRINK-SURVIVAL-OK" + +// The consumer rules each SDK module ships under META-INF/proguard. The harness feeds these to R8 +// explicitly (rather than relying on R8 to auto-discover them) so the run fails loudly if a module +// ever stops shipping its rules — that is the regression this module guards. +val shippedConsumerRulePaths: List = + listOf( + "META-INF/proguard/sdk-core.pro", + "META-INF/proguard/sdk-io-okio3.pro", + "META-INF/proguard/sdk-transport-okhttp.pro", + "META-INF/proguard/sdk-serde-jackson.pro", + ) + +// Bundle the consumer program and its entire runtime classpath into one jar — the program R8 will +// shrink. Service-loader manifests are concatenated; other duplicates (e.g. repeated module +// metadata, signature files) are dropped so the merged jar stays valid. +val buildShrinkInputJar by tasks.registering(Jar::class) { + group = "shrink" + description = "Bundles the consumer program and its runtime classpath into the R8 input jar." + + dependsOn(tasks.named("classes")) + destinationDirectory.set(shrinkBuildDir) + archiveFileName.set("consumer-all.jar") + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + + val runtimeClasspath = configurations.named("runtimeClasspath") + from(sourceSets.main.get().output) + from(runtimeClasspath.map { cfg -> cfg.map { if (it.isDirectory) it else zipTree(it) } }) + + // Drop signed-jar metadata that would otherwise make the merged jar fail verification, and + // module descriptors that are meaningless once everything is flattened onto the classpath. + exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA") + exclude("module-info.class", "META-INF/versions/**/module-info.class") + + manifest { + attributes("Main-Class" to "org.dexpace.sdk.shrinktest.ShrinkSurvivalApp") + } +} + +// Java toolchain used to RUN R8 (the tool is Java-11 bytecode) and as R8's `--lib` runtime image. +// Java 8 program bytecode runs fine against an 11 boot image, so this does not change the program's +// target. Resolved lazily so configuration does not force toolchain provisioning. +val r8Launcher = + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(11)) + } + +val r8Shrink by tasks.registering(JavaExec::class) { + group = "shrink" + description = "Runs R8 in full mode over the consumer jar using the SDK's shipped keep-rules." + + dependsOn(buildShrinkInputJar) + inputs.files(r8Tool) + inputs.file(shrinkInputJar) + inputs.file(layout.projectDirectory.file("src/r8/app-rules.pro")) + outputs.file(shrunkJar) + + classpath = r8Tool + mainClass.set("com.android.tools.r8.R8") + javaLauncher.set(r8Launcher) + + doFirst { + val inputJar = shrinkInputJar.get().asFile + val outputJar = shrunkJar.get().asFile + outputJar.parentFile.mkdirs() + outputJar.delete() + + // Extract each shipped consumer-rules file out of the input jar and assert it is present. + // A missing file here is exactly the shrink-survival regression we want to catch. + val extractedRulesDir = shrinkBuildDir.get().dir("shipped-rules").asFile + extractedRulesDir.mkdirs() + val extractedRuleFiles = mutableListOf() + ZipFile(inputJar).use { zip -> + shippedConsumerRulePaths.forEach { entryPath -> + val entry = + zip.getEntry(entryPath) + ?: error( + "Shipped consumer keep-rules missing from the SDK on the classpath: " + + "$entryPath. Every SDK module that has reflectively/SPI-reached " + + "surface must ship its rules under META-INF/proguard.", + ) + val dest = File(extractedRulesDir, entryPath.substringAfterLast('/')) + zip.getInputStream(entry).use { input -> dest.outputStream().use { input.copyTo(it) } } + extractedRuleFiles += dest + } + } + + // R8 needs a boot image to resolve java.* references; the launcher's JDK home serves as the + // `--lib` runtime image. + val jdkHome = r8Launcher.get().metadata.installationPath.asFile.absolutePath + val appRules = layout.projectDirectory.file("src/r8/app-rules.pro").asFile + + val r8Args = mutableListOf("--release", "--classfile", "--output", outputJar.absolutePath) + r8Args += listOf("--lib", jdkHome) + extractedRuleFiles.forEach { r8Args += listOf("--pg-conf", it.absolutePath) } + r8Args += listOf("--pg-conf", appRules.absolutePath) + r8Args += inputJar.absolutePath + args = r8Args + + logger.lifecycle( + "Running R8 over ${inputJar.name} with ${extractedRuleFiles.size} shipped rule " + + "file(s) + app rules", + ) + } +} + +val r8Run by tasks.registering(JavaExec::class) { + group = "shrink" + description = "Runs the R8-shrunk consumer program and asserts the SDK survived shrinking." + + dependsOn(r8Shrink) + inputs.file(shrunkJar) + // A marker output so the task is up-to-date when nothing changed. + val resultMarker = shrinkBuildDir.map { it.file("r8-run-ok.txt") } + outputs.file(resultMarker) + + // Run the shrunk classfiles directly; an 11 launcher executes the Java-8 program fine. + javaLauncher.set(r8Launcher) + mainClass.set("org.dexpace.sdk.shrinktest.ShrinkSurvivalApp") + // `files(...)` is lazy, so referencing the not-yet-produced shrunk jar at configuration time is + // fine — it is resolved when the task runs, after r8Shrink has created it. + classpath = files(shrunkJar) + + val captured = ByteArrayOutputStream() + standardOutput = captured + + doLast { + val output = captured.toString("UTF-8") + logger.lifecycle(output.trim()) + check(output.contains(successSentinel)) { + "The R8-shrunk consumer did not print the success sentinel. The SDK did not survive " + + "shrinking with its shipped keep-rules. Program output was:\n$output" + } + resultMarker.get().asFile.writeText("ok\n") + } +} + +// Make the shrink-survival check part of the normal build: `./gradlew build` (which runs `check`) +// now bundles, shrinks, and runs the consumer, failing if the SDK does not survive R8. +tasks.named("check") { + dependsOn(r8Run) +} diff --git a/sdk-shrink-test/src/main/kotlin/org/dexpace/sdk/shrinktest/ShrinkSurvivalApp.kt b/sdk-shrink-test/src/main/kotlin/org/dexpace/sdk/shrinktest/ShrinkSurvivalApp.kt new file mode 100644 index 00000000..7e76078d --- /dev/null +++ b/sdk-shrink-test/src/main/kotlin/org/dexpace/sdk/shrinktest/ShrinkSurvivalApp.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.shrinktest + +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import org.dexpace.sdk.core.http.common.CommonMediaTypes +import org.dexpace.sdk.core.http.request.Method +import org.dexpace.sdk.core.http.request.Request +import org.dexpace.sdk.core.http.request.RequestBody +import org.dexpace.sdk.core.http.response.Status +import org.dexpace.sdk.core.io.Io +import org.dexpace.sdk.core.serde.Tristate +import org.dexpace.sdk.io.OkioIoProvider +import org.dexpace.sdk.serde.jackson.JacksonSerde +import org.dexpace.sdk.transport.okhttp.OkHttpTransport + +/** + * A small but representative SDK consumer, used as the input program for the R8 shrink-survival + * check. It deliberately drives the toolkit only through its documented public surface and SPI + * seams — the same surface the shipped `META-INF/proguard` consumer rules protect — and prints + * [SUCCESS_SENTINEL] on a clean run. + * + * The harness runs this twice: once from the un-shrunk jar to establish the baseline, and once + * from the R8-shrunk jar. Because the shrunk run performs a real HTTP round-trip and a real JSON + * round-trip, it proves not merely that the kept classes still exist but that their members + * remain wired correctly after tree-shaking and (potential) renaming. + */ +public object ShrinkSurvivalApp { + /** Printed verbatim to stdout once every exercise below has passed. */ + public const val SUCCESS_SENTINEL: String = "SHRINK-SURVIVAL-OK" + + @JvmStatic + public fun main(args: Array) { + // 1. I/O provider seam — install the only adapter through the documented entry point. + Io.installProvider(OkioIoProvider) + + exerciseTransportRoundTrip() + exerciseSerdeRoundTrip() + + // Reaching here means every kept surface resolved and behaved. Emit the sentinel the + // harness greps for. + println(SUCCESS_SENTINEL) + } + + /** + * Drives a full request/response exchange through [OkHttpTransport] against an in-process + * [MockWebServer]. Exercises the request builder, [RequestBody], the transport's sync + * `execute` path, and reading the [org.dexpace.sdk.core.http.response.Response] body via the + * I/O seam. + */ + private fun exerciseTransportRoundTrip() { + MockWebServer().use { server -> + server.enqueue( + MockResponse.Builder() + .code(Status.OK.code) + .addHeader("Content-Type", "application/json") + .body("""{"echo":"pong"}""") + .build(), + ) + server.start() + + val baseUrl = server.url("/echo").toString() + + OkHttpTransport.builder().build().use { transport -> + val request = + Request.builder() + .method(Method.POST) + .url(baseUrl) + .addHeader("Accept", "application/json") + .body( + RequestBody.create( + """{"ping":"ping"}""", + CommonMediaTypes.APPLICATION_JSON, + ), + ) + .build() + + val response = transport.execute(request) + val status = response.status.code + val payload = response.body?.source()?.readUtf8().orEmpty() + response.close() + + check(status == Status.OK.code) { "unexpected status from transport: $status" } + check(payload.contains("pong")) { "unexpected body from transport: $payload" } + } + } + } + + /** + * Round-trips a model carrying a [Tristate] field through [JacksonSerde]. This is the most + * shrink-fragile path: Jackson binds the model reflectively and the custom Tristate module + * branches on the runtime type of each variant, so a shrinker that stripped the Tristate + * hierarchy or the data-class metadata would surface here. + */ + private fun exerciseSerdeRoundTrip() { + val serde = JacksonSerde.withDefaults() + + val original = + ConsumerModel( + name = "widget", + replacement = Tristate.present("gadget"), + cleared = Tristate.nullValue(), + untouched = Tristate.absent(), + ) + + // Drive the round-trip through the core Serde SPI (Serializer / Deserializer) rather than a + // Jackson-specific helper, so the consumer touches only sdk-core's serde surface. Jackson + // still recovers each field's declared type (including Tristate) from the model's + // Kotlin metadata, which is exactly the reflective path the shipped keep-rules protect. + val json = serde.serializer.serialize(original) + val restored = serde.deserializer.deserialize(json, ConsumerModel::class.java) + + check(restored.name == "widget") { "name did not survive serde round-trip: ${restored.name}" } + + // Present carries its payload across the wire. + val replacement = restored.replacement + check(replacement is Tristate.Present && replacement.value == "gadget") { + "present Tristate did not survive serde round-trip: $replacement" + } + // An explicit JSON null stays Null. + check(restored.cleared is Tristate.Null) { + "null Tristate did not survive serde round-trip: ${restored.cleared}" + } + // Absent is omitted on serialize; on deserialize the model's Tristate.Absent default keeps + // the missing key Absent rather than collapsing it to Null. Exercising all three variants + // forces every branch of the custom Tristate (de)serializer the keep-rules protect. + check(restored.untouched is Tristate.Absent) { + "absent Tristate did not survive serde round-trip: ${restored.untouched}" + } + } +} + +/** + * Stand-in for an application model that a consumer would (de)serialize with the SDK's Jackson + * serde. The [Tristate] fields force the custom module's reflective binding into play. Each + * defaults to [Tristate.Absent] so that a JSON key missing entirely deserializes as Absent rather + * than collapsing to Null via Jackson's missing-property handling — the contract documented on the + * SDK's own Tristate module. + */ +public data class ConsumerModel( + val name: String, + val replacement: Tristate = Tristate.absent(), + val cleared: Tristate = Tristate.absent(), + val untouched: Tristate = Tristate.absent(), +) diff --git a/sdk-shrink-test/src/r8/app-rules.pro b/sdk-shrink-test/src/r8/app-rules.pro new file mode 100644 index 00000000..e1d87de4 --- /dev/null +++ b/sdk-shrink-test/src/r8/app-rules.pro @@ -0,0 +1,53 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah +# +# Licensed under the MIT License. See LICENSE in the project root. +# SPDX-License-Identifier: MIT + +# Application-side R8 configuration for the shrink-survival harness. +# +# These rules stand in for what a real SDK consumer writes for THEIR OWN code; the rules that +# protect the SDK ship inside the SDK jars under META-INF/proguard and are picked up +# automatically (the harness verifies they are present and sufficient). Keeping them separate +# keeps this file honest: if a kept SDK member were missing from the shipped rules, the shrunk +# run would fail here rather than be papered over by an SDK-specific keep living app-side. + +# Target a desktop/server JVM, not Android: emit classfiles, do not require a min API, and keep +# enough debug info that a stack trace from the shrunk run is legible. +-dontobfuscate +-keepattributes SourceFile,LineNumberTable + +# The consumer's entry point. Everything the program needs is reachable from here, so this is +# the single application-side seed for the whole shrink. +-keep class org.dexpace.sdk.shrinktest.ShrinkSurvivalApp { + public static void main(java.lang.String[]); + public static java.lang.String SUCCESS_SENTINEL; +} + +# The harness also reads the model class reflectively through Jackson; a real app would carry +# its own model keep, so we do too. +-keep class org.dexpace.sdk.shrinktest.ConsumerModel { *; } + +# --- Suppress warnings for references R8 cannot resolve on a plain JVM classpath ------------- +# +# These are optional/transitive integrations that the bundled libraries reference but never use +# in this program. A consumer's real build silences the same categories. None of them are SDK +# types; suppressing them does not weaken the survival assertion for the SDK surface. + +# Kotlin stdlib's optional intrinsics and coroutine/annotation hooks. +-dontwarn kotlin.** +-dontwarn kotlinx.** +-dontwarn org.jetbrains.annotations.** + +# SLF4J is compileOnly in the SDK; the program supplies no binding and logs nothing. +-dontwarn org.slf4j.** + +# Okio / OkHttp / MockWebServer optional platform integrations and animal-sniffer annotations. +-dontwarn okio.** +-dontwarn okhttp3.** +-dontwarn mockwebserver3.** +-dontwarn org.codehaus.mojo.animal_sniffer.** +-dontwarn javax.annotation.** + +# Jackson optional databind extensions (e.g. java.beans / DOM bridges) absent on this classpath. +-dontwarn com.fasterxml.jackson.** +-dontwarn java.beans.** diff --git a/sdk-transport-okhttp/src/main/resources/META-INF/proguard/sdk-transport-okhttp.pro b/sdk-transport-okhttp/src/main/resources/META-INF/proguard/sdk-transport-okhttp.pro new file mode 100644 index 00000000..1e9364a8 --- /dev/null +++ b/sdk-transport-okhttp/src/main/resources/META-INF/proguard/sdk-transport-okhttp.pro @@ -0,0 +1,20 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah +# +# Licensed under the MIT License. See LICENSE in the project root. +# SPDX-License-Identifier: MIT + +# Consumer ProGuard/R8 keep rules for sdk-transport-okhttp. +# +# OkHttpTransport is the public entry point; callers construct it through builder() / create() +# and then use it only through the HttpClient / AsyncHttpClient interfaces. Keep the class, its +# Builder, and the static factories so the construction path survives shrinking. +-keep class org.dexpace.sdk.transport.okhttp.OkHttpTransport { *; } +-keep class org.dexpace.sdk.transport.okhttp.OkHttpTransport$Builder { *; } + +# OkHttp performs its own service/reflection lookups (platform TLS, optional Conscrypt and +# BouncyCastle providers) that are absent on a plain JVM classpath. Suppress warnings for those +# optional integrations so a consumer's shrink does not fail on references it will never use. +-dontwarn okhttp3.internal.platform.** +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** diff --git a/settings.gradle.kts b/settings.gradle.kts index 1416c2a6..b69515f3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,3 +24,8 @@ include("sdk-async-virtualthreads") include("sdk-transport-okhttp") include("sdk-transport-jdkhttp") include("sdk-serde-jackson") + +// Test-only, unpublished: runs R8 against a consumer of the SDK to verify the toolkit survives +// downstream shrinking with its shipped consumer keep-rules. Kept out of the kover aggregate and +// the binary-compatibility snapshot (see root build.gradle.kts). +include("sdk-shrink-test") From d69e308507dd6a85a43f8f1ffd692d177ef389ce Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 22:31:14 +0300 Subject: [PATCH 2/3] build: correct duplicate-merge comment and clarify global scope of shipped keep-rules --- sdk-core/src/main/resources/META-INF/proguard/sdk-core.pro | 4 ++++ .../main/resources/META-INF/proguard/sdk-serde-jackson.pro | 7 +++++++ sdk-shrink-test/build.gradle.kts | 7 +++++-- .../kotlin/org/dexpace/sdk/shrinktest/ShrinkSurvivalApp.kt | 7 +++---- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/sdk-core/src/main/resources/META-INF/proguard/sdk-core.pro b/sdk-core/src/main/resources/META-INF/proguard/sdk-core.pro index 514f443d..3837fde7 100644 --- a/sdk-core/src/main/resources/META-INF/proguard/sdk-core.pro +++ b/sdk-core/src/main/resources/META-INF/proguard/sdk-core.pro @@ -66,5 +66,9 @@ # Kotlin emits @Metadata on every class; reflective Kotlin tooling (including Jackson's Kotlin # module) reads it to recover constructor parameter names and nullability. Strip it and # data-class binding silently degrades, so keep the annotation across the toolkit. +# +# Scope note: `-keepattributes` is a global directive. Because this file ships under +# META-INF/proguard, depending on sdk-core adds these attributes to the consumer's *entire* +# program, not just the SDK's classes. -keepattributes RuntimeVisibleAnnotations,AnnotationDefault -keep class kotlin.Metadata { *; } diff --git a/sdk-serde-jackson/src/main/resources/META-INF/proguard/sdk-serde-jackson.pro b/sdk-serde-jackson/src/main/resources/META-INF/proguard/sdk-serde-jackson.pro index 2707f732..024207cb 100644 --- a/sdk-serde-jackson/src/main/resources/META-INF/proguard/sdk-serde-jackson.pro +++ b/sdk-serde-jackson/src/main/resources/META-INF/proguard/sdk-serde-jackson.pro @@ -20,6 +20,13 @@ # conventional — and the only safe — consumer recommendation is to keep the databind, core, and # annotation packages wholesale, retain the attributes Jackson reflects over, and keep every # annotation enum intact. +# +# Scope note: because this file ships under META-INF/proguard, R8/AGP applies it to the consumer's +# entire program, not just the SDK's classes. The wholesale Jackson `-keep` rules below therefore +# exempt the consumer's *entire* Jackson surface from shrinking — including any Jackson the app uses +# directly, elsewhere. The `-keepattributes` directive is likewise global: it adds these attributes +# to the whole consumer build, not only to SDK classes. An app that wants tighter shrinking can +# override these rules in its own configuration. -keepattributes Signature,*Annotation*,EnclosingMethod,InnerClasses -keep class com.fasterxml.jackson.databind.** { *; } -keep class com.fasterxml.jackson.core.** { *; } diff --git a/sdk-shrink-test/build.gradle.kts b/sdk-shrink-test/build.gradle.kts index fa1f7ca3..40f75285 100644 --- a/sdk-shrink-test/build.gradle.kts +++ b/sdk-shrink-test/build.gradle.kts @@ -87,8 +87,11 @@ val shippedConsumerRulePaths: List = ) // Bundle the consumer program and its entire runtime classpath into one jar — the program R8 will -// shrink. Service-loader manifests are concatenated; other duplicates (e.g. repeated module -// metadata, signature files) are dropped so the merged jar stays valid. +// shrink. `DuplicatesStrategy.EXCLUDE` keeps only the first occurrence of any duplicated path and +// skips the rest; it does not merge. That includes service-loader manifests — colliding +// `META-INF/services/` entries are NOT concatenated, only the first wins. It happens not to +// matter for the current classpath (no colliding service files), but a future dependency that +// ships one would need explicit concatenation (e.g. a Shadow `ServiceFileTransformer`) here. val buildShrinkInputJar by tasks.registering(Jar::class) { group = "shrink" description = "Bundles the consumer program and its runtime classpath into the R8 input jar." diff --git a/sdk-shrink-test/src/main/kotlin/org/dexpace/sdk/shrinktest/ShrinkSurvivalApp.kt b/sdk-shrink-test/src/main/kotlin/org/dexpace/sdk/shrinktest/ShrinkSurvivalApp.kt index 7e76078d..b371f58e 100644 --- a/sdk-shrink-test/src/main/kotlin/org/dexpace/sdk/shrinktest/ShrinkSurvivalApp.kt +++ b/sdk-shrink-test/src/main/kotlin/org/dexpace/sdk/shrinktest/ShrinkSurvivalApp.kt @@ -26,10 +26,9 @@ import org.dexpace.sdk.transport.okhttp.OkHttpTransport * seams — the same surface the shipped `META-INF/proguard` consumer rules protect — and prints * [SUCCESS_SENTINEL] on a clean run. * - * The harness runs this twice: once from the un-shrunk jar to establish the baseline, and once - * from the R8-shrunk jar. Because the shrunk run performs a real HTTP round-trip and a real JSON - * round-trip, it proves not merely that the kept classes still exist but that their members - * remain wired correctly after tree-shaking and (potential) renaming. + * The harness runs this from the R8-shrunk jar. Because that run performs a real HTTP round-trip + * and a real JSON round-trip, it proves not merely that the kept classes still exist but that their + * members remain wired correctly after tree-shaking and (potential) renaming. */ public object ShrinkSurvivalApp { /** Printed verbatim to stdout once every exercise below has passed. */ From 8fa5a27fcb49423695530dded557017ab5a7c281 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 17 Jun 2026 00:43:33 +0300 Subject: [PATCH 3/3] build: guard jdkhttp keep-rules and harden the shrink-survival harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship consumer keep-rules for sdk-transport-jdkhttp so both reference transports protect their construction surface symmetrically, and guard the new rules for real: the harness now bundles the jdkhttp transport and drives an R8-shrunk round-trip through both transports via a shared helper, rather than only okhttp. Depending on the Java-11 jdkhttp module means sdk-shrink-test now targets JDK 11 — the honest model, since the pipeline already runs on a JDK 11 (R8 is Java-11 bytecode and the shrunk program runs on the same launcher). Detekt is skipped here for the same JDK-25-toolchain reason as the other non-8 modules; ktlint and explicit-API strict mode stay on. Also: - Clarify that the harness is shrink-only (obfuscation disabled): correct the app KDoc and expand app-rules.pro on why renaming is out of scope. - r8Run now ignores the exit value and asserts exit code + sentinel in doLast, so a failing shrunk run surfaces its captured output instead of an empty JavaExec abort. - Document in CLAUDE.md that check now needs a JDK 11 toolchain and the Google Maven repo for the shrink-survival step. --- CLAUDE.md | 11 ++- sdk-shrink-test/build.gradle.kts | 72 ++++++++++++-- .../sdk/shrinktest/ShrinkSurvivalApp.kt | 95 +++++++++++-------- sdk-shrink-test/src/r8/app-rules.pro | 8 ++ .../proguard/sdk-transport-jdkhttp.pro | 14 +++ 5 files changed, 151 insertions(+), 49 deletions(-) create mode 100644 sdk-transport-jdkhttp/src/main/resources/META-INF/proguard/sdk-transport-jdkhttp.pro diff --git a/CLAUDE.md b/CLAUDE.md index 7675b78c..a84aaa68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,10 +21,17 @@ root `check` task — see `build.gradle.kts`). Detekt is skipped on the two non- system toolchain when a module targets a non-8 toolchain; see those build scripts for the upstream issue and re-enable conditions. It runs everywhere else, including `sdk-transport-okhttp`. +`check` (so a plain `./gradlew build`) also runs the R8 shrink-survival guard in the test-only +`sdk-shrink-test` module. That step **requires a JDK 11 toolchain** (Gradle auto-provisions one if absent) +and network access to **Google's Maven repo** to fetch `com.android.tools:r8`. An offline build, or one +that cannot provision JDK 11, will fail on `:sdk-shrink-test:r8Run`; scope the build (e.g. build specific +modules) to skip it. See that module's `build.gradle.kts` for the pipeline. + ## Repository Layout -Nine Gradle modules (see `settings.gradle.kts`). `gradle/libs.versions.toml` is the single source of truth -for dependency and plugin versions. Group `org.dexpace`, version `0.0.1-alpha.1`. +Ten Gradle modules (see `settings.gradle.kts`). `gradle/libs.versions.toml` is the single source of truth +for dependency and plugin versions. Group `org.dexpace`, version `0.0.1-alpha.1`. (The tenth, +`sdk-shrink-test`, is a test-only, unpublished R8 shrink-survival guard — not listed below.) | Module | Purpose | JVM target | |---|---|---| diff --git a/sdk-shrink-test/build.gradle.kts b/sdk-shrink-test/build.gradle.kts index 40f75285..c083dae6 100644 --- a/sdk-shrink-test/build.gradle.kts +++ b/sdk-shrink-test/build.gradle.kts @@ -5,6 +5,8 @@ * SPDX-License-Identifier: MIT */ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.io.ByteArrayOutputStream import java.io.File import java.util.zip.ZipFile @@ -13,24 +15,61 @@ plugins { // Kotlin only — no kover, no maven-publish, no signing. This module produces no published // artifact and contributes no coverage; it is a build-time regression guard. The root build // therefore does NOT add it to the kover aggregate, and `apiValidation.ignoredProjects` - // (root build.gradle.kts) keeps it out of the binary-compatibility snapshot. Java-8 bytecode, - // explicit-API strict mode, ktlint, and detekt are all inherited from the root build and left - // ON — the shrink harness honours the same conventions as the published modules. + // (root build.gradle.kts) keeps it out of the binary-compatibility snapshot. Explicit-API + // strict mode and ktlint are inherited from the root build and left ON; detekt is disabled + // below for the same JDK-25-toolchain reason as the other non-Java-8 modules. kotlin("jvm") } group = "org.dexpace" version = "0.0.1-alpha.1" +// This module targets JDK 11, not the root's Java-8 default. It depends on sdk-transport-jdkhttp +// (Java-11 bytecode) so it can exercise that transport through R8, and the whole shrink pipeline +// already runs on a JDK 11 (R8 itself is Java-11 bytecode, and the shrunk program runs on the same +// 11 launcher) — so a downstream consumer that uses the jdkhttp transport is, by construction, an +// 11+ consumer. Override all three knobs the way sdk-transport-jdkhttp documents (S2.12 — Toolchain +// discipline): the toolchain, the `java {}` source/target, and every Kotlin compile task's target. +kotlin { + jvmToolchain(11) +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + toolchain { + languageVersion.set(JavaLanguageVersion.of(11)) + } +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +// Detekt is disabled here for the same reason as sdk-transport-jdkhttp and sdk-async-virtualthreads: +// detekt 1.23.x crashes parsing the JDK 25+ system toolchain version when a module targets a non-8 +// toolchain (detekt/detekt#8714). Re-enable when detekt 1.23.x embeds Kotlin >= 2.1.20 or the build +// moves to detekt 2.x. ktlint and explicit-API strict mode stay on. +tasks.matching { it.name == "detekt" }.configureEach { + enabled = false +} + // The shrink harness exercises the SDK exactly as a downstream consumer would: it depends on the -// published modules (core, the Okio I/O adapter, the OkHttp transport, the Jackson serde) and +// published modules (core, the Okio I/O adapter, both reference transports, the Jackson serde) and // their real transitive runtime dependencies, then bundles the lot into a single program for R8 -// to shrink. MockWebServer drives a genuine in-process HTTP round-trip so the shrunk program -// proves the transport still works end-to-end, not merely that its classes survived. +// to shrink. MockWebServer drives a genuine in-process HTTP round-trip through each transport so +// the shrunk program proves they still work end-to-end, not merely that their classes survived. +// +// Both transports are included so the harness guards each one's shipped keep-rules symmetrically: +// dropping a rule from either sdk-transport-okhttp or sdk-transport-jdkhttp would fail this module. +// Depending on the Java-11 jdkhttp module is why this module itself targets JDK 11 (see above). dependencies { implementation(project(":sdk-core")) implementation(project(":sdk-io-okio3")) implementation(project(":sdk-transport-okhttp")) + implementation(project(":sdk-transport-jdkhttp")) implementation(project(":sdk-serde-jackson")) implementation(libs.okhttp.mockwebserver.junit5) @@ -47,7 +86,7 @@ dependencies { // --------------------------------------------------------------------------------------------- // R8 shrink-survival pipeline // -// buildShrinkInputJar -> fat jar: consumer program + SDK + okio + okhttp + jackson + stdlib +// buildShrinkInputJar -> fat jar: consumer program + SDK + okio + transports + jackson + stdlib // r8Shrink -> runs R8 in full mode over that jar with the SHIPPED consumer rules // r8Run -> runs the shrunk program and asserts it prints the success sentinel // @@ -83,6 +122,7 @@ val shippedConsumerRulePaths: List = "META-INF/proguard/sdk-core.pro", "META-INF/proguard/sdk-io-okio3.pro", "META-INF/proguard/sdk-transport-okhttp.pro", + "META-INF/proguard/sdk-transport-jdkhttp.pro", "META-INF/proguard/sdk-serde-jackson.pro", ) @@ -201,13 +241,25 @@ val r8Run by tasks.registering(JavaExec::class) { val captured = ByteArrayOutputStream() standardOutput = captured + // Capture rather than inherit stderr too, so a stack trace from a failing shrunk run is folded + // into the diagnostic below instead of racing the captured stdout to the console. + errorOutput = captured + // Do not let a non-zero exit abort the task before doLast: if the shrunk program throws (the + // SDK did not survive shrinking), JavaExec's default behaviour would fail here and discard the + // captured output — exactly the diagnostic we need. We assert success ourselves in doLast. + isIgnoreExitValue = true + // `executionResult` is a member of JavaExec, but inside `doLast` the lambda receiver is the bare + // Task; capture the provider here so the exit code is reachable when the action runs. + val execResult = executionResult doLast { val output = captured.toString("UTF-8") logger.lifecycle(output.trim()) - check(output.contains(successSentinel)) { - "The R8-shrunk consumer did not print the success sentinel. The SDK did not survive " + - "shrinking with its shipped keep-rules. Program output was:\n$output" + val exitValue = execResult.get().exitValue + check(exitValue == 0 && output.contains(successSentinel)) { + "The R8-shrunk consumer did not survive shrinking with its shipped keep-rules " + + "(exit code $exitValue). The SDK surface a downstream R8 build relies on was " + + "stripped or broken. Program output was:\n$output" } resultMarker.get().asFile.writeText("ok\n") } diff --git a/sdk-shrink-test/src/main/kotlin/org/dexpace/sdk/shrinktest/ShrinkSurvivalApp.kt b/sdk-shrink-test/src/main/kotlin/org/dexpace/sdk/shrinktest/ShrinkSurvivalApp.kt index b371f58e..b11d4fc4 100644 --- a/sdk-shrink-test/src/main/kotlin/org/dexpace/sdk/shrinktest/ShrinkSurvivalApp.kt +++ b/sdk-shrink-test/src/main/kotlin/org/dexpace/sdk/shrinktest/ShrinkSurvivalApp.kt @@ -9,6 +9,7 @@ package org.dexpace.sdk.shrinktest import mockwebserver3.MockResponse import mockwebserver3.MockWebServer +import org.dexpace.sdk.core.client.HttpClient import org.dexpace.sdk.core.http.common.CommonMediaTypes import org.dexpace.sdk.core.http.request.Method import org.dexpace.sdk.core.http.request.Request @@ -18,6 +19,7 @@ import org.dexpace.sdk.core.io.Io import org.dexpace.sdk.core.serde.Tristate import org.dexpace.sdk.io.OkioIoProvider import org.dexpace.sdk.serde.jackson.JacksonSerde +import org.dexpace.sdk.transport.jdkhttp.JdkHttpTransport import org.dexpace.sdk.transport.okhttp.OkHttpTransport /** @@ -27,8 +29,12 @@ import org.dexpace.sdk.transport.okhttp.OkHttpTransport * [SUCCESS_SENTINEL] on a clean run. * * The harness runs this from the R8-shrunk jar. Because that run performs a real HTTP round-trip - * and a real JSON round-trip, it proves not merely that the kept classes still exist but that their - * members remain wired correctly after tree-shaking and (potential) renaming. + * and a real JSON round-trip, it proves not merely that the kept classes still exist after R8's + * tree-shaking but that their members remain wired correctly and functional. The harness runs R8 + * in shrink-only mode (obfuscation is disabled in `src/r8/app-rules.pro`, see the rationale there), + * so it guards against dead-code elimination of the SDK's reflective and SPI surface — not against + * member renaming, which a downstream that also obfuscates would additionally need its own rules + * for the third-party libraries on its classpath. */ public object ShrinkSurvivalApp { /** Printed verbatim to stdout once every exercise below has passed. */ @@ -39,7 +45,7 @@ public object ShrinkSurvivalApp { // 1. I/O provider seam — install the only adapter through the documented entry point. Io.installProvider(OkioIoProvider) - exerciseTransportRoundTrip() + exerciseTransportRoundTrips() exerciseSerdeRoundTrip() // Reaching here means every kept surface resolved and behaved. Emit the sentinel the @@ -48,49 +54,64 @@ public object ShrinkSurvivalApp { } /** - * Drives a full request/response exchange through [OkHttpTransport] against an in-process - * [MockWebServer]. Exercises the request builder, [RequestBody], the transport's sync - * `execute` path, and reading the [org.dexpace.sdk.core.http.response.Response] body via the - * I/O seam. + * Drives a full request/response exchange through each reference transport — [OkHttpTransport] + * and [JdkHttpTransport] — against an in-process [MockWebServer]. Exercises the request builder, + * [RequestBody], the transport's sync `execute` path, and reading the + * [org.dexpace.sdk.core.http.response.Response] body via the I/O seam. Both transports are + * driven so the harness guards each module's shipped keep-rules; the construction path of either + * being stripped would surface here. */ - private fun exerciseTransportRoundTrip() { + private fun exerciseTransportRoundTrips() { MockWebServer().use { server -> - server.enqueue( - MockResponse.Builder() - .code(Status.OK.code) - .addHeader("Content-Type", "application/json") - .body("""{"echo":"pong"}""") - .build(), - ) + // One queued response per transport — they each issue a single request below. + repeat(2) { + server.enqueue( + MockResponse.Builder() + .code(Status.OK.code) + .addHeader("Content-Type", "application/json") + .body("""{"echo":"pong"}""") + .build(), + ) + } server.start() val baseUrl = server.url("/echo").toString() - OkHttpTransport.builder().build().use { transport -> - val request = - Request.builder() - .method(Method.POST) - .url(baseUrl) - .addHeader("Accept", "application/json") - .body( - RequestBody.create( - """{"ping":"ping"}""", - CommonMediaTypes.APPLICATION_JSON, - ), - ) - .build() - - val response = transport.execute(request) - val status = response.status.code - val payload = response.body?.source()?.readUtf8().orEmpty() - response.close() - - check(status == Status.OK.code) { "unexpected status from transport: $status" } - check(payload.contains("pong")) { "unexpected body from transport: $payload" } - } + OkHttpTransport.builder().build().use { roundTrip(it, baseUrl) } + JdkHttpTransport.builder().build().use { roundTrip(it, baseUrl) } } } + /** + * Issues one POST through [transport] (typed as the core [HttpClient] SPI, so the exercise stays + * transport-agnostic) and asserts the response came back intact through the I/O seam. + */ + private fun roundTrip( + transport: HttpClient, + baseUrl: String, + ) { + val request = + Request.builder() + .method(Method.POST) + .url(baseUrl) + .addHeader("Accept", "application/json") + .body( + RequestBody.create( + """{"ping":"ping"}""", + CommonMediaTypes.APPLICATION_JSON, + ), + ) + .build() + + val response = transport.execute(request) + val status = response.status.code + val payload = response.body?.source()?.readUtf8().orEmpty() + response.close() + + check(status == Status.OK.code) { "unexpected status from transport: $status" } + check(payload.contains("pong")) { "unexpected body from transport: $payload" } + } + /** * Round-trips a model carrying a [Tristate] field through [JacksonSerde]. This is the most * shrink-fragile path: Jackson binds the model reflectively and the custom Tristate module diff --git a/sdk-shrink-test/src/r8/app-rules.pro b/sdk-shrink-test/src/r8/app-rules.pro index e1d87de4..8c36d603 100644 --- a/sdk-shrink-test/src/r8/app-rules.pro +++ b/sdk-shrink-test/src/r8/app-rules.pro @@ -13,6 +13,14 @@ # Target a desktop/server JVM, not Android: emit classfiles, do not require a min API, and keep # enough debug info that a stack trace from the shrunk run is legible. +# +# Obfuscation (member/class renaming) is deliberately OFF. This harness guards SHRINK survival: +# that R8's dead-code elimination does not strip the SDK's reflective and SPI surface. It does not +# guard obfuscation survival, because renaming would also rename the third-party libraries bundled +# here (OkHttp, Okio, Jackson), each of which ships its own consumer keep-rules that a real +# obfuscating consumer applies — reproducing that whole closure is out of scope for this module. +# The SDK's own shipped rules use `-keep ... { *; }`, which already pins names against renaming, so +# enabling obfuscation here would mostly test the bundled libraries' rules, not the SDK's. -dontobfuscate -keepattributes SourceFile,LineNumberTable diff --git a/sdk-transport-jdkhttp/src/main/resources/META-INF/proguard/sdk-transport-jdkhttp.pro b/sdk-transport-jdkhttp/src/main/resources/META-INF/proguard/sdk-transport-jdkhttp.pro new file mode 100644 index 00000000..9ec14954 --- /dev/null +++ b/sdk-transport-jdkhttp/src/main/resources/META-INF/proguard/sdk-transport-jdkhttp.pro @@ -0,0 +1,14 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah +# +# Licensed under the MIT License. See LICENSE in the project root. +# SPDX-License-Identifier: MIT + +# Consumer ProGuard/R8 keep rules for sdk-transport-jdkhttp. +# +# JdkHttpTransport is the public entry point; callers construct it through builder() / create() +# and then use it only through the HttpClient / AsyncHttpClient interfaces. Keep the class, its +# Builder, and the static factories so the construction path survives shrinking. This mirrors the +# rules sdk-transport-okhttp ships for its own transport — every reference transport protects the +# same construction surface. +-keep class org.dexpace.sdk.transport.jdkhttp.JdkHttpTransport { *; } +-keep class org.dexpace.sdk.transport.jdkhttp.JdkHttpTransport$Builder { *; }