diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3da5b0c43f..0a21c5354b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -517,53 +517,140 @@ tasks.register("createPluginArtifactsZip") { // Merges the API surface plugins already compile against (plugin-api + common + // eventbus-events + idetooltips) into one coordinate. The three add-ons are // v7/v8-flavored (unlike plugin-api); their classes are ABI-neutral so v8 is used. -tasks.register("assemblePluginApiFatJar") { - dependsOn( - ":plugin-api:assembleRelease", - ":common:assembleV8Release", - ":eventbus-events:assembleV8Release", - ":idetooltips:assembleV8Release", - ) - archiveFileName.set("plugin-api-1.0.0.jar") - destinationDirectory.set(layout.buildDirectory.dir("plugin-maven-repo-staging")) - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - - from( - zipTree( - project(":plugin-api") - .layout.buildDirectory - .file("intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar") - .get() - .asFile, - ), - ) - from( - zipTree( - project(":common") - .layout.buildDirectory - .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar") - .get() - .asFile, - ), - ) - from( - zipTree( - project(":eventbus-events") - .layout.buildDirectory - .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar") - .get() - .asFile, - ), - ) - from( - zipTree( - project(":idetooltips") - .layout.buildDirectory - .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar") - .get() - .asFile, - ), - ) +val pluginApiFatJar = + tasks.register("assemblePluginApiFatJar") { + dependsOn( + ":plugin-api:assembleRelease", + ":common:assembleV8Release", + ":eventbus-events:assembleV8Release", + ":idetooltips:assembleV8Release", + ) + archiveFileName.set("plugin-api-1.0.0.jar") + destinationDirectory.set(layout.buildDirectory.dir("plugin-maven-repo-staging")) + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + + from( + zipTree( + project(":plugin-api") + .layout.buildDirectory + .file("intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar") + .get() + .asFile, + ), + ) + from( + zipTree( + project(":common") + .layout.buildDirectory + .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar") + .get() + .asFile, + ), + ) + from( + zipTree( + project(":eventbus-events") + .layout.buildDirectory + .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar") + .get() + .asFile, + ), + ) + from( + zipTree( + project(":idetooltips") + .layout.buildDirectory + .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar") + .get() + .asFile, + ), + ) + } + +// Plugins load through DexClassLoader, so R8 never sees a reference to the ABI +// they call. Emitting one -keep per class in the fat jar published above makes +// the rules exactly the published ABI, so they cannot drift as classes move +// between the four merged modules. +abstract class GeneratePluginApiKeepRules : DefaultTask() { + @get:InputFile + abstract val fatJar: RegularFileProperty + + // Module path -> a class that module must contribute. The fat jar merges four + // hardcoded AGP intermediate paths with DuplicatesStrategy.EXCLUDE, so a path + // that stops resolving (AGP layout change, dropped from(...)) drops that + // module's classes silently instead of failing. + @get:Input + abstract val sentinelClasses: MapProperty + + @get:OutputFile + abstract val keepRules: RegularFileProperty + + @TaskAction + fun generate() { + val jar = fatJar.get().asFile + val classes = + ZipFile(jar).use { zip -> + zip + .entries() + .asSequence() + .map { it.name } + .filter { it.endsWith(".class") } + .map { it.removeSuffix(".class").replace('/', '.') } + .sorted() + .toList() + } + + check(classes.isNotEmpty()) { + "$jar holds no classes; generating keep rules from it would ship the plugin ABI unprotected." + } + + val present = classes.toSet() + val missing = sentinelClasses.get().filterValues { it !in present } + check(missing.isEmpty()) { + missing.entries.joinToString( + prefix = "$jar is missing whole modules, so their ABI would ship unprotected: ", + ) { "${it.key} (no ${it.value})" } + } + + val header = + """ + # Generated by :app:generatePluginApiKeepRules -- do not edit. + # One -keep per class in the published plugin-api fat jar: plugins resolve the + # ABI parent-first through DexClassLoader, which R8 cannot see. + # + # These cover the ABI only. Plugins also resolve kotlin.** from the app's dex, + # which app/proguard-rules.pro keeps separately (ADFA-5156). + + """.trimIndent() + + val file = keepRules.get().asFile + file.parentFile.mkdirs() + file.writeText( + classes.joinToString("\n", prefix = header, postfix = "\n") { "-keep class $it { *; }" }, + ) + logger.lifecycle("Wrote ${classes.size} plugin-ABI keep rules to ${file.absolutePath}") + } +} + +val pluginApiKeepRules = + tasks.register("generatePluginApiKeepRules") { + fatJar.set(pluginApiFatJar.flatMap { it.archiveFile }) + sentinelClasses.set( + mapOf( + ":plugin-api" to "com.itsaky.androidide.plugins.IPlugin", + ":common" to "com.itsaky.androidide.utils.Environment", + ":eventbus-events" to "com.itsaky.androidide.eventbus.events.Event", + ":idetooltips" to "com.itsaky.androidide.idetooltips.IDETooltipItem", + ), + ) + keepRules.set(layout.buildDirectory.file("generated/proguard/plugin-api-keep.pro")) + } + +// proguardFiles (not dependsOn) so Gradle infers the dependency for every +// consumer: R8 and lint both read this list, and wiring only R8 fails validation +// in a real release build. Debug variants stay off the fat jar entirely. +androidComponents.onVariants(androidComponents.selector().withBuildType("release")) { variant -> + variant.proguardFiles.add(pluginApiKeepRules.flatMap { it.keepRules }) } // Dependency-free POM for the fat plugin-api coordinate: it is compile-only/provided, diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index c5c01850b9..343c32d0af 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -193,16 +193,25 @@ -keep class io.sentry.** { *; } -dontwarn io.sentry.** -# ADFA-5156: TEMPORARY ROLLBACK of the R8 shrinking re-enabled in ADFA-3604. -# Plugins load parent-first through a stock DexClassLoader, so they resolve -# kotlin.** from the app's dex rather than their own bundled stdlib. R8 cannot -# see plugin call sites, so it strips every stdlib member the IDE itself does -# not call and plugins die with NoSuchMethodError at runtime (Sketch to UI: -# ArraysKt.maxOrNull([F)). With -dontobfuscate and -dontoptimize already set, -# this restores R8 to a pass-through and returns the release build to the -# configuration shipped before ADFA-3604. Revert once ADFA-5156 lands a -# targeted fix (keep rules for kotlin.**/kotlinx.coroutines.**). --dontshrink +# ADFA-5156: plugins load parent-first through a stock DexClassLoader, so they +# resolve kotlin.** from the app's dex rather than their own bundled stdlib. R8 +# cannot see plugin call sites, so it strips every stdlib member the IDE itself +# does not call and plugins die with NoSuchMethodError at runtime (Sketch to UI: +# ArraysKt.maxOrNull([F)). ADFA-5156 first fixed this with -dontshrink, which +# made R8 a pass-through over the whole app to protect one library; ADFA-5195 +# replaced it with the targeted keeps below, restoring dead-code removal +# everywhere else. +# +# ADFA-5164 narrows this: freeze the stdlib subset plugins may rely on and have +# plugins bundle the rest. Until then the whole stdlib is the contract, so these +# stay broad -- and note the keeps further up cover only kotlin.reflect, +# kotlin.script and kotlinx.coroutines.internal, not kotlin.collections/text/io/ +# sequences, which is the hole maxOrNull([F) fell through. +-keep class kotlin.** { *; } +-keep class kotlinx.coroutines.** { *; } + +-keep class com.google.firebase.** { *; } +-keep class com.google.android.gms.** { *; } ## Plugin SPI ## Plugins are loaded dynamically via DexClassLoader, so R8 cannot see their