diff --git a/api/shadow.api b/api/shadow.api index 1e7e202dd..af1767889 100644 --- a/api/shadow.api +++ b/api/shadow.api @@ -202,6 +202,28 @@ 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 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; +} + 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 +254,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 +262,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; diff --git a/docs/changes/README.md b/docs/changes/README.md index 59754291a..d8a166854 100644 --- a/docs/changes/README.md +++ b/docs/changes/README.md @@ -7,6 +7,7 @@ - Check `DuplicatesStrategy` for merging transformers. ([#2026](https://github.com/GradleUp/shadow/pull/2026)) This will log warnings when an incompatible `DuplicatesStrategy` (e.g., `EXCLUDE`) is applied in Gradle configuration for built-in `ResourceTransformer`s. +- 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..179ce4992 100644 --- a/docs/configuration/minimizing/README.md +++ b/docs/configuration/minimizing/README.md @@ -72,6 +72,209 @@ 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 +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 { + args.addAll(listOf("--map-diagnostics", "warning", "info")) + } + } + } + ``` + +=== "Groovy" + + ```groovy + repositories { + google() + } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + minimize { + r8 { + args.addAll(['--map-diagnostics', 'warning', 'info']) + } + } + } + ``` + +To enable name obfuscation: + +=== "Kotlin" + + ```kotlin + repositories { + google() + } + + tasks.shadowJar { + minimize { + r8 { + enableObfuscation() + } + } + } + ``` + +=== "Groovy" + + ```groovy + repositories { + google() + } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + minimize { + r8 { + enableObfuscation() + } + } + } + ``` + +To enable optimization: + +=== "Kotlin" + + ```kotlin + repositories { + google() + } + + tasks.shadowJar { + minimize { + r8 { + enableOptimization() + } + } + } + ``` + +=== "Groovy" + + ```groovy + repositories { + google() + } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + minimize { + r8 { + enableOptimization() + } + } + } + ``` + +To enable both: + +=== "Kotlin" + + ```kotlin + repositories { + google() + } + + tasks.shadowJar { + minimize { + r8 { + enableObfuscation() + enableOptimization() + } + } + } + ``` + +=== "Groovy" + + ```groovy + repositories { + google() + } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + 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/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..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 @@ -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,152 @@ 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 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() + 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 +537,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 + ) + } } 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/internal/DefaultMinimizeSpec.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultMinimizeSpec.kt new file mode 100644 index 000000000..111e86398 --- /dev/null +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultMinimizeSpec.kt @@ -0,0 +1,46 @@ +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 +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) + + 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(): DefaultR8Spec = r8Spec + + 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/DefaultR8Spec.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultR8Spec.kt new file mode 100644 index 000000000..cbceedef2 --- /dev/null +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultR8Spec.kt @@ -0,0 +1,44 @@ +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 +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input + +internal class DefaultR8Spec(objectFactory: ObjectFactory) : R8Spec { + 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/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, 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..23c397979 --- /dev/null +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/R8Minimizer.kt @@ -0,0 +1,337 @@ +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 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, including relocations, resource transformers, merged + * service files, and duplicate handling. R8 then processes that exact artifact. + * + * 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: DefaultR8Spec, + 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 (shouldDisableOptimization(r8Args)) { + rules += DefaultR8Spec.DONT_OPTIMIZE_RULE + } + 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() + } + + 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 { + 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) + + // 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 + 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_$]*)*") + } +} 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( 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..cf83ec88a --- /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 groovy.lang.Closure +import org.gradle.api.Action +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input + +/** 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 + + /** 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<*>) +} 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, +} 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..d6d36dd92 --- /dev/null +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/R8Spec.kt @@ -0,0 +1,42 @@ +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 + + /** + * 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 956d679e4..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 @@ -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,15 @@ 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 +107,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 +145,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 +316,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 +485,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 +517,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 +563,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,