From 304cded68f18d5d01776930ffcc2be799c0ad96a Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Wed, 1 Jul 2026 16:12:41 -0400 Subject: [PATCH 01/14] Add a tool enum --- .../gradle/plugins/shadow/tasks/MinimizeTool.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeTool.kt diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeTool.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeTool.kt new file mode 100644 index 000000000..609ce0852 --- /dev/null +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeTool.kt @@ -0,0 +1,10 @@ +package com.github.jengelman.gradle.plugins.shadow.tasks + +/** A tool that can minimize a shadowed JAR. */ +public enum class MinimizeTool { + /** Shadow's default, simple dependency analyzer. */ + DEPENDENCY_ANALYZER, + + /** R8 classfile shrinking. */ + R8, +} From f6115af7c6b5d5d258a4d9a970c9bd91c7e01ea7 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Wed, 1 Jul 2026 16:13:04 -0400 Subject: [PATCH 02/14] Add MinimizeSpec --- .../plugins/shadow/tasks/MinimizeSpec.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeSpec.kt diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeSpec.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeSpec.kt new file mode 100644 index 000000000..9d20ab212 --- /dev/null +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeSpec.kt @@ -0,0 +1,22 @@ +package com.github.jengelman.gradle.plugins.shadow.tasks + +import org.gradle.api.Action +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Nested + +/** Configures how [ShadowJar.minimize] removes unused code from the shadowed JAR. */ +public interface MinimizeSpec : DependencyFilter { + /** + * The tool used to minimize the shadowed JAR. + * + * Defaults to [MinimizeTool.DEPENDENCY_ANALYZER]. + */ + @get:Input public val tool: Property + + /** R8-specific minimization options. */ + @get:Nested public val r8Spec: R8Spec + + /** Use R8 to minimize the shadowed JAR and configure its options. */ + public fun r8(action: Action) +} From d6dcd8aa763462ec87f45b5e3a4e990329ff15e2 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Wed, 1 Jul 2026 16:13:14 -0400 Subject: [PATCH 03/14] Add R8 spec --- .../gradle/plugins/shadow/tasks/R8Spec.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/R8Spec.kt diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/R8Spec.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/R8Spec.kt new file mode 100644 index 000000000..080a3f663 --- /dev/null +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/R8Spec.kt @@ -0,0 +1,26 @@ +package com.github.jengelman.gradle.plugins.shadow.tasks + +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity + +/** Minimal R8 configuration for [ShadowJar.minimize]. */ +public interface R8Spec { + /** + * Additional R8 command line arguments. + * + * Defaults to `--no-minification`, so R8 shrinks without renaming classes. + */ + @get:Input public val args: ListProperty + + /** Additional ProGuard/R8 keep rules. */ + @get:Input public val keepRules: ListProperty + + /** Files containing additional ProGuard/R8 keep rules. */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + public val keepRuleFiles: ConfigurableFileCollection +} From 1aea92c15a3895b94af254bec18cc26b2a47c099 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Wed, 1 Jul 2026 16:14:28 -0400 Subject: [PATCH 04/14] Make DependenyFilter specs `@Internal` --- .../gradle/plugins/shadow/tasks/DependencyFilter.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/DependencyFilter.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/DependencyFilter.kt index 93291853a..8733353f7 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/DependencyFilter.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/DependencyFilter.kt @@ -10,6 +10,7 @@ import org.gradle.api.artifacts.ResolvedDependency import org.gradle.api.file.FileCollection import org.gradle.api.provider.Provider import org.gradle.api.specs.Spec +import org.gradle.api.tasks.Internal // DependencyFilter is used as Gradle Input in ShadowJar, so it must be Serializable. public interface DependencyFilter : Serializable { @@ -36,8 +37,13 @@ public interface DependencyFilter : Serializable { public abstract class AbstractDependencyFilter( @Transient private val project: Project, - @Transient protected val includeSpecs: MutableList> = mutableListOf(), - @Transient protected val excludeSpecs: MutableList> = mutableListOf(), + // @Internal because MinimizeSpec is a @Nested task input now + @get:Internal + @Transient + protected val includeSpecs: MutableList> = mutableListOf(), + @get:Internal + @Transient + protected val excludeSpecs: MutableList> = mutableListOf(), ) : DependencyFilter { protected abstract fun resolve( From fc3e6ab07f35224336f0446f622a19b76e9a6ff2 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Wed, 1 Jul 2026 16:42:43 -0400 Subject: [PATCH 05/14] Groovy --- .../jengelman/gradle/plugins/shadow/tasks/MinimizeSpec.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeSpec.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeSpec.kt index 9d20ab212..ec2faa46b 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeSpec.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeSpec.kt @@ -1,5 +1,6 @@ package com.github.jengelman.gradle.plugins.shadow.tasks +import groovy.lang.Closure import org.gradle.api.Action import org.gradle.api.provider.Property import org.gradle.api.tasks.Input @@ -19,4 +20,7 @@ public interface MinimizeSpec : DependencyFilter { /** Use R8 to minimize the shadowed JAR and configure its options. */ public fun r8(action: Action) + + /** Use R8 to minimize the shadowed JAR and configure its options. */ + public fun r8(action: Closure<*>) } From 5358a90a6cb9ae408a05d9db17ca6a3b01548545 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Wed, 1 Jul 2026 16:53:55 -0400 Subject: [PATCH 06/14] Add DefaultMinimizeSpec --- .../shadow/internal/DefaultMinimizeSpec.kt | 37 +++++++++++++++++++ .../internal/MinimizeDependencyFilter.kt | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultMinimizeSpec.kt diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultMinimizeSpec.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultMinimizeSpec.kt new file mode 100644 index 000000000..391738b94 --- /dev/null +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultMinimizeSpec.kt @@ -0,0 +1,37 @@ +package com.github.jengelman.gradle.plugins.shadow.internal + +import com.github.jengelman.gradle.plugins.shadow.tasks.MinimizeSpec +import com.github.jengelman.gradle.plugins.shadow.tasks.MinimizeTool +import com.github.jengelman.gradle.plugins.shadow.tasks.R8Spec +import groovy.lang.Closure +import org.gradle.api.Action +import org.gradle.api.Project +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property + +internal class DefaultMinimizeSpec(project: Project, objectFactory: ObjectFactory) : + MinimizeDependencyFilter(project), MinimizeSpec { + override val tool: Property = + objectFactory.property(MinimizeTool.DEPENDENCY_ANALYZER) + + override val r8Spec: R8Spec = DefaultR8Spec(objectFactory) + + override fun r8(action: Action) { + tool.set(MinimizeTool.R8) + action.execute(r8Spec) + } + + override fun r8(action: Closure<*>) { + tool.set(MinimizeTool.R8) + val previousDelegate = action.delegate + val previousResolveStrategy = action.resolveStrategy + try { + action.delegate = r8Spec + action.resolveStrategy = Closure.DELEGATE_FIRST + action.call(r8Spec) + } finally { + action.delegate = previousDelegate + action.resolveStrategy = previousResolveStrategy + } + } +} diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/MinimizeDependencyFilter.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/MinimizeDependencyFilter.kt index 75fdd5d46..e22a33bff 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/MinimizeDependencyFilter.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/MinimizeDependencyFilter.kt @@ -4,7 +4,7 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.DependencyFilter import org.gradle.api.Project import org.gradle.api.artifacts.ResolvedDependency -internal class MinimizeDependencyFilter(project: Project) : +internal open class MinimizeDependencyFilter(project: Project) : DependencyFilter.AbstractDependencyFilter(project) { override fun resolve( dependencies: Set, From 467b0c117ec455efd886ac13ef636a6b7837cbb8 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Wed, 1 Jul 2026 16:58:09 -0400 Subject: [PATCH 07/14] Make R8 inputs lazy --- .../plugins/shadow/internal/DefaultMinimizeSpec.kt | 11 ++++++++++- .../gradle/plugins/shadow/tasks/MinimizeSpec.kt | 4 ---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultMinimizeSpec.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultMinimizeSpec.kt index 391738b94..837f88966 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultMinimizeSpec.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultMinimizeSpec.kt @@ -8,13 +8,22 @@ import org.gradle.api.Action import org.gradle.api.Project import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property +import org.gradle.api.tasks.Nested +import org.gradle.api.tasks.Optional internal class DefaultMinimizeSpec(project: Project, objectFactory: ObjectFactory) : MinimizeDependencyFilter(project), MinimizeSpec { override val tool: Property = objectFactory.property(MinimizeTool.DEPENDENCY_ANALYZER) - override val r8Spec: R8Spec = DefaultR8Spec(objectFactory) + private val r8Spec: R8Spec by lazy { DefaultR8Spec(objectFactory) } + + @get:Nested + @get:Optional + val r8SpecForInputs: R8Spec? + get() = if (tool.orNull == MinimizeTool.R8) r8Spec else null + + internal fun r8Spec(): R8Spec = r8Spec override fun r8(action: Action) { tool.set(MinimizeTool.R8) diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeSpec.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeSpec.kt index ec2faa46b..cf83ec88a 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeSpec.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeSpec.kt @@ -4,7 +4,6 @@ import groovy.lang.Closure import org.gradle.api.Action import org.gradle.api.provider.Property import org.gradle.api.tasks.Input -import org.gradle.api.tasks.Nested /** Configures how [ShadowJar.minimize] removes unused code from the shadowed JAR. */ public interface MinimizeSpec : DependencyFilter { @@ -15,9 +14,6 @@ public interface MinimizeSpec : DependencyFilter { */ @get:Input public val tool: Property - /** R8-specific minimization options. */ - @get:Nested public val r8Spec: R8Spec - /** Use R8 to minimize the shadowed JAR and configure its options. */ public fun r8(action: Action) From 9335f426ec6b4302829af63ddb824232bd07a856 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Wed, 1 Jul 2026 16:59:54 -0400 Subject: [PATCH 08/14] Add impl --- .../plugins/shadow/internal/DefaultR8Spec.kt | 21 ++ .../plugins/shadow/internal/R8Minimizer.kt | 331 ++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultR8Spec.kt create mode 100644 src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/R8Minimizer.kt diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultR8Spec.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultR8Spec.kt new file mode 100644 index 000000000..6241699cd --- /dev/null +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultR8Spec.kt @@ -0,0 +1,21 @@ +package com.github.jengelman.gradle.plugins.shadow.internal + +import com.github.jengelman.gradle.plugins.shadow.tasks.R8Spec +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty + +internal class DefaultR8Spec(objectFactory: ObjectFactory) : R8Spec { + override val args: ListProperty = + objectFactory.listProperty(String::class.java).convention(DEFAULT_ARGS) + + override val keepRules: ListProperty = + objectFactory.listProperty(String::class.java).convention(emptyList()) + + override val keepRuleFiles: ConfigurableFileCollection = objectFactory.fileCollection() + + internal companion object { + internal const val NO_MINIFICATION_ARG = "--no-minification" + internal val DEFAULT_ARGS = listOf(NO_MINIFICATION_ARG) + } +} diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/R8Minimizer.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/R8Minimizer.kt new file mode 100644 index 000000000..56715e3df --- /dev/null +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/R8Minimizer.kt @@ -0,0 +1,331 @@ +package com.github.jengelman.gradle.plugins.shadow.internal + +import com.github.jengelman.gradle.plugins.shadow.relocation.Relocator +import com.github.jengelman.gradle.plugins.shadow.relocation.relocateClass +import com.github.jengelman.gradle.plugins.shadow.tasks.R8Spec +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.util.jar.JarFile +import org.apache.tools.zip.UnixStat +import org.apache.tools.zip.Zip64Mode +import org.apache.tools.zip.ZipOutputStream +import org.gradle.api.GradleException +import org.gradle.api.file.FileCollection +import org.gradle.api.logging.Logger +import org.gradle.api.tasks.bundling.ZipEntryCompression +import org.gradle.process.ExecOperations + +/** + * Runs R8 as a final-archive shrinker. + * + * Shadow first writes the complete jar with relocations, resource transformers, merged service + * files, and duplicate handling applied. R8 then processes that exact artifact. The result is + * normalized back through Shadow's archive settings because R8 does not know about this task's + * reproducibility options. + * + * Generated rules intentionally use final jar contents: source-set classes are kept as roots, + * dependencies excluded from minimization are kept, and entries under `META-INF/services` keep both + * service interfaces and providers for downstream `ServiceLoader` use. User rule files and inline + * rules are appended last. The default [R8Spec.args] are shrink-only: `--no-minification` also + * causes Shadow to generate `-dontoptimize`; callers that replace the default args own the rest of + * R8's behavior. + */ +internal class R8Minimizer( + private val execOperations: ExecOperations, + private val logger: Logger, + private val r8Classpath: FileCollection, + private val r8Spec: R8Spec, + private val sourceSetsClassesDirs: Iterable, + private val keptDependencyFiles: Iterable, + private val relocators: Iterable, + private val preserveFileTimestamps: Boolean, + private val reproducibleFileOrder: Boolean, + private val zip64: Boolean, + private val entryCompression: ZipEntryCompression, + private val metadataCharset: String?, +) { + fun minimize(inputJar: File, temporaryDir: File) { + if (r8Classpath.isEmpty) { + throw GradleException( + "R8 minimization requires a non-empty R8 classpath. Apply the Shadow plugin or configure the shadowR8 configuration." + ) + } + + val r8Dir = temporaryDir.resolve("r8").also { it.mkdirs() } + val rulesFile = r8Dir.resolve("rules.pro") + val r8Output = r8Dir.resolve("output.jar") + val normalizedOutput = r8Dir.resolve("normalized-output.jar") + val javaHome = System.getProperty("java.home") + if (javaHome.isNullOrBlank()) { + throw GradleException("R8 minimization requires the java.home system property.") + } + + val r8Args = r8Spec.args.get() + rulesFile.writeText(createRules(inputJar, r8Args).joinToString(System.lineSeparator())) + + val arguments = buildList { + add("--classfile") + add("--output") + add(r8Output.absolutePath) + add("--pg-conf") + add(rulesFile.absolutePath) + add("--lib") + add(javaHome) + addAll(r8Args) + add(inputJar.absolutePath) + } + + logger.info("Running R8 to minimize {}.", inputJar) + execOperations.javaexec { + it.classpath = r8Classpath + it.mainClass.set(R8_MAIN_CLASS) + it.args(arguments) + } + + normalizeJar(r8Output, normalizedOutput) + Files.move(normalizedOutput.toPath(), inputJar.toPath(), REPLACE_EXISTING) + } + + private fun createRules(inputJar: File, r8Args: List): List { + val rules = linkedSetOf() + if (DefaultR8Spec.NO_MINIFICATION_ARG in r8Args) { + rules += "-dontoptimize" + } + rules += sourceKeepRules(inputJar) + rules += keptDependencyRules(inputJar) + rules += serviceKeepRules(inputJar) + r8Spec.keepRuleFiles.files + .sortedBy { it.absolutePath } + .forEach { file -> + if (file.isFile) { + rules += file.readText().lineSequence().toList() + } + } + rules += r8Spec.keepRules.get() + return rules.toList() + } + + // Project classes are the public surface of the shadowed jar, even when nothing in the input jar + // refers to every class directly. + private fun sourceKeepRules(inputJar: File): List { + val jarClasses = jarClassEntries(inputJar) + return sourceSetsClassesDirs + .asSequence() + .filter(File::isDirectory) + .flatMap { dir -> + dir + .walkTopDown() + .filter { it.isFile && it.name.endsWith(".class") } + .mapNotNull { file -> + file.toClassName(relativeTo = dir) + } + } + .map { relocators.relocateClass(it) } + .filter { it.isJavaTypeName() } + .filter { className -> "${className.replace('.', '/')}.class" in jarClasses } + .distinct() + .sorted() + .map { "-keep,includedescriptorclasses class $it { *; }" } + .toList() + } + + // Keep dependencies users explicitly excluded from minimization, matching the existing + // minimize { exclude(...) } contract for the default analyzer. + private fun keptDependencyRules(inputJar: File): List { + val jarClasses = jarClassEntries(inputJar) + return keptDependencyFiles + .asSequence() + .flatMap { it.classNames() } + .map { relocators.relocateClass(it) } + .filter { it.isJavaTypeName() } + .filter { className -> "${className.replace('.', '/')}.class" in jarClasses } + .distinct() + .sorted() + .map { "-keep class $it { *; }" } + .toList() + } + + // Service descriptors are usage edges for downstream ServiceLoader calls, so keep the service + // interface and every listed provider even if R8 sees no direct references. + private fun serviceKeepRules(inputJar: File): List { + val rules = linkedSetOf() + JarFile(inputJar).use { jarFile -> + jarFile + .entries() + .asSequence() + .filter { !it.isDirectory && it.name.startsWith(SERVICES_PATH) } + .sortedBy { it.name } + .forEach { entry -> + val serviceClass = entry.name.removePrefix(SERVICES_PATH).replace('/', '.') + if (serviceClass.isJavaTypeName()) { + rules += "-keep class $serviceClass { *; }" + } + jarFile.getInputStream(entry).bufferedReader().useLines { lines -> + lines + .map { it.substringBefore('#').trim() } + .filter { it.isNotEmpty() && it.isJavaTypeName() } + .forEach { provider -> rules += "-keep class $provider { *; }" } + } + } + } + return rules.toList() + } + + private fun jarClassEntries(inputJar: File): Set { + return JarFile(inputJar).use { jarFile -> + jarFile + .entries() + .asSequence() + .filter { !it.isDirectory && it.name.endsWith(".class") } + .map { it.name } + .toSet() + } + } + + private fun File.toClassName(relativeTo: File): String? { + if (name == "module-info.class" || name == "package-info.class") return null + return relativeTo + .toPath() + .relativize(toPath()) + .toString() + .replace(File.separatorChar, '/') + .removeSuffix(".class") + .replace('/', '.') + } + + private fun File.classNames(): Sequence { + return when { + isDirectory -> + walkTopDown() + .filter { it.isFile && it.name.endsWith(".class") } + .mapNotNull { + it.toClassName(relativeTo = this) + } + isFile -> + JarFile(this) + .use { jarFile -> + jarFile + .entries() + .asSequence() + .filter { !it.isDirectory && it.name.endsWith(".class") } + .mapNotNull { it.name.toClassName() } + .toList() + } + .asSequence() + else -> emptySequence() + } + } + + private fun String.toClassName(): String? { + val name = substringAfterLast('/') + if (name == "module-info.class" || name == "package-info.class") return null + return removeSuffix(".class").replace('/', '.') + } + + // R8 writes a fresh jar, so rewrite it through Shadow's archive settings to preserve + // reproducible ordering, timestamps, compression, zip64, and metadata charset behavior. + private fun normalizeJar(inputJar: File, outputJar: File) { + val entries = + JarFile(inputJar).use { jarFile -> + jarFile + .entries() + .asSequence() + .filter { !it.isDirectory } + .map { entry -> + R8JarEntry( + name = entry.name, + time = entry.time, + bytes = jarFile.getInputStream(entry).use { it.readBytes() }, + ) + } + .toList() + } + val orderedEntries = if (reproducibleFileOrder) entries.sortedBy { it.name } else entries + val entryCompressionMethod = + when (entryCompression) { + ZipEntryCompression.DEFLATED -> ZipOutputStream.DEFLATED + ZipEntryCompression.STORED -> ZipOutputStream.STORED + } + val zipOutputStream = + if (entryCompressionMethod == ZipOutputStream.STORED) { + ZipOutputStream(outputJar) + } else { + ZipOutputStream(outputJar.outputStream().buffered()) + } + zipOutputStream.use { zos -> + if (metadataCharset != null) { + zos.setEncoding(metadataCharset) + } + zos.setUseZip64(if (zip64) Zip64Mode.AsNeeded else Zip64Mode.Never) + zos.setMethod(entryCompressionMethod) + val added = mutableSetOf() + + fun addParentDirs(name: String) { + val parent = name.substringBeforeLast('/', "") + if (parent.isEmpty()) return + addParentDirs(parent) + val entryName = "$parent/" + if (added.add(entryName)) { + zos.putNextEntry( + zipEntry(entryName, preserveFileTimestamps) { + unixMode = UnixStat.DIR_FLAG or DEFAULT_DIR_MODE + } + ) + zos.closeEntry() + } + } + + orderedEntries.forEach { entry -> + addParentDirs(entry.name) + if (added.add(entry.name)) { + zos.putNextEntry( + zipEntry(entry.name, preserveFileTimestamps, entry.time) { + unixMode = UnixStat.FILE_FLAG or DEFAULT_FILE_MODE + } + ) + zos.write(entry.bytes) + zos.closeEntry() + } + } + } + } + + private fun String.isJavaTypeName(): Boolean = javaTypeNameRegex.matches(this) + + private class R8JarEntry(val name: String, val time: Long, val bytes: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as R8JarEntry + + if (time != other.time) return false + if (name != other.name) return false + if (!bytes.contentEquals(other.bytes)) return false + + return true + } + + override fun hashCode(): Int { + var result = time.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + bytes.contentHashCode() + return result + } + + override fun toString(): String { + return "R8JarEntry(name='$name', time=$time, bytes=${bytes.toHexString()})" + } + } + + private companion object { + private const val R8_MAIN_CLASS = "com.android.tools.r8.R8" + private const val SERVICES_PATH = "META-INF/services/" + private const val DEFAULT_DIR_MODE = 493 // 0755 + private const val DEFAULT_FILE_MODE = 420 // 0644 + // Keep only ordinary dot-separated Java type names in generated rules. This filters out blank + // service lines, comments, malformed providers, and JVM-only names R8 would reject. + private val javaTypeNameRegex = Regex("[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*") + } +} From 326b944017bc13a922836ec0a225f7602a098e61 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Wed, 1 Jul 2026 17:00:19 -0400 Subject: [PATCH 09/14] Wiring --- .../gradle/plugins/shadow/ShadowBasePlugin.kt | 11 +++ .../gradle/plugins/shadow/tasks/ShadowJar.kt | 77 ++++++++++++++++--- 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowBasePlugin.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowBasePlugin.kt index 6c4f6fe8e..77e4cf1fe 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowBasePlugin.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowBasePlugin.kt @@ -24,6 +24,15 @@ public abstract class ShadowBasePlugin : Plugin { } @Suppress("EagerGradleConfiguration") // this should be created eagerly. configurations.create(CONFIGURATION_NAME) + @Suppress("EagerGradleConfiguration") // this should be created eagerly. + configurations.create(R8_CONFIGURATION_NAME) { + it.description = "R8 executable used by ShadowJar R8 minimization." + it.isCanBeConsumed = false + it.isCanBeResolved = true + it.defaultDependencies { dependencies -> + dependencies.add(project.dependencies.create(DEFAULT_R8_DEPENDENCY)) + } + } } public companion object { @@ -44,6 +53,8 @@ public abstract class ShadowBasePlugin : Plugin { public const val SHADOW: String = "shadow" public const val EXTENSION_NAME: String = SHADOW public const val CONFIGURATION_NAME: String = SHADOW + internal const val R8_CONFIGURATION_NAME: String = "shadowR8" + internal const val DEFAULT_R8_DEPENDENCY: String = "com.android.tools:r8:9.1.31" @get:JvmSynthetic public inline val ConfigurationContainer.shadow: NamedDomainObjectProvider diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt index 956d679e4..c238df4f3 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt @@ -1,10 +1,12 @@ package com.github.jengelman.gradle.plugins.shadow.tasks import com.github.jengelman.gradle.plugins.shadow.ShadowBasePlugin +import com.github.jengelman.gradle.plugins.shadow.ShadowBasePlugin.Companion.R8_CONFIGURATION_NAME import com.github.jengelman.gradle.plugins.shadow.ShadowBasePlugin.Companion.shadow import com.github.jengelman.gradle.plugins.shadow.internal.DefaultDependencyFilter import com.github.jengelman.gradle.plugins.shadow.internal.DefaultInheritManifest -import com.github.jengelman.gradle.plugins.shadow.internal.MinimizeDependencyFilter +import com.github.jengelman.gradle.plugins.shadow.internal.DefaultMinimizeSpec +import com.github.jengelman.gradle.plugins.shadow.internal.R8Minimizer import com.github.jengelman.gradle.plugins.shadow.internal.UnusedTracker import com.github.jengelman.gradle.plugins.shadow.internal.classPathAttributeKey import com.github.jengelman.gradle.plugins.shadow.internal.fileCollection @@ -62,10 +64,16 @@ import org.gradle.api.tasks.bundling.Jar import org.gradle.api.tasks.bundling.ZipEntryCompression import org.gradle.api.tasks.options.Option import org.gradle.language.base.plugins.LifecycleBasePlugin +import org.gradle.process.ExecOperations @CacheableTask public abstract class ShadowJar : Jar() { - private val dependencyFilterForMinimize = MinimizeDependencyFilter(project) + private val defaultMinimizeSpec = DefaultMinimizeSpec(project, objectFactory) + + /** Options for [minimize]. */ + @get:Nested + public open val minimizeSpec: MinimizeSpec = defaultMinimizeSpec + private val shadowDependencies = project.provider { // Find shadow configuration here instead of get, as the ShadowJar tasks could be registered // without Shadow plugin applied. @@ -100,14 +108,28 @@ public abstract class ShadowJar : Jar() { @get:Classpath public open val toMinimize: ConfigurableFileCollection = objectFactory.fileCollection { - minimizeJar.map { - if (it) (dependencyFilterForMinimize.resolve(configurations.get()) - apiJars) else emptySet() + minimizeJar.zip(minimizeSpec.tool) { enabled, tool -> + if (!enabled) { + return@zip emptySet() + } + when (tool) { + MinimizeTool.DEPENDENCY_ANALYZER, + MinimizeTool.R8 -> minimizeSpec.resolve(configurations.get()) - apiJars + } } } @get:Classpath public open val apiJars: ConfigurableFileCollection = objectFactory.fileCollection { - minimizeJar.map { if (it) project.getApiJars() else emptySet() } + minimizeJar.zip(minimizeSpec.tool) { enabled, tool -> + if (!enabled) { + return@zip project.provider { emptyList() } + } + when (tool) { + MinimizeTool.DEPENDENCY_ANALYZER, + MinimizeTool.R8 -> project.getApiJars() + } + } } @get:InputFiles @@ -124,6 +146,17 @@ public abstract class ShadowJar : Jar() { } } + @get:Classpath + public open val r8Classpath: ConfigurableFileCollection = objectFactory.fileCollection { + minimizeJar.zip(minimizeSpec.tool) { enabled, tool -> + if (enabled && tool == MinimizeTool.R8) { + project.configurations.findByName(R8_CONFIGURATION_NAME) ?: project.files() + } else { + emptySet() + } + } + } + /** [ResourceTransformer]s to be applied in the shadow steps. */ @get:Nested public open val transformers: SetProperty = objectFactory.setProperty() @@ -284,11 +317,13 @@ public abstract class ShadowJar : Jar() { @get:Inject protected abstract val archiveOperations: ArchiveOperations - /** Enable [minimizeJar] and execute the [action] with the [DependencyFilter] for minimize. */ + @get:Inject protected abstract val execOperations: ExecOperations + + /** Enable [minimizeJar] and execute the [action] with the [MinimizeSpec] for minimize. */ @JvmOverloads - public open fun minimize(action: Action = Action {}) { + public open fun minimize(action: Action = Action {}) { minimizeJar.set(true) - action.execute(dependencyFilterForMinimize) + action.execute(minimizeSpec) } /** Extra dependency operations to be applied in the shadow steps. */ @@ -451,6 +486,13 @@ public abstract class ShadowJar : Jar() { @TaskAction override fun copy() { + val useR8 = minimizeJar.get() && minimizeSpec.tool.get() == MinimizeTool.R8 + val keptDependencyFilesForR8 = + if (useR8) { + includedDependencies.files - toMinimize.files + } else { + emptySet() + } includedDependencies.files.forEach { file -> when { !file.exists() -> { @@ -476,6 +518,23 @@ public abstract class ShadowJar : Jar() { } injectManifestAttributes() super.copy() + if (useR8) { + R8Minimizer( + execOperations = execOperations, + logger = logger, + r8Classpath = r8Classpath, + r8Spec = defaultMinimizeSpec.r8Spec(), + sourceSetsClassesDirs = sourceSetsClassesDirs.files, + keptDependencyFiles = keptDependencyFilesForR8, + relocators = relocators.get() + packageRelocators, + preserveFileTimestamps = isPreserveFileTimestamps, + reproducibleFileOrder = isReproducibleFileOrder, + zip64 = isZip64, + entryCompression = entryCompression, + metadataCharset = metadataCharset, + ) + .minimize(archiveFile.get().asFile, temporaryDir) + } } @Suppress("InternalGradleApiUsage") // For creating ShadowCopyAction. @@ -505,7 +564,7 @@ public abstract class ShadowJar : Jar() { } } val unusedClasses = - if (minimizeJar.get()) { + if (minimizeJar.get() && minimizeSpec.tool.get() == MinimizeTool.DEPENDENCY_ANALYZER) { val unusedTracker = UnusedTracker( sourceSetsClassesDirs = sourceSetsClassesDirs.files, From c5e882fb5ec77f5925d93e220de417e03b898b1a Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Wed, 1 Jul 2026 17:00:28 -0400 Subject: [PATCH 10/14] Tests --- .../gradle/plugins/shadow/CachingTest.kt | 119 +++++++++ .../gradle/plugins/shadow/MinimizeTest.kt | 251 ++++++++++++++++++ 2 files changed, 370 insertions(+) diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/CachingTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/CachingTest.kt index 06818ef0f..ae5c2616c 100644 --- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/CachingTest.kt +++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/CachingTest.kt @@ -7,6 +7,8 @@ import assertk.assertions.isEqualTo import com.github.jengelman.gradle.plugins.shadow.internal.MinimizeDependencyFilter import com.github.jengelman.gradle.plugins.shadow.internal.mainClassAttributeKey import com.github.jengelman.gradle.plugins.shadow.testkit.JarPath +import com.github.jengelman.gradle.plugins.shadow.testkit.containsAtLeast +import com.github.jengelman.gradle.plugins.shadow.testkit.containsNone import com.github.jengelman.gradle.plugins.shadow.testkit.containsOnly import com.github.jengelman.gradle.plugins.shadow.testkit.getMainAttr import com.github.jengelman.gradle.plugins.shadow.transformers.ResourceTransformer @@ -340,6 +342,40 @@ class CachingTest : BasePluginTest() { } } + @Test + fun r8KeepRuleFileChanged() { + val previousTaskPath = taskPath + taskPath = serverShadowJarPath + try { + writeR8Repository() + writeR8ClientAndServerModules() + val keepRules = path("server/r8-rules.pro") + keepRules.writeText("") + + assertExecutionSuccess() + assertThat(outputServerShadowedJar).useAll { + containsAtLeast("server/Server.class", "client/Used.class", *manifestEntries) + containsNone("client/Reflective.class") + } + + keepRules.writeText("-keep class client.Reflective { *; }") + + assertExecutionSuccess() + assertThat(outputServerShadowedJar).useAll { + containsAtLeast( + "server/Server.class", + "client/Used.class", + "client/Reflective.class", + *manifestEntries, + ) + containsNone("client/Unused.class") + } + assertExecutionsFromCacheAndUpToDate() + } finally { + taskPath = previousTaskPath + } + } + @Test fun relocatorChanged() { projectScript.appendText( @@ -513,4 +549,87 @@ class CachingTest : BasePluginTest() { val result = runWithSuccess(taskPath) assertThat(result).taskOutcomeEquals(taskPath, expectedOutcome) } + + private fun writeR8Repository() { + settingsScript.writeText( + settingsScript.readText().replace("mavenCentral()", "mavenCentral()\n google()") + ) + } + + private fun writeR8ClientAndServerModules() { + settingsScript.appendText( + """ + include 'client', 'server' + """ + .trimIndent() + ) + projectScript.writeText("") + + path("client/src/main/java/client/Used.java") + .writeText( + """ + package client; + public class Used { + public static String name() { + return "used"; + } + } + """ + .trimIndent() + ) + path("client/src/main/java/client/Unused.java") + .writeText( + """ + package client; + public class Unused {} + """ + .trimIndent() + ) + path("client/src/main/java/client/Reflective.java") + .writeText( + """ + package client; + public class Reflective {} + """ + .trimIndent() + ) + path("client/build.gradle") + .writeText( + """ + ${getDefaultProjectBuildScript("java")} + """ + .trimIndent() + lineSeparator + ) + + path("server/src/main/java/server/Server.java") + .writeText( + """ + package server; + import client.Used; + public class Server { + public String name() { + return Used.name(); + } + } + """ + .trimIndent() + ) + path("server/build.gradle") + .writeText( + """ + ${getDefaultProjectBuildScript("java")} + dependencies { + implementation project(':client') + } + $shadowJarTask { + minimize { + r8 { + keepRuleFiles.from(file("r8-rules.pro")) + } + } + } + """ + .trimIndent() + lineSeparator + ) + } } diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/MinimizeTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/MinimizeTest.kt index aa50d6e82..8e1c7f558 100644 --- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/MinimizeTest.kt +++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/MinimizeTest.kt @@ -1,13 +1,19 @@ package com.github.jengelman.gradle.plugins.shadow import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isTrue import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar.Companion.SHADOW_JAR_TASK_NAME import com.github.jengelman.gradle.plugins.shadow.testkit.JarPath import com.github.jengelman.gradle.plugins.shadow.testkit.containsAtLeast import com.github.jengelman.gradle.plugins.shadow.testkit.containsNone import com.github.jengelman.gradle.plugins.shadow.testkit.containsOnly +import com.github.jengelman.gradle.plugins.shadow.testkit.getContent import com.github.jengelman.gradle.plugins.shadow.util.Issue +import java.net.URLClassLoader +import java.util.ServiceLoader import kotlin.io.path.appendText +import kotlin.io.path.readText import kotlin.io.path.writeText import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest @@ -296,6 +302,106 @@ class MinimizeTest : BasePluginTest() { } } + @Test + fun minimizeWithR8ShrinksUnusedDependencyClasses() { + writeR8Repository() + writeR8ClientAndServerModules( + serverShadowBlock = + """ + minimize { + r8 {} + } + """ + .trimIndent() + ) + + runWithSuccess(serverShadowJarPath) + + assertThat(outputServerShadowedJar).useAll { + containsAtLeast("server/Server.class", "client/Used.class", *manifestEntries) + containsNone("client/Unused.class") + } + } + + @Test + fun minimizeWithR8KeepsServiceProviders() { + writeR8Repository() + writeR8ServiceModules() + + runWithSuccess(serverShadowJarPath) + + assertThat(outputServerShadowedJar).useAll { + containsAtLeast( + "server/Server.class", + "service/Greeter.class", + "service/DefaultGreeter.class", + "META-INF/services/service.Greeter", + *manifestEntries, + ) + getContent("META-INF/services/service.Greeter").isEqualTo("service.DefaultGreeter\n") + } + val shadowJarUrl = outputServerShadowedJar.use { it.path.toUri().toURL() } + URLClassLoader(arrayOf(shadowJarUrl), null).use { loader -> + val serviceClass = loader.loadClass("service.Greeter") + assertThat(ServiceLoader.load(serviceClass, loader).iterator().hasNext()).isTrue() + } + } + + @Test + fun minimizeWithR8HonorsCustomKeepRules() { + writeR8Repository() + writeR8ClientAndServerModules( + serverShadowBlock = + """ + minimize { + r8 { + keepRules.add("-keep class client.Reflective { *; }") + } + } + """ + .trimIndent() + ) + + runWithSuccess(serverShadowJarPath) + + assertThat(outputServerShadowedJar).useAll { + containsAtLeast( + "server/Server.class", + "client/Used.class", + "client/Reflective.class", + *manifestEntries, + ) + containsNone("client/Unused.class") + } + } + + @Test + fun minimizeWithR8HonorsDependencyExcludes() { + writeR8Repository() + writeR8ClientAndServerModules( + serverShadowBlock = + """ + minimize { + exclude(project(':client')) + r8 {} + } + """ + .trimIndent() + ) + + runWithSuccess(serverShadowJarPath) + + assertThat(outputServerShadowedJar).useAll { + containsAtLeast( + "server/Server.class", + "client/Used.class", + "client/Unused.class", + "client/Reflective.class", + *manifestEntries, + ) + } + } + private fun writeApiLibAndImplModules() { settingsScript.appendText( """ @@ -385,4 +491,149 @@ class MinimizeTest : BasePluginTest() { .trimIndent() + lineSeparator ) } + + private fun writeR8Repository() { + settingsScript.writeText( + settingsScript.readText().replace("mavenCentral()", "mavenCentral()\n google()") + ) + } + + private fun writeR8ClientAndServerModules(serverShadowBlock: String) { + settingsScript.appendText( + """ + include 'client', 'server' + """ + .trimIndent() + ) + projectScript.writeText("") + + path("client/src/main/java/client/Used.java") + .writeText( + """ + package client; + public class Used { + public static String name() { + return "used"; + } + } + """ + .trimIndent() + ) + path("client/src/main/java/client/Unused.java") + .writeText( + """ + package client; + public class Unused {} + """ + .trimIndent() + ) + path("client/src/main/java/client/Reflective.java") + .writeText( + """ + package client; + public class Reflective {} + """ + .trimIndent() + ) + path("client/build.gradle") + .writeText( + """ + ${getDefaultProjectBuildScript("java")} + """ + .trimIndent() + lineSeparator + ) + + path("server/src/main/java/server/Server.java") + .writeText( + """ + package server; + import client.Used; + public class Server { + public String name() { + return Used.name(); + } + } + """ + .trimIndent() + ) + path("server/build.gradle") + .writeText( + """ + ${getDefaultProjectBuildScript("java")} + dependencies { + implementation project(':client') + } + $shadowJarTask { + $serverShadowBlock + } + """ + .trimIndent() + lineSeparator + ) + } + + private fun writeR8ServiceModules() { + settingsScript.appendText( + """ + include 'service', 'server' + """ + .trimIndent() + ) + projectScript.writeText("") + + path("service/src/main/java/service/Greeter.java") + .writeText( + """ + package service; + public interface Greeter { + String greet(); + } + """ + .trimIndent() + ) + path("service/src/main/java/service/DefaultGreeter.java") + .writeText( + """ + package service; + public class DefaultGreeter implements Greeter { + public String greet() { + return "hello"; + } + } + """ + .trimIndent() + ) + path("service/src/main/resources/META-INF/services/service.Greeter") + .writeText("service.DefaultGreeter") + path("service/build.gradle") + .writeText( + """ + ${getDefaultProjectBuildScript("java")} + """ + .trimIndent() + lineSeparator + ) + + path("server/src/main/java/server/Server.java") + .writeText( + """ + package server; + public class Server {} + """ + .trimIndent() + ) + path("server/build.gradle") + .writeText( + """ + ${getDefaultProjectBuildScript("java")} + dependencies { + implementation project(':service') + } + $shadowJarTask { + minimize { + r8 {} + } + } + """ + .trimIndent() + lineSeparator + ) + } } From 32871e4f3b8e6735769eafab4a79b18ecde58684 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Wed, 1 Jul 2026 17:00:58 -0400 Subject: [PATCH 11/14] Docs --- docs/changes/README.md | 1 + docs/configuration/minimizing/README.md | 106 ++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/docs/changes/README.md b/docs/changes/README.md index ee5e54667..6c18e98ae 100644 --- a/docs/changes/README.md +++ b/docs/changes/README.md @@ -12,6 +12,7 @@ - Expose `patternSet` of `Log4j2PluginsCacheFileTransformer` as `public`. ([#2028](https://github.com/GradleUp/shadow/pull/2028)) - Expose `patternSet` of `ManifestAppenderTransformer` as `public`. ([#2028](https://github.com/GradleUp/shadow/pull/2028)) - Expose `patternSet` of `ManifestResourceTransformer` as `public`. ([#2028](https://github.com/GradleUp/shadow/pull/2028)) +- Add R8 as an opt-in `minimize { r8 { ... } }` tool for shrinking the final shadowed JAR. ### Changed diff --git a/docs/configuration/minimizing/README.md b/docs/configuration/minimizing/README.md index 91ab66b5d..4528fbad7 100644 --- a/docs/configuration/minimizing/README.md +++ b/docs/configuration/minimizing/README.md @@ -72,6 +72,112 @@ Similar to [`ShadowJar.dependencies`][ShadowJar.dependencies], projects can also > When excluding a `project`, all dependencies of the excluded `project` are automatically excluded from > minimization as well. +## Minimizing with R8 + +Shadow can also run [R8](https://r8.googlesource.com/r8) over the final shadowed JAR. This is useful when you want +whole-program shrinking instead of the default dependency analyzer. R8 runs after Shadow has merged, transformed, and +relocated the JAR, so service descriptors in `META-INF/services` are used to keep service providers. + +The default R8 configuration only shrinks unused code. It disables name minification and optimization. + +=== "Kotlin" + + ```kotlin + repositories { + google() + } + + tasks.shadowJar { + minimize { + r8 { + // Optional extra configuration + keepRules.add("-keep class com.example.ReflectiveApi { *; }") + keepRuleFiles.from(layout.projectDirectory.file("r8-rules.pro")) + } + } + } + ``` + +=== "Groovy" + + ```groovy + repositories { + google() + } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + minimize { + r8 { + // Optional extra configuration + keepRules.add('-keep class com.example.ReflectiveApi { *; }') + keepRuleFiles.from(layout.projectDirectory.file('r8-rules.pro')) + } + } + } + ``` + +Shadow resolves R8 from the `shadowR8` configuration. The default dependency is `com.android.tools:r8`, which is +published by Google Maven rather than Maven Central. Add `google()` to your repositories or override the dependency: + +=== "Kotlin" + + ```kotlin + dependencies { + shadowR8("com.android.tools:r8:9.1.31") + } + ``` + +=== "Groovy" + + ```groovy + dependencies { + shadowR8 'com.android.tools:r8:9.1.31' + } + ``` + +Advanced R8 command line arguments can be added with `args`. Replacing the default `args` value removes Shadow's +shrink-only defaults, including the generated `-dontoptimize` rule. + +=== "Kotlin" + + ```kotlin + repositories { + google() + } + + tasks.shadowJar { + minimize { + r8 { + // Appends to Shadow's defaults. + args.addAll(listOf("--map-diagnostics", "warning", "info")) + + // Replaces Shadow's defaults. + // args.set(emptyList()) + } + } + } + ``` + +=== "Groovy" + + ```groovy + repositories { + google() + } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + minimize { + r8 { + // Appends to Shadow's defaults. + args.addAll(['--map-diagnostics', 'warning', 'info']) + + // Replaces Shadow's defaults. + // args.set([]) + } + } + } + ``` + [ShadowJar.dependencies]: ../../api/shadow/com.github.jengelman.gradle.plugins.shadow.tasks/-shadow-jar/dependencies.html From c1cc947bb96aba81e25d2e18ab522a4820ac0e87 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Wed, 1 Jul 2026 17:01:03 -0400 Subject: [PATCH 12/14] API --- api/shadow.api | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/api/shadow.api b/api/shadow.api index 4a67b24a3..6609eb4c2 100644 --- a/api/shadow.api +++ b/api/shadow.api @@ -202,6 +202,26 @@ public abstract interface class com/github/jengelman/gradle/plugins/shadow/tasks public abstract fun inheritFrom ([Ljava/lang/Object;Lorg/gradle/api/Action;)V } +public abstract interface class com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeSpec : com/github/jengelman/gradle/plugins/shadow/tasks/DependencyFilter { + public abstract fun getTool ()Lorg/gradle/api/provider/Property; + public abstract fun r8 (Lgroovy/lang/Closure;)V + public abstract fun r8 (Lorg/gradle/api/Action;)V +} + +public final class com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeTool : java/lang/Enum { + public static final field DEPENDENCY_ANALYZER Lcom/github/jengelman/gradle/plugins/shadow/tasks/MinimizeTool; + public static final field R8 Lcom/github/jengelman/gradle/plugins/shadow/tasks/MinimizeTool; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/github/jengelman/gradle/plugins/shadow/tasks/MinimizeTool; + public static fun values ()[Lcom/github/jengelman/gradle/plugins/shadow/tasks/MinimizeTool; +} + +public abstract interface class com/github/jengelman/gradle/plugins/shadow/tasks/R8Spec { + public abstract fun getArgs ()Lorg/gradle/api/provider/ListProperty; + public abstract fun getKeepRuleFiles ()Lorg/gradle/api/file/ConfigurableFileCollection; + public abstract fun getKeepRules ()Lorg/gradle/api/provider/ListProperty; +} + public class com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction : org/gradle/api/internal/file/copy/CopyAction { public static final field CONSTANT_TIME_FOR_ZIP_ENTRIES J public static final field Companion Lcom/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction$Companion; @@ -232,6 +252,7 @@ public abstract class com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar public fun getEnableAutoRelocation ()Lorg/gradle/api/provider/Property; public fun getEnableKotlinModuleRemapping ()Lorg/gradle/api/provider/Property; public fun getExcludes ()Ljava/util/Set; + protected abstract fun getExecOperations ()Lorg/gradle/process/ExecOperations; public fun getFailOnDuplicateEntries ()Lorg/gradle/api/provider/Property; public fun getIncludedDependencies ()Lorg/gradle/api/file/ConfigurableFileCollection; public fun getIncludes ()Ljava/util/Set; @@ -239,6 +260,8 @@ public abstract class com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar public fun getManifest ()Lcom/github/jengelman/gradle/plugins/shadow/tasks/InheritManifest; public synthetic fun getManifest ()Lorg/gradle/api/java/archives/Manifest; public fun getMinimizeJar ()Lorg/gradle/api/provider/Property; + public fun getMinimizeSpec ()Lcom/github/jengelman/gradle/plugins/shadow/tasks/MinimizeSpec; + public fun getR8Classpath ()Lorg/gradle/api/file/ConfigurableFileCollection; public fun getRelocationPrefix ()Lorg/gradle/api/provider/Property; public fun getRelocators ()Lorg/gradle/api/provider/SetProperty; public fun getSourceSetsClassesDirs ()Lorg/gradle/api/file/ConfigurableFileCollection; From c20b78482330fa0ab3a991ec4751cd6bc6ab3df2 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Wed, 1 Jul 2026 17:34:32 -0400 Subject: [PATCH 13/14] Add controls for optimization and obfuscation --- api/shadow.api | 2 + docs/configuration/minimizing/README.md | 85 +++++++++++++++---- .../gradle/plugins/shadow/MinimizeTest.kt | 46 ++++++++++ .../shadow/internal/DefaultMinimizeSpec.kt | 4 +- .../plugins/shadow/internal/DefaultR8Spec.kt | 25 +++++- .../plugins/shadow/internal/R8Minimizer.kt | 34 +++++--- .../gradle/plugins/shadow/tasks/R8Spec.kt | 16 ++++ .../gradle/plugins/shadow/tasks/ShadowJar.kt | 3 +- 8 files changed, 181 insertions(+), 34 deletions(-) diff --git a/api/shadow.api b/api/shadow.api index 6609eb4c2..a44bb5f02 100644 --- a/api/shadow.api +++ b/api/shadow.api @@ -217,6 +217,8 @@ public final class com/github/jengelman/gradle/plugins/shadow/tasks/MinimizeTool } public abstract interface class com/github/jengelman/gradle/plugins/shadow/tasks/R8Spec { + public abstract fun enableObfuscation ()V + public abstract fun enableOptimization ()V public abstract fun getArgs ()Lorg/gradle/api/provider/ListProperty; public abstract fun getKeepRuleFiles ()Lorg/gradle/api/file/ConfigurableFileCollection; public abstract fun getKeepRules ()Lorg/gradle/api/provider/ListProperty; diff --git a/docs/configuration/minimizing/README.md b/docs/configuration/minimizing/README.md index 4528fbad7..2f83e1b5e 100644 --- a/docs/configuration/minimizing/README.md +++ b/docs/configuration/minimizing/README.md @@ -136,23 +136,19 @@ published by Google Maven rather than Maven Central. Add `google()` to your repo ``` Advanced R8 command line arguments can be added with `args`. Replacing the default `args` value removes Shadow's -shrink-only defaults, including the generated `-dontoptimize` rule. +default command line arguments, so prefer the helper functions for common obfuscation and optimization toggles. These +helpers are independent and can be used together. + +For example, to downgrade R8 warnings to info: === "Kotlin" ```kotlin repositories { - google() - } - tasks.shadowJar { minimize { r8 { - // Appends to Shadow's defaults. args.addAll(listOf("--map-diagnostics", "warning", "info")) - - // Replaces Shadow's defaults. - // args.set(emptyList()) } } } @@ -162,22 +158,81 @@ shrink-only defaults, including the generated `-dontoptimize` rule. ```groovy repositories { - google() - } - tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { minimize { r8 { - // Appends to Shadow's defaults. args.addAll(['--map-diagnostics', 'warning', 'info']) - - // Replaces Shadow's defaults. - // args.set([]) } } } ``` +To enable name obfuscation: + +=== "Kotlin" + + ```kotlin + minimize { + r8 { + enableObfuscation() + } + } + ``` + +=== "Groovy" + + ```groovy + minimize { + r8 { + enableObfuscation() + } + } + ``` + +To enable optimization: + +=== "Kotlin" + + ```kotlin + minimize { + r8 { + enableOptimization() + } + } + ``` + +=== "Groovy" + + ```groovy + minimize { + r8 { + enableOptimization() + } + } + ``` + +To enable both: +=== "Kotlin" + + ```kotlin + minimize { + r8 { + enableObfuscation() + enableOptimization() + } + } + ``` + +=== "Groovy" + + ```groovy + minimize { + r8 { + enableObfuscation() + enableOptimization() + } + } + ``` [ShadowJar.dependencies]: ../../api/shadow/com.github.jengelman.gradle.plugins.shadow.tasks/-shadow-jar/dependencies.html diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/MinimizeTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/MinimizeTest.kt index 8e1c7f558..624124e3d 100644 --- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/MinimizeTest.kt +++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/MinimizeTest.kt @@ -375,6 +375,52 @@ class MinimizeTest : BasePluginTest() { } } + @Test + fun minimizeWithR8CanEnableObfuscation() { + writeR8Repository() + writeR8ClientAndServerModules( + serverShadowBlock = + """ + minimize { + r8 { + enableObfuscation() + } + } + """ + .trimIndent() + ) + + runWithSuccess(serverShadowJarPath) + + assertThat(outputServerShadowedJar).useAll { + containsAtLeast("server/Server.class", *manifestEntries) + containsNone("client/Used.class", "client/Unused.class") + } + } + + @Test + fun minimizeWithR8CanEnableOptimization() { + writeR8Repository() + writeR8ClientAndServerModules( + serverShadowBlock = + """ + minimize { + r8 { + enableOptimization() + } + } + """ + .trimIndent() + ) + + runWithSuccess(serverShadowJarPath) + + assertThat(outputServerShadowedJar).useAll { + containsAtLeast("server/Server.class", *manifestEntries) + containsNone("client/Used.class", "client/Unused.class") + } + } + @Test fun minimizeWithR8HonorsDependencyExcludes() { writeR8Repository() diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultMinimizeSpec.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultMinimizeSpec.kt index 837f88966..111e86398 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultMinimizeSpec.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultMinimizeSpec.kt @@ -16,14 +16,14 @@ internal class DefaultMinimizeSpec(project: Project, objectFactory: ObjectFactor override val tool: Property = objectFactory.property(MinimizeTool.DEPENDENCY_ANALYZER) - private val r8Spec: R8Spec by lazy { DefaultR8Spec(objectFactory) } + private val r8Spec: DefaultR8Spec by lazy { DefaultR8Spec(objectFactory) } @get:Nested @get:Optional val r8SpecForInputs: R8Spec? get() = if (tool.orNull == MinimizeTool.R8) r8Spec else null - internal fun r8Spec(): R8Spec = r8Spec + internal fun r8Spec(): DefaultR8Spec = r8Spec override fun r8(action: Action) { tool.set(MinimizeTool.R8) diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultR8Spec.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultR8Spec.kt index 6241699cd..cbceedef2 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultR8Spec.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultR8Spec.kt @@ -4,18 +4,41 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.R8Spec import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input internal class DefaultR8Spec(objectFactory: ObjectFactory) : R8Spec { - override val args: ListProperty = + private val defaultArgs: ListProperty = objectFactory.listProperty(String::class.java).convention(DEFAULT_ARGS) + @get:Input + val obfuscationEnabled: Property = + objectFactory.property(Boolean::class.java).convention(false) + + @get:Input + val optimizationEnabled: Property = + objectFactory.property(Boolean::class.java).convention(false) + + override val args: ListProperty = + objectFactory.listProperty(String::class.java).convention(defaultArgs) + override val keepRules: ListProperty = objectFactory.listProperty(String::class.java).convention(emptyList()) override val keepRuleFiles: ConfigurableFileCollection = objectFactory.fileCollection() + override fun enableObfuscation() { + defaultArgs.set(emptyList()) + obfuscationEnabled.set(true) + } + + override fun enableOptimization() { + optimizationEnabled.set(true) + } + internal companion object { internal const val NO_MINIFICATION_ARG = "--no-minification" + internal const val DONT_OPTIMIZE_RULE = "-dontoptimize" internal val DEFAULT_ARGS = listOf(NO_MINIFICATION_ARG) } } diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/R8Minimizer.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/R8Minimizer.kt index 56715e3df..23c397979 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/R8Minimizer.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/R8Minimizer.kt @@ -2,7 +2,6 @@ package com.github.jengelman.gradle.plugins.shadow.internal import com.github.jengelman.gradle.plugins.shadow.relocation.Relocator import com.github.jengelman.gradle.plugins.shadow.relocation.relocateClass -import com.github.jengelman.gradle.plugins.shadow.tasks.R8Spec import java.io.File import java.nio.file.Files import java.nio.file.StandardCopyOption.REPLACE_EXISTING @@ -19,23 +18,24 @@ import org.gradle.process.ExecOperations /** * Runs R8 as a final-archive shrinker. * - * Shadow first writes the complete jar with relocations, resource transformers, merged service - * files, and duplicate handling applied. R8 then processes that exact artifact. The result is - * normalized back through Shadow's archive settings because R8 does not know about this task's - * reproducibility options. + * Shadow first writes the complete jar, including relocations, resource transformers, merged + * service files, and duplicate handling. R8 then processes that exact artifact. * - * Generated rules intentionally use final jar contents: source-set classes are kept as roots, - * dependencies excluded from minimization are kept, and entries under `META-INF/services` keep both - * service interfaces and providers for downstream `ServiceLoader` use. User rule files and inline - * rules are appended last. The default [R8Spec.args] are shrink-only: `--no-minification` also - * causes Shadow to generate `-dontoptimize`; callers that replace the default args own the rest of - * R8's behavior. + * R8 does not know about Shadow's reproducible archive settings, so its output is normalized before + * replacing the original jar. + * + * Generated rules are based on the final jar contents. Source-set classes are kept as roots, + * dependencies excluded from minimization are kept, and service descriptors keep providers for + * downstream `ServiceLoader` users. User rule files and inline rules are appended last. + * + * The default R8 configuration is shrink-only. Shadow passes `--no-minification` to disable name + * obfuscation and generates `-dontoptimize` unless optimization is enabled explicitly. */ internal class R8Minimizer( private val execOperations: ExecOperations, private val logger: Logger, private val r8Classpath: FileCollection, - private val r8Spec: R8Spec, + private val r8Spec: DefaultR8Spec, private val sourceSetsClassesDirs: Iterable, private val keptDependencyFiles: Iterable, private val relocators: Iterable, @@ -89,8 +89,8 @@ internal class R8Minimizer( private fun createRules(inputJar: File, r8Args: List): List { val rules = linkedSetOf() - if (DefaultR8Spec.NO_MINIFICATION_ARG in r8Args) { - rules += "-dontoptimize" + if (shouldDisableOptimization(r8Args)) { + rules += DefaultR8Spec.DONT_OPTIMIZE_RULE } rules += sourceKeepRules(inputJar) rules += keptDependencyRules(inputJar) @@ -106,6 +106,11 @@ internal class R8Minimizer( return rules.toList() } + private fun shouldDisableOptimization(r8Args: List): Boolean { + if (r8Spec.optimizationEnabled.get()) return false + return r8Spec.obfuscationEnabled.get() || DefaultR8Spec.NO_MINIFICATION_ARG in r8Args + } + // Project classes are the public surface of the shadowed jar, even when nothing in the input jar // refers to every class directly. private fun sourceKeepRules(inputJar: File): List { @@ -293,6 +298,7 @@ internal class R8Minimizer( private fun String.isJavaTypeName(): Boolean = javaTypeNameRegex.matches(this) + // Not a data class because of the bytearray private class R8JarEntry(val name: String, val time: Long, val bytes: ByteArray) { override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/R8Spec.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/R8Spec.kt index 080a3f663..d6d36dd92 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/R8Spec.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/R8Spec.kt @@ -23,4 +23,20 @@ public interface R8Spec { @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) public val keepRuleFiles: ConfigurableFileCollection + + /** + * Enable R8 name obfuscation while keeping Shadow's default no-optimization behavior. + * + * This removes Shadow's default `--no-minification` argument. Optimization remains disabled + * unless [enableOptimization] is also called. + */ + public fun enableObfuscation() + + /** + * Enable R8 optimization while keeping Shadow's default no-obfuscation behavior. + * + * This removes Shadow's generated `-dontoptimize` rule. Name obfuscation remains disabled unless + * [enableObfuscation] is also called. + */ + public fun enableOptimization() } diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt index c238df4f3..4a2bad6b4 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt @@ -71,8 +71,7 @@ public abstract class ShadowJar : Jar() { private val defaultMinimizeSpec = DefaultMinimizeSpec(project, objectFactory) /** Options for [minimize]. */ - @get:Nested - public open val minimizeSpec: MinimizeSpec = defaultMinimizeSpec + @get:Nested public open val minimizeSpec: MinimizeSpec = defaultMinimizeSpec private val shadowDependencies = project.provider { // Find shadow configuration here instead of get, as the ShadowJar tasks could be registered From 16232a22d02d8b62e6d60300d7f50bb38a85668e Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Thu, 2 Jul 2026 00:38:00 -0400 Subject: [PATCH 14/14] Fix docs I think --- docs/configuration/minimizing/README.md | 82 +++++++++++++++++++------ 1 file changed, 62 insertions(+), 20 deletions(-) diff --git a/docs/configuration/minimizing/README.md b/docs/configuration/minimizing/README.md index 2f83e1b5e..179ce4992 100644 --- a/docs/configuration/minimizing/README.md +++ b/docs/configuration/minimizing/README.md @@ -145,6 +145,9 @@ For example, to downgrade R8 warnings to info: ```kotlin repositories { + google() + } + tasks.shadowJar { minimize { r8 { @@ -158,6 +161,9 @@ For example, to downgrade R8 warnings to info: ```groovy repositories { + google() + } + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { minimize { r8 { @@ -172,9 +178,15 @@ To enable name obfuscation: === "Kotlin" ```kotlin - minimize { - r8 { - enableObfuscation() + repositories { + google() + } + + tasks.shadowJar { + minimize { + r8 { + enableObfuscation() + } } } ``` @@ -182,9 +194,15 @@ To enable name obfuscation: === "Groovy" ```groovy - minimize { - r8 { - enableObfuscation() + repositories { + google() + } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + minimize { + r8 { + enableObfuscation() + } } } ``` @@ -194,9 +212,15 @@ To enable optimization: === "Kotlin" ```kotlin - minimize { - r8 { - enableOptimization() + repositories { + google() + } + + tasks.shadowJar { + minimize { + r8 { + enableOptimization() + } } } ``` @@ -204,9 +228,15 @@ To enable optimization: === "Groovy" ```groovy - minimize { - r8 { - enableOptimization() + repositories { + google() + } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + minimize { + r8 { + enableOptimization() + } } } ``` @@ -216,10 +246,16 @@ To enable both: === "Kotlin" ```kotlin - minimize { - r8 { - enableObfuscation() - enableOptimization() + repositories { + google() + } + + tasks.shadowJar { + minimize { + r8 { + enableObfuscation() + enableOptimization() + } } } ``` @@ -227,10 +263,16 @@ To enable both: === "Groovy" ```groovy - minimize { - r8 { - enableObfuscation() - enableOptimization() + repositories { + google() + } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + minimize { + r8 { + enableObfuscation() + enableOptimization() + } } } ```