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/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..3837fde7 --- /dev/null +++ b/sdk-core/src/main/resources/META-INF/proguard/sdk-core.pro @@ -0,0 +1,74 @@ +# 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. +# +# 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-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..024207cb --- /dev/null +++ b/sdk-serde-jackson/src/main/resources/META-INF/proguard/sdk-serde-jackson.pro @@ -0,0 +1,35 @@ +# 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. +# +# 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.** { *; } +-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..c083dae6 --- /dev/null +++ b/sdk-shrink-test/build.gradle.kts @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * 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 + +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. 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, 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 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) + + // 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 + 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 +// +// 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-transport-jdkhttp.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. `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." + + 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 + // 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()) + 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") + } +} + +// 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..b11d4fc4 --- /dev/null +++ b/sdk-shrink-test/src/main/kotlin/org/dexpace/sdk/shrinktest/ShrinkSurvivalApp.kt @@ -0,0 +1,171 @@ +/* + * 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.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 +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.jdkhttp.JdkHttpTransport +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 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 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. */ + 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) + + exerciseTransportRoundTrips() + 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 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 exerciseTransportRoundTrips() { + MockWebServer().use { server -> + // 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 { 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 + * 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..8c36d603 --- /dev/null +++ b/sdk-shrink-test/src/r8/app-rules.pro @@ -0,0 +1,61 @@ +# 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. +# +# 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 + +# 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-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 { *; } 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")