From 0772f4a91244e92181ca5298d2e138aca1d18764 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 13 Aug 2026 03:13:23 -0700 Subject: [PATCH 01/22] ADFA-4931: stop shipping a second Kotlin compiler The on-device Gradle distribution has always carried one: 8.14.3 bundled kotlin-compiler-embeddable 2.0.21, and 9.6.1 bundles 2.3.21. Quick Build's compile daemon shipped its own copy beside it, so the APK carried the same ~57 MB artifact twice at two versions that differ only in patch. Pin the daemon's compiler to the version the distribution embeds, drop it from quickbuild-daemon.zip, and link the distribution's copy into the daemon dir at stage time. The daemon jar's manifest Class-Path is unchanged -- it still names the jar, the file is still there, only its provenance moved. The host-side stageDaemon layout is untouched too, since it copies straight from runtimeClasspath, so the daemon's own tests and the corpus harness are unaffected. The link runs from stageDaemonIfNeeded, before the stamp is written. The skip test at the top of that function reads the stamp and the daemon jar, not the linked compiler, so a stamp written over a failed link would skip re-staging on every later call and leave the daemon without a compiler until the next install -- the stamp means a complete staging, and the link is part of one. quickbuild-daemon.zip: 61,954,394 -> 9,545,494 bytes. It sits in the APK uncompressed, so the APK should drop by the same ~52.4 MB. The pin is a separate catalog version rather than a move of the shared `kotlin` alias, which also compiles the IDE across ~60 modules; the compiler the daemon runs and the compiler that builds CoGo are different concerns. quickBuildCompilerVersionCheck fails the build if the two drift, because the failure it prevents surfaces on device as a NoClassDefFoundError partway into a compile. Left bundled: kotlin-reflect, stdlib, coroutines, gson, asm and build-tools-impl also appear in the distribution, but unlike the compiler they do not version-match (reflect 1.6.10 vs 2.3.21, coroutines 1.8.0 vs 1.10.2, asm 9.7.1 vs 9.9). About 4 MB for a behaviour change rather than a packaging one -- worth its own look, not this commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE --- app/build.gradle.kts | 29 +++++++++++++ .../quickbuild/QuickBuildArtifactStager.kt | 43 +++++++++++++++++++ gradle/libs.versions.toml | 11 +++-- 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d0e1bfc9b5..dd795738d8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,6 +7,8 @@ import com.itsaky.androidide.plugins.tasks.AddFileToAssetsTask import org.adfa.constants.GRADLE_API_NAME_JAR_BR import org.adfa.constants.GRADLE_API_NAME_JAR_ZIP import org.adfa.constants.GRADLE_DISTRIBUTION_ARCHIVE_NAME +import org.adfa.constants.GRADLE_DISTRIBUTION_NAME +import org.adfa.constants.KOTLIN_VERSION import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform import org.json.JSONObject import java.io.BufferedOutputStream @@ -507,16 +509,43 @@ dependencies { evaluationDependsOn(":quickbuild:runtime") evaluationDependsOn(":quickbuild:daemon") +// ADFA-4931: the daemon's compiler is the one the on-device Gradle distribution already +// embeds, so the two versions must be the same number. Checked here rather than trusted, +// because the failure it prevents surfaces on device as a NoClassDefFoundError partway +// into a compile, a long way from the constant that caused it. +val quickBuildCompilerVersionCheck = + tasks.register("quickBuildCompilerVersionCheck") { + val pinned = + libs.versions.kotlin.daemon.compiler + .get() + doLast { + if (pinned != KOTLIN_VERSION) { + throw GradleException( + "Quick Build's compiler pin (libs.versions.kotlin-daemon-compiler = $pinned) must equal " + + "the Kotlin the on-device Gradle distribution embeds " + + "(org.adfa.constants.KOTLIN_VERSION = $KOTLIN_VERSION). The daemon loads " + + "$GRADLE_DISTRIBUTION_NAME/lib/kotlin-compiler-embeddable-$KOTLIN_VERSION.jar at runtime.", + ) + } + } + } + val quickBuildDaemonZip = tasks.register("quickBuildDaemonZip") { archiveFileName.set("quickbuild-daemon.zip") destinationDirectory.set(layout.buildDirectory.dir("intermediates/quickbuild")) + dependsOn(quickBuildCompilerVersionCheck) val daemonProject = rootProject.project(":quickbuild:daemon") dependsOn(daemonProject.tasks.named("daemonJar")) from(daemonProject.tasks.named("daemonJar")) // The daemon jar's manifest Class-Path names these by file name; they must sit // next to the jar after extraction. from(daemonProject.configurations.named("runtimeClasspath")) + // ADFA-4931: except the compiler. The on-device Gradle distribution ships the same + // artifact at the same version (~57 MB of it), so shipping a second copy in the APK + // bought nothing. QuickBuildArtifactStager links the distribution's copy into the + // daemon dir under this name, which is what the manifest Class-Path still expects. + exclude("kotlin-compiler-embeddable-*.jar") // Compose compiler plugin, version-matched to the daemon's compiler; the stable // name is the contract EnvironmentQuickBuildPaths.composeCompilerPlugin reads. from(daemonProject.configurations.named("composeCompilerPlugin")) { diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt index a42fe8ba74..5138760611 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt @@ -3,11 +3,14 @@ package com.itsaky.androidide.quickbuild import android.content.Context import androidx.core.content.pm.PackageInfoCompat import com.itsaky.androidide.utils.Environment +import org.adfa.constants.GRADLE_DISTRIBUTION_NAME +import org.adfa.constants.KOTLIN_VERSION import org.slf4j.LoggerFactory import java.io.File import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream +import java.nio.file.Files import java.util.zip.ZipInputStream /** @@ -88,6 +91,10 @@ object QuickBuildArtifactStager { Environment.mkdirIfNotExists(daemonDir) val count = extractDaemonZip(openZip(), daemonDir) + // Before the stamp: the skip test above reads the stamp and the daemon jar, not the + // linked compiler, so stamping a staging whose link failed would skip re-staging forever + // and leave the daemon without a compiler until the next install. + linkKotlinCompiler(daemonDir) stamp.writeText(installStamp) log.info("Staged {} daemon files into {}", count, daemonDir) return true @@ -132,4 +139,40 @@ object QuickBuildArtifactStager { return count } } + + /** + * Put the Kotlin compiler where the daemon jar's manifest Class-Path expects it. + * + * The APK no longer carries one (ADFA-4931): the on-device Gradle distribution already + * ships the same artifact at the same version, so a second ~57 MB copy in the daemon zip + * was pure duplication. The two are always on one filesystem - `gradle-dists/` and + * `quickbuild/` are both under `` - so a symlink costs nothing; a copy is + * the fallback for a filesystem that refuses one. + * + * @throws FileNotFoundException when the distribution has not been extracted yet. There is + * deliberately no bundled fallback: the alternative to failing here is a + * NoClassDefFoundError partway into the user's first compile, which is far harder to read. + */ + @Throws(IOException::class) + private fun linkKotlinCompiler(daemonDir: File) { + val jarName = "kotlin-compiler-embeddable-$KOTLIN_VERSION.jar" + val source = File(File(Environment.GRADLE_DISTS, GRADLE_DISTRIBUTION_NAME), "lib/$jarName") + if (!source.isFile) { + throw FileNotFoundException( + "Kotlin compiler missing from the on-device Gradle distribution: $source. " + + "Quick Build loads the compiler from there rather than from the APK.", + ) + } + + val target = File(daemonDir, jarName) + try { + Files.createSymbolicLink(target.toPath(), source.toPath()) + log.info("Linked Kotlin compiler {} -> {}", target, source) + } catch (e: Exception) { + // UnsupportedOperationException / IOException / SecurityException all mean the same + // thing here: no symlink, so pay the bytes. + log.warn("Symlink to {} failed ({}); copying instead", source, e.toString()) + source.inputStream().use { input -> target.outputStream().use { input.copyTo(it) } } + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index def9994279..20b11bb39a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,11 @@ junit-jupiter = "5.10.2" anroidx-test-core = "2.2.0" koinAndroid = "4.1.1" kotlin = "2.3.0" +# The Quick Build daemon's compiler. Must equal org.adfa.constants.KOTLIN_VERSION -- the +# Kotlin the on-device Gradle distribution embeds -- because the daemon loads that +# distribution's kotlin-compiler-embeddable instead of shipping its own copy. :app's +# quickBuildCompilerVersionCheck asserts the two agree. +kotlin-daemon-compiler = "2.3.21" kotlin-coroutines = "1.9.0" kotlinxCoroutinesCore = "1.10.2" lifecycleViewmodelKtx = "2.7.0" @@ -270,10 +275,10 @@ xml-jb-annotations = { module = "org.jetbrains:annotations", version = "24.1.0" git-jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version = "6.8.0.202311291450-r" } # Quick Build daemon (ADFA-4128): Kotlin Build Tools API incremental engine -kotlin-buildToolsApi = { module = "org.jetbrains.kotlin:kotlin-build-tools-api", version.ref = "kotlin" } -kotlin-buildToolsImpl = { module = "org.jetbrains.kotlin:kotlin-build-tools-impl", version.ref = "kotlin" } +kotlin-buildToolsApi = { module = "org.jetbrains.kotlin:kotlin-build-tools-api", version.ref = "kotlin-daemon-compiler" } +kotlin-buildToolsImpl = { module = "org.jetbrains.kotlin:kotlin-build-tools-impl", version.ref = "kotlin-daemon-compiler" } # Compose compiler plugin, version-matched to the daemon's compiler -kotlin-composeCompilerPluginEmbeddable = { module = "org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable", version.ref = "kotlin" } +kotlin-composeCompilerPluginEmbeddable = { module = "org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable", version.ref = "kotlin-daemon-compiler" } # Compose runtime for the daemon's compose compile tests (see :quickbuild:daemon) composeRuntimeDaemonTests = { module = "androidx.compose.runtime:runtime-android", version = "1.7.3" } ow2-asm = { module = "org.ow2.asm:asm", version = "9.7.1" } From bed2217a45a6640e798130635364948bc8d33653 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 13 Aug 2026 03:29:38 -0700 Subject: [PATCH 02/22] ADFA-4931: point the TestKit fixtures at the toolchain they now test AGP 9.3.1 refuses to configure on a Gradle older than 9.5.0, so every TestKit test that applied com.android.application died at plugin application -- 8 of them -- against a Gradle version two constants still named 8.14.3. AGP_VERSION_GRADLE_LATEST now reads GRADLE_DISTRIBUTION_VERSION instead of repeating it. Its comment already said it tracks the distribution the IDE bundles, and it had drifted once before for the same reason (8.6 stale against AGP 8.11), so deriving it is what stops the third time. AndroidIDEInitScriptPluginTest pins its own versions to span the supported range deliberately, so that pair moves rather than gets derived: 8.14.3 is now below AGP's floor and is replaced by 9.5.1, with the bundled 9.6.1 as the upper end. Same intent -- both ends of the range, not just the middle. :gradle-plugin:test 132 passing, 6 skipped (the pre-existing LogSender bucket). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE --- build-info/build.gradle.kts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/build-info/build.gradle.kts b/build-info/build.gradle.kts index 076f62ce95..2b6e9f61c4 100644 --- a/build-info/build.gradle.kts +++ b/build-info/build.gradle.kts @@ -26,6 +26,7 @@ import com.itsaky.androidide.build.config.publishingVersion import com.itsaky.androidide.build.config.releaseVersion import com.itsaky.androidide.build.config.replaceContents import com.itsaky.androidide.build.config.simpleVersionName +import org.adfa.constants.GRADLE_DISTRIBUTION_VERSION plugins { //noinspection JavaPluginLanguageLevel @@ -76,10 +77,11 @@ tasks.create("generateBuildInfo") { libs.versions.agp.tooling .get(), // The Gradle version AGP_VERSION_LATEST gets exercised against: the - // distribution the IDE bundles (GRADLE_DISTRIBUTION_VERSION in - // composite-builds/build-deps-common). AGP 9.3.1 refuses to configure - // on anything older than 9.5. - "AGP_VERSION_GRADLE_LATEST" to "9.6.1", + // distribution the IDE bundles. Read from the constant rather than + // repeated, because AGP refuses to configure on a distribution older + // than it expects (8.11 needed 8.13; 9.3 needs 9.5), so a stale copy + // here fails every TestKit test at plugin-application time. + "AGP_VERSION_GRADLE_LATEST" to GRADLE_DISTRIBUTION_VERSION, "SNAPSHOTS_REPOSITORY" to VersionUtils.SONATYPE_SNAPSHOTS_REPO, "PUBLIC_REPOSITORY" to VersionUtils.SONATYPE_PUBLIC_REPO, ), From 06728f69e398ff9a3695613d872014fcdb117688 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 13 Aug 2026 03:46:54 -0700 Subject: [PATCH 03/22] ADFA-4931: cover the compiler link, and correct what the daemon deps say The link is the half of ADFA-4931 that runs on device, and it had no test. Its bad failure mode is silence: if it stages nothing, the daemon jar's manifest Class-Path names a jar that is not there and the first compile dies deep inside the Build Tools API. Five tests now pin it -- content readable at the expected name, the name carrying the pinned version, a link rather than a copy so the bytes are not paid twice, and the missing-distribution case failing loudly with both the distribution and the jar name in the message. All five go red against a no-op link. linkKotlinCompiler takes the dists root as a defaulted parameter purely so a test can point at a fake distribution; production callers are unchanged. The dependency comment was also wrong in a way worth correcting rather than leaving: it justified keeping kotlin-daemon-client, a module the 2.3.21 graph does not resolve at all. Re-tested both suspicious deps by exclusion on the current graph. kotlin-daemon-embeddable is what actually carries CompileIterationResult. And kotlin-reflect, which reads like a stale 1.6.10 pin, is nothing of the sort -- it is what the 2.3.21 compiler's own POM declares, and excluding it fails every daemon test that runs a real compile with NoClassDefFoundError. Both findings are now in the comment so the next person to eye those 3 MB has the evidence instead of the guess. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE --- .../quickbuild/QuickBuildArtifactStager.kt | 9 +- ...uickBuildArtifactStagerCompilerLinkTest.kt | 104 ++++++++++++++++++ quickbuild/daemon/build.gradle.kts | 27 +++-- 3 files changed, 128 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerCompilerLinkTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt index 5138760611..d7efd133c8 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt @@ -149,14 +149,19 @@ object QuickBuildArtifactStager { * `quickbuild/` are both under `` - so a symlink costs nothing; a copy is * the fallback for a filesystem that refuses one. * + * @param gradleDists parameterised only so tests can point at a fake distribution; production + * callers take the default. * @throws FileNotFoundException when the distribution has not been extracted yet. There is * deliberately no bundled fallback: the alternative to failing here is a * NoClassDefFoundError partway into the user's first compile, which is far harder to read. */ @Throws(IOException::class) - private fun linkKotlinCompiler(daemonDir: File) { + internal fun linkKotlinCompiler( + daemonDir: File, + gradleDists: File = Environment.GRADLE_DISTS, + ) { val jarName = "kotlin-compiler-embeddable-$KOTLIN_VERSION.jar" - val source = File(File(Environment.GRADLE_DISTS, GRADLE_DISTRIBUTION_NAME), "lib/$jarName") + val source = File(File(gradleDists, GRADLE_DISTRIBUTION_NAME), "lib/$jarName") if (!source.isFile) { throw FileNotFoundException( "Kotlin compiler missing from the on-device Gradle distribution: $source. " + diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerCompilerLinkTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerCompilerLinkTest.kt new file mode 100644 index 0000000000..f41c69109d --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerCompilerLinkTest.kt @@ -0,0 +1,104 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import org.adfa.constants.GRADLE_DISTRIBUTION_NAME +import org.adfa.constants.KOTLIN_VERSION +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.FileNotFoundException +import java.nio.file.Files + +/** + * ADFA-4931 removed the Kotlin compiler from the APK, so the daemon now gets it from the + * on-device Gradle distribution. That makes the link step load-bearing: if it silently does + * nothing, the daemon's manifest Class-Path names a jar that is not there and the first + * compile dies deep inside the Build Tools API. + */ +class QuickBuildArtifactStagerCompilerLinkTest { + @get:Rule + val temp = TemporaryFolder() + + private val jarName = "kotlin-compiler-embeddable-$KOTLIN_VERSION.jar" + + /** A fake `gradle-dists/` holding one distribution with a compiler jar of [content]. */ + private fun gradleDistsWithCompiler(content: String): File { + val dists = temp.newFolder("gradle-dists") + File(dists, "$GRADLE_DISTRIBUTION_NAME/lib").mkdirs() + File(dists, "$GRADLE_DISTRIBUTION_NAME/lib/$jarName").writeText(content) + return dists + } + + @Test + fun `compiler is readable from the daemon dir under the name the manifest expects`() { + val dists = gradleDistsWithCompiler("compiler-bytes") + val daemonDir = temp.newFolder("daemon") + + QuickBuildArtifactStager.linkKotlinCompiler(daemonDir, dists) + + // Asserting the CONTENT rather than mere existence: a symlink and a copy are both + // acceptable outcomes, and both must leave the bytes reachable at this path. + val staged = File(daemonDir, jarName) + assertThat(staged.readText()).isEqualTo("compiler-bytes") + } + + @Test + fun `the staged name carries the pinned Kotlin version`() { + val dists = gradleDistsWithCompiler("compiler-bytes") + val daemonDir = temp.newFolder("daemon") + + QuickBuildArtifactStager.linkKotlinCompiler(daemonDir, dists) + + // The daemon jar's manifest Class-Path is generated from the same resolved version, so + // the file name is a contract between the build and this staging step, not a detail. + assertThat(daemonDir.list()!!.toList()).containsExactly(jarName) + } + + @Test + fun `a link rather than a copy is preferred so the bytes are not duplicated on device`() { + val dists = gradleDistsWithCompiler("compiler-bytes") + val daemonDir = temp.newFolder("daemon") + + QuickBuildArtifactStager.linkKotlinCompiler(daemonDir, dists) + + // The whole point of ADFA-4931 is to stop carrying two copies. A copy is a legitimate + // fallback, but on any filesystem that supports links (every JVM this test runs on) we + // should not be paying the bytes twice. + val staged = File(daemonDir, jarName).toPath() + assertThat(Files.isSymbolicLink(staged)).isTrue() + } + + @Test + fun `missing distribution fails loudly and names the path a human has to fix`() { + val emptyDists = temp.newFolder("gradle-dists") + val daemonDir = temp.newFolder("daemon") + + val thrown = + runCatching { QuickBuildArtifactStager.linkKotlinCompiler(daemonDir, emptyDists) } + .exceptionOrNull() + + // Failing here is deliberate: the alternative is a NoClassDefFoundError partway into the + // user's first compile, a long way from the missing file that caused it. + assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) + assertThat(thrown!!).hasMessageThat().contains(GRADLE_DISTRIBUTION_NAME) + assertThat(thrown).hasMessageThat().contains(jarName) + assertThat(daemonDir.list()!!.toList()).isEmpty() + } + + @Test + fun `a distribution directory for a different version does not satisfy the link`() { + val dists = temp.newFolder("gradle-dists") + File(dists, "gradle-0.0.1/lib").mkdirs() + File(dists, "gradle-0.0.1/lib/$jarName").writeText("wrong distribution") + val daemonDir = temp.newFolder("daemon") + + // Guards the version-derived path: the compiler must come from the distribution this + // build actually bundles, not from whatever else happens to be extracted alongside it. + val thrown = + runCatching { QuickBuildArtifactStager.linkKotlinCompiler(daemonDir, dists) } + .exceptionOrNull() + + assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) + } +} diff --git a/quickbuild/daemon/build.gradle.kts b/quickbuild/daemon/build.gradle.kts index 608fb04c49..6d390a56f6 100644 --- a/quickbuild/daemon/build.gradle.kts +++ b/quickbuild/daemon/build.gradle.kts @@ -106,18 +106,25 @@ dependencies { implementation(libs.google.gson) // ACC_FINAL stripping on recompiled payload classes (proxies extend user classes). implementation(libs.ow2.asm) - // The BTA implementation + its runtime deps are loaded from the daemon's runtime - // classpath on device (staged alongside the jar), matched to the bundled compiler. + // The BTA implementation + its runtime deps are staged alongside the daemon jar on + // device and loaded from there. The compiler itself is the exception: ADFA-4931 + // excludes it from the zip and links the on-device Gradle distribution's copy in, so + // this must stay pinned to the distribution's Kotlin (libs.versions.toml's + // kotlin-daemon-compiler, asserted against org.adfa.constants.KOTLIN_VERSION). + // // kotlin-compiler-runner exists solely to launch/talk to a separate long-lived // "Kotlin compile daemon" JVM over RMI, which IncrementalCompiler never does here - // (it always calls useInProcessStrategy()) - dead weight (~17 KB of the ~62 MB - // quickbuild-daemon.zip, ADFA-4128 size audit). - // kotlin-daemon-client and kotlin-daemon-embeddable looked like the same kind of - // dead weight but are NOT: BuildToolsApiBuildICReporter.reportCompileIteration (part - // of kotlin-build-tools-impl itself, on the in-process path) references - // org.jetbrains.kotlin.daemon.common.CompileIterationResult, which lives in - // kotlin-daemon-client - excluding it throws NoClassDefFoundError and failed 12/52 - // :quickbuild-daemon:test cases. Keep both. + // (it always calls useInProcessStrategy()) - dead weight (~17 KB, ADFA-4128 size audit). + // + // Two others look like the same dead weight and are NOT. Both re-tested by exclusion on + // the 2.3.21 graph (ADFA-4931), because the note here used to name kotlin-daemon-client, + // which this graph no longer resolves at all: + // - kotlin-daemon-embeddable carries + // org.jetbrains.kotlin.daemon.common.CompileIterationResult, referenced by + // kotlin-build-tools-impl's own BuildToolsApiBuildICReporter on the in-process path. + // - kotlin-reflect - 1.6.10, which is what the 2.3.21 compiler's POM declares rather + // than a stale pin of ours - is needed by the compiler itself. Excluding it fails + // every daemon test that runs a real compile with NoClassDefFoundError. runtimeOnly(libs.kotlin.buildToolsImpl) { exclude(group = "org.jetbrains.kotlin", module = "kotlin-compiler-runner") } From 8aa348ca0861cd17c3cf7e6bbdbe0fb22a3be5e4 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 13 Aug 2026 04:56:49 -0700 Subject: [PATCH 04/22] ADFA-4931: stop shipping four more jars the distribution already has The compiler was the expensive one, but it was not the only duplicate. The on-device Gradle distribution also ships kotlin-stdlib, kotlin-build-tools-impl, kotlin-daemon-embeddable and kotlin-script-runtime at exactly the versions the daemon resolves - byte for byte, same artifacts from the same Maven coordinates. Link those too and the daemon zip drops from 9,545,494 to 6,167,300 bytes. The list moves out of the code and into one table. quickBuildDistLinkedJars names the jars and pins the SHA-256 each has in gradle-9.6.1/lib; the zip task excludes those names, a generated dist-linked-jars.txt travels with the zip, and the stager links whatever that file names. So the build's exclusion list and the device's link step are the same list, and adding a jar is one line rather than two edits that can disagree. quickBuildDistJarCheck earns its keep by failing three ways, each watched go red before this landed: a jar whose bytes drift from the distribution's copy, a name that is not on the daemon's runtime classpath (which would exclude a jar nothing puts back), and a distribution version that has moved past the one the hashes were read from. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE --- app/build.gradle.kts | 117 ++++++++++-- .../quickbuild/QuickBuildArtifactStager.kt | 86 ++++++--- ...uickBuildArtifactStagerCompilerLinkTest.kt | 104 ----------- .../QuickBuildArtifactStagerDistLinkTest.kt | 168 ++++++++++++++++++ gradle/libs.versions.toml | 2 +- 5 files changed, 332 insertions(+), 145 deletions(-) delete mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerCompilerLinkTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dd795738d8..1e43bb3999 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,7 @@ import org.adfa.constants.GRADLE_API_NAME_JAR_BR import org.adfa.constants.GRADLE_API_NAME_JAR_ZIP import org.adfa.constants.GRADLE_DISTRIBUTION_ARCHIVE_NAME import org.adfa.constants.GRADLE_DISTRIBUTION_NAME +import org.adfa.constants.GRADLE_DISTRIBUTION_VERSION import org.adfa.constants.KOTLIN_VERSION import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform import org.json.JSONObject @@ -509,15 +510,54 @@ dependencies { evaluationDependsOn(":quickbuild:runtime") evaluationDependsOn(":quickbuild:daemon") -// ADFA-4931: the daemon's compiler is the one the on-device Gradle distribution already -// embeds, so the two versions must be the same number. Checked here rather than trusted, -// because the failure it prevents surfaces on device as a NoClassDefFoundError partway -// into a compile, a long way from the constant that caused it. -val quickBuildCompilerVersionCheck = - tasks.register("quickBuildCompilerVersionCheck") { +// ADFA-4931: jars the daemon loads from the on-device Gradle distribution instead of from the +// APK. The distribution ships each of these byte for byte, and `gradle-dists/` and +// `quickbuild/` are both under , so a second copy in the daemon zip bought +// nothing but download and disk. quickBuildDaemonZip leaves them out; +// QuickBuildArtifactStager links them into the daemon dir under exactly these names, which is +// what the daemon jar's manifest Class-Path still expects. +// +// The values are the SHA-256 of each jar as gradle-$GRADLE_DISTRIBUTION_VERSION/lib/ ships it, +// asserted below against what the daemon actually resolves. Re-derive after any distribution +// bump (and move DIST_JAR_HASHES_FROM with it): +// unzip -p assets/$GRADLE_DISTRIBUTION_ARCHIVE_NAME \ +// "$GRADLE_DISTRIBUTION_NAME/lib/" | shasum -a 256 +val quickBuildDistLinkedJars = + mapOf( + "kotlin-compiler-embeddable-$KOTLIN_VERSION.jar" to + "d3e70fb011675e77ef00f1a68918d3cd91eed26058977d1e3ea2a37a293233af", + "kotlin-stdlib-$KOTLIN_VERSION.jar" to + "6f64eac736db9434dd6925b4a518b9d1d17177652320c37916cf9ba3ce7d7d7a", + "kotlin-build-tools-impl-$KOTLIN_VERSION.jar" to + "2cd70396404a0e43c05aaf79a8b35ff3a8ff2e296aedf770f6e5923fbfacae3b", + "kotlin-daemon-embeddable-$KOTLIN_VERSION.jar" to + "222fc10a3c22e74d30369bf8a0ca6c4f9372eac7d4a0081e290c256fa4d30fb2", + "kotlin-script-runtime-$KOTLIN_VERSION.jar" to + "ec61bf1229c837fa9f4255f34ca69816138b73158e7ae9b8d964cf92ca6dfd2e", + ) + +/** The distribution the hashes above were read from; see [quickBuildDistLinkedJars]. */ +val quickBuildDistHashesFrom = "9.6.1" + +// Checked here rather than trusted: every failure this prevents surfaces on device as a +// NoClassDefFoundError partway into the user's first compile, a long way from the version +// bump that caused it. +val quickBuildDistJarCheck = + tasks.register("quickBuildDistJarCheck") { val pinned = libs.versions.kotlin.daemon.compiler .get() + val expected = quickBuildDistLinkedJars + val distVersion = GRADLE_DISTRIBUTION_VERSION + val hashesFrom = quickBuildDistHashesFrom + val classpath = + files( + rootProject + .project(":quickbuild:daemon") + .configurations + .named("runtimeClasspath"), + ) + inputs.files(classpath) doLast { if (pinned != KOTLIN_VERSION) { throw GradleException( @@ -527,6 +567,59 @@ val quickBuildCompilerVersionCheck = "$GRADLE_DISTRIBUTION_NAME/lib/kotlin-compiler-embeddable-$KOTLIN_VERSION.jar at runtime.", ) } + if (distVersion != hashesFrom) { + throw GradleException( + "The Gradle distribution moved to $distVersion but the linked-jar hashes in " + + "app/build.gradle.kts were read from $hashesFrom. Re-derive them from the new " + + "distribution and move quickBuildDistHashesFrom, or the daemon may link a jar " + + "that is no longer the one it was built against.", + ) + } + + val resolved = classpath.files.associateBy { it.name } + val digest = MessageDigest.getInstance("SHA-256") + expected.forEach { (name, sha) -> + val jar = + resolved[name] ?: throw GradleException( + "$name is listed as linked from the Gradle distribution but is not on the " + + "daemon's runtime classpath. Excluding it from the daemon zip would drop a " + + "jar nothing puts back. Resolved: ${resolved.keys.sorted()}", + ) + digest.reset() + val actual = + jar.inputStream().use { input -> + val buffer = ByteArray(1 shl 16) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + digest.digest().joinToString("") { "%02x".format(it) } + } + if (actual != sha) { + throw GradleException( + "$name no longer matches the copy gradle-$distVersion ships " + + "(expected $sha, resolved $actual from $jar). Quick Build links the " + + "distribution's copy in place of this one, so they must be the same bytes. " + + "Either drop it from quickBuildDistLinkedJars and let the zip carry it, or " + + "re-derive the hash if the distribution genuinely changed.", + ) + } + } + logger.lifecycle("quickBuildDistJarCheck: verified ${expected.size} jars against gradle-$distVersion") + } + } + +// The names the stager links, travelling with the zip so the device side reads the build's +// list rather than repeating it. +val quickBuildDistLinkedJarList = + tasks.register("quickBuildDistLinkedJarList") { + val names = quickBuildDistLinkedJars.keys.sorted() + val listFile = layout.buildDirectory.file("intermediates/quickbuild/dist-linked-jars.txt") + inputs.property("names", names) + outputs.file(listFile) + doLast { + listFile.get().asFile.writeText(names.joinToString("\n", postfix = "\n")) } } @@ -534,18 +627,18 @@ val quickBuildDaemonZip = tasks.register("quickBuildDaemonZip") { archiveFileName.set("quickbuild-daemon.zip") destinationDirectory.set(layout.buildDirectory.dir("intermediates/quickbuild")) - dependsOn(quickBuildCompilerVersionCheck) + dependsOn(quickBuildDistJarCheck) val daemonProject = rootProject.project(":quickbuild:daemon") dependsOn(daemonProject.tasks.named("daemonJar")) from(daemonProject.tasks.named("daemonJar")) // The daemon jar's manifest Class-Path names these by file name; they must sit // next to the jar after extraction. from(daemonProject.configurations.named("runtimeClasspath")) - // ADFA-4931: except the compiler. The on-device Gradle distribution ships the same - // artifact at the same version (~57 MB of it), so shipping a second copy in the APK - // bought nothing. QuickBuildArtifactStager links the distribution's copy into the - // daemon dir under this name, which is what the manifest Class-Path still expects. - exclude("kotlin-compiler-embeddable-*.jar") + // ADFA-4931: except the ones the distribution already carries. The stager links those + // in at provision time, reading the list this task stages beside them. + val linked = quickBuildDistLinkedJars.keys + exclude { it.name in linked } + from(quickBuildDistLinkedJarList) // Compose compiler plugin, version-matched to the daemon's compiler; the stable // name is the contract EnvironmentQuickBuildPaths.composeCompilerPlugin reads. from(daemonProject.configurations.named("composeCompilerPlugin")) { diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt index d7efd133c8..5fb61a008e 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt @@ -4,7 +4,6 @@ import android.content.Context import androidx.core.content.pm.PackageInfoCompat import com.itsaky.androidide.utils.Environment import org.adfa.constants.GRADLE_DISTRIBUTION_NAME -import org.adfa.constants.KOTLIN_VERSION import org.slf4j.LoggerFactory import java.io.File import java.io.FileNotFoundException @@ -37,6 +36,9 @@ object QuickBuildArtifactStager { /** Written last, after a complete extraction, so a crash mid-extract leaves no stamp. */ internal const val DAEMON_STAMP_FILE = ".staged-for-install" + /** Written into the daemon zip by :app's `quickBuildDistLinkedJarList`. */ + internal const val LINKED_JARS_LIST = "dist-linked-jars.txt" + /** @throws IOException when an asset is missing or extraction fails. */ @Throws(IOException::class) fun stage( @@ -92,9 +94,9 @@ object QuickBuildArtifactStager { val count = extractDaemonZip(openZip(), daemonDir) // Before the stamp: the skip test above reads the stamp and the daemon jar, not the - // linked compiler, so stamping a staging whose link failed would skip re-staging forever + // linked jars, so stamping a staging whose link failed would skip re-staging forever // and leave the daemon without a compiler until the next install. - linkKotlinCompiler(daemonDir) + linkDistJars(daemonDir) stamp.writeText(installStamp) log.info("Staged {} daemon files into {}", count, daemonDir) return true @@ -141,43 +143,71 @@ object QuickBuildArtifactStager { } /** - * Put the Kotlin compiler where the daemon jar's manifest Class-Path expects it. + * Put the jars the APK deliberately does not carry where the daemon jar's manifest + * Class-Path expects them. + * + * ADFA-4931: the on-device Gradle distribution already ships these artifacts at the same + * versions, byte for byte - the compiler alone is ~57 MB - so a second copy in the daemon + * zip was pure duplication. `gradle-dists/` and `quickbuild/` are always on one filesystem + * (both under ``), so a symlink costs nothing; a copy is the fallback for + * a filesystem that refuses one. * - * The APK no longer carries one (ADFA-4931): the on-device Gradle distribution already - * ships the same artifact at the same version, so a second ~57 MB copy in the daemon zip - * was pure duplication. The two are always on one filesystem - `gradle-dists/` and - * `quickbuild/` are both under `` - so a symlink costs nothing; a copy is - * the fallback for a filesystem that refuses one. + * The names come from [LINKED_JARS_LIST], staged into the daemon dir by :app's + * `quickBuildDistLinkedJarList`, so the build's exclusion list and this link step cannot + * drift apart. * * @param gradleDists parameterised only so tests can point at a fake distribution; production * callers take the default. - * @throws FileNotFoundException when the distribution has not been extracted yet. There is - * deliberately no bundled fallback: the alternative to failing here is a - * NoClassDefFoundError partway into the user's first compile, which is far harder to read. + * @throws FileNotFoundException when the list is absent, or when the distribution has not + * been extracted yet. There is deliberately no bundled fallback: the alternative to + * failing here is a NoClassDefFoundError partway into the user's first compile, which is + * far harder to read. */ @Throws(IOException::class) - internal fun linkKotlinCompiler( + internal fun linkDistJars( daemonDir: File, gradleDists: File = Environment.GRADLE_DISTS, ) { - val jarName = "kotlin-compiler-embeddable-$KOTLIN_VERSION.jar" - val source = File(File(gradleDists, GRADLE_DISTRIBUTION_NAME), "lib/$jarName") - if (!source.isFile) { + val distLib = File(File(gradleDists, GRADLE_DISTRIBUTION_NAME), "lib") + for (jarName in readLinkedJarNames(daemonDir)) { + val source = File(distLib, jarName) + if (!source.isFile) { + throw FileNotFoundException( + "$jarName missing from the on-device Gradle distribution: $source. " + + "Quick Build loads it from there rather than from the APK.", + ) + } + + val target = File(daemonDir, jarName) + try { + Files.createSymbolicLink(target.toPath(), source.toPath()) + log.info("Linked {} -> {}", target, source) + } catch (e: Exception) { + // UnsupportedOperationException / IOException / SecurityException all mean the + // same thing here: no symlink, so pay the bytes. + log.warn("Symlink to {} failed ({}); copying instead", source, e.toString()) + source.inputStream().use { input -> target.outputStream().use { input.copyTo(it) } } + } + } + } + + /** + * The jar names the build left out of the zip for the distribution to supply. A missing or + * empty list means the zip was built without the staging task, which would otherwise show up + * as a first compile failing on a jar nobody linked. + */ + private fun readLinkedJarNames(daemonDir: File): List { + val list = File(daemonDir, LINKED_JARS_LIST) + if (!list.isFile) { throw FileNotFoundException( - "Kotlin compiler missing from the on-device Gradle distribution: $source. " + - "Quick Build loads the compiler from there rather than from the APK.", + "$LINKED_JARS_LIST missing from the staged daemon at $daemonDir. It names the jars " + + "Quick Build links from the Gradle distribution rather than shipping in the APK.", ) } - - val target = File(daemonDir, jarName) - try { - Files.createSymbolicLink(target.toPath(), source.toPath()) - log.info("Linked Kotlin compiler {} -> {}", target, source) - } catch (e: Exception) { - // UnsupportedOperationException / IOException / SecurityException all mean the same - // thing here: no symlink, so pay the bytes. - log.warn("Symlink to {} failed ({}); copying instead", source, e.toString()) - source.inputStream().use { input -> target.outputStream().use { input.copyTo(it) } } + val names = list.readLines().map(String::trim).filter(String::isNotEmpty) + if (names.isEmpty()) { + throw FileNotFoundException("$LINKED_JARS_LIST at $list names no jars") } + return names } } diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerCompilerLinkTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerCompilerLinkTest.kt deleted file mode 100644 index f41c69109d..0000000000 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerCompilerLinkTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.itsaky.androidide.quickbuild - -import com.google.common.truth.Truth.assertThat -import org.adfa.constants.GRADLE_DISTRIBUTION_NAME -import org.adfa.constants.KOTLIN_VERSION -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder -import java.io.File -import java.io.FileNotFoundException -import java.nio.file.Files - -/** - * ADFA-4931 removed the Kotlin compiler from the APK, so the daemon now gets it from the - * on-device Gradle distribution. That makes the link step load-bearing: if it silently does - * nothing, the daemon's manifest Class-Path names a jar that is not there and the first - * compile dies deep inside the Build Tools API. - */ -class QuickBuildArtifactStagerCompilerLinkTest { - @get:Rule - val temp = TemporaryFolder() - - private val jarName = "kotlin-compiler-embeddable-$KOTLIN_VERSION.jar" - - /** A fake `gradle-dists/` holding one distribution with a compiler jar of [content]. */ - private fun gradleDistsWithCompiler(content: String): File { - val dists = temp.newFolder("gradle-dists") - File(dists, "$GRADLE_DISTRIBUTION_NAME/lib").mkdirs() - File(dists, "$GRADLE_DISTRIBUTION_NAME/lib/$jarName").writeText(content) - return dists - } - - @Test - fun `compiler is readable from the daemon dir under the name the manifest expects`() { - val dists = gradleDistsWithCompiler("compiler-bytes") - val daemonDir = temp.newFolder("daemon") - - QuickBuildArtifactStager.linkKotlinCompiler(daemonDir, dists) - - // Asserting the CONTENT rather than mere existence: a symlink and a copy are both - // acceptable outcomes, and both must leave the bytes reachable at this path. - val staged = File(daemonDir, jarName) - assertThat(staged.readText()).isEqualTo("compiler-bytes") - } - - @Test - fun `the staged name carries the pinned Kotlin version`() { - val dists = gradleDistsWithCompiler("compiler-bytes") - val daemonDir = temp.newFolder("daemon") - - QuickBuildArtifactStager.linkKotlinCompiler(daemonDir, dists) - - // The daemon jar's manifest Class-Path is generated from the same resolved version, so - // the file name is a contract between the build and this staging step, not a detail. - assertThat(daemonDir.list()!!.toList()).containsExactly(jarName) - } - - @Test - fun `a link rather than a copy is preferred so the bytes are not duplicated on device`() { - val dists = gradleDistsWithCompiler("compiler-bytes") - val daemonDir = temp.newFolder("daemon") - - QuickBuildArtifactStager.linkKotlinCompiler(daemonDir, dists) - - // The whole point of ADFA-4931 is to stop carrying two copies. A copy is a legitimate - // fallback, but on any filesystem that supports links (every JVM this test runs on) we - // should not be paying the bytes twice. - val staged = File(daemonDir, jarName).toPath() - assertThat(Files.isSymbolicLink(staged)).isTrue() - } - - @Test - fun `missing distribution fails loudly and names the path a human has to fix`() { - val emptyDists = temp.newFolder("gradle-dists") - val daemonDir = temp.newFolder("daemon") - - val thrown = - runCatching { QuickBuildArtifactStager.linkKotlinCompiler(daemonDir, emptyDists) } - .exceptionOrNull() - - // Failing here is deliberate: the alternative is a NoClassDefFoundError partway into the - // user's first compile, a long way from the missing file that caused it. - assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) - assertThat(thrown!!).hasMessageThat().contains(GRADLE_DISTRIBUTION_NAME) - assertThat(thrown).hasMessageThat().contains(jarName) - assertThat(daemonDir.list()!!.toList()).isEmpty() - } - - @Test - fun `a distribution directory for a different version does not satisfy the link`() { - val dists = temp.newFolder("gradle-dists") - File(dists, "gradle-0.0.1/lib").mkdirs() - File(dists, "gradle-0.0.1/lib/$jarName").writeText("wrong distribution") - val daemonDir = temp.newFolder("daemon") - - // Guards the version-derived path: the compiler must come from the distribution this - // build actually bundles, not from whatever else happens to be extracted alongside it. - val thrown = - runCatching { QuickBuildArtifactStager.linkKotlinCompiler(daemonDir, dists) } - .exceptionOrNull() - - assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) - } -} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt new file mode 100644 index 0000000000..eef27b7d0b --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt @@ -0,0 +1,168 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import org.adfa.constants.GRADLE_DISTRIBUTION_NAME +import org.adfa.constants.KOTLIN_VERSION +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.FileNotFoundException +import java.nio.file.Files + +/** + * ADFA-4931 removed five jars from the APK - the Kotlin compiler and four smaller artifacts the + * on-device Gradle distribution already ships - so the daemon now gets them from the + * distribution. That makes the link step load-bearing: if it silently does nothing, the daemon's + * manifest Class-Path names jars that are not there and the first compile dies deep inside the + * Build Tools API. + */ +class QuickBuildArtifactStagerDistLinkTest { + @get:Rule + val temp = TemporaryFolder() + + /** + * Stand-ins for the real list, which the build generates. Two entries rather than one so a + * link step that handles only the first is visible; the compiler name is kept real because + * it is the one whose absence is most expensive. + */ + private val jarNames = + listOf( + "kotlin-compiler-embeddable-$KOTLIN_VERSION.jar", + "kotlin-stdlib-$KOTLIN_VERSION.jar", + ) + + /** A staged daemon dir whose list names [names], as the build's list task writes it. */ + private fun daemonDirListing(names: List = jarNames): File { + val daemonDir = temp.newFolder("daemon") + File(daemonDir, QuickBuildArtifactStager.LINKED_JARS_LIST) + .writeText(names.joinToString("\n", postfix = "\n")) + return daemonDir + } + + /** A fake `gradle-dists/` holding one distribution whose lib/ carries [names]. */ + private fun gradleDistsWith(names: List = jarNames): File { + val dists = temp.newFolder("gradle-dists") + val lib = File(dists, "$GRADLE_DISTRIBUTION_NAME/lib") + lib.mkdirs() + names.forEach { File(lib, it).writeText("$it-bytes") } + return dists + } + + private fun stagedNames(daemonDir: File) = daemonDir.list()!!.toList() - QuickBuildArtifactStager.LINKED_JARS_LIST + + @Test + fun `every listed jar is readable from the daemon dir under the name the manifest expects`() { + val daemonDir = daemonDirListing() + + QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith()) + + // Asserting the CONTENT rather than mere existence: a symlink and a copy are both + // acceptable outcomes, and both must leave the bytes reachable at this path. + jarNames.forEach { name -> + assertThat(File(daemonDir, name).readText()).isEqualTo("$name-bytes") + } + } + + @Test + fun `nothing beyond the listed jars is staged`() { + val daemonDir = daemonDirListing() + + // The distribution carries far more than the daemon links; linking the whole lib/ would + // bury the daemon's classpath in Gradle's own jars. + QuickBuildArtifactStager.linkDistJars( + daemonDir, + gradleDistsWith(jarNames + "gradle-core-api-9.6.1.jar"), + ) + + assertThat(stagedNames(daemonDir)).containsExactlyElementsIn(jarNames) + } + + @Test + fun `a link rather than a copy is preferred so the bytes are not duplicated on device`() { + val daemonDir = daemonDirListing() + + QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith()) + + // The whole point of ADFA-4931 is to stop carrying two copies. A copy is a legitimate + // fallback, but on any filesystem that supports links (every JVM this test runs on) we + // should not be paying the bytes twice. + jarNames.forEach { name -> + assertThat(Files.isSymbolicLink(File(daemonDir, name).toPath())).isTrue() + } + } + + @Test + fun `missing distribution fails loudly and names the path a human has to fix`() { + val daemonDir = daemonDirListing() + val emptyDists = temp.newFolder("gradle-dists") + + val thrown = + runCatching { QuickBuildArtifactStager.linkDistJars(daemonDir, emptyDists) } + .exceptionOrNull() + + // Failing here is deliberate: the alternative is a NoClassDefFoundError partway into the + // user's first compile, a long way from the missing file that caused it. + assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) + assertThat(thrown!!).hasMessageThat().contains(GRADLE_DISTRIBUTION_NAME) + assertThat(thrown).hasMessageThat().contains(jarNames.first()) + assertThat(stagedNames(daemonDir)).isEmpty() + } + + @Test + fun `one listed jar missing from the distribution fails the whole staging`() { + val daemonDir = daemonDirListing() + + // A partial link is the worst outcome: the daemon starts, and dies on whichever class + // happened to live in the jar that was skipped. + val thrown = + runCatching { + QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith(jarNames.take(1))) + }.exceptionOrNull() + + assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) + assertThat(thrown!!).hasMessageThat().contains(jarNames.last()) + } + + @Test + fun `a distribution directory for a different version does not satisfy the link`() { + val daemonDir = daemonDirListing() + val dists = temp.newFolder("gradle-dists") + File(dists, "gradle-0.0.1/lib").mkdirs() + jarNames.forEach { File(dists, "gradle-0.0.1/lib/$it").writeText("wrong distribution") } + + // Guards the version-derived path: the jars must come from the distribution this build + // actually bundles, not from whatever else happens to be extracted alongside it. + val thrown = + runCatching { QuickBuildArtifactStager.linkDistJars(daemonDir, dists) } + .exceptionOrNull() + + assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) + } + + @Test + fun `a daemon zip built without the list fails rather than linking nothing`() { + val daemonDir = temp.newFolder("daemon") + + // The list is how the build tells the device which jars it left out. Without it there is + // no way to tell "the build shipped everything" from "the build shipped nothing", and + // guessing the friendly answer means a compile that dies much later. + val thrown = + runCatching { QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith()) } + .exceptionOrNull() + + assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) + assertThat(thrown!!).hasMessageThat().contains(QuickBuildArtifactStager.LINKED_JARS_LIST) + } + + @Test + fun `an empty list is treated as a broken build, not as nothing to do`() { + val daemonDir = daemonDirListing(names = emptyList()) + + val thrown = + runCatching { QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith()) } + .exceptionOrNull() + + assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 20b11bb39a..7ae3d2c9d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,7 +23,7 @@ kotlin = "2.3.0" # The Quick Build daemon's compiler. Must equal org.adfa.constants.KOTLIN_VERSION -- the # Kotlin the on-device Gradle distribution embeds -- because the daemon loads that # distribution's kotlin-compiler-embeddable instead of shipping its own copy. :app's -# quickBuildCompilerVersionCheck asserts the two agree. +# quickBuildDistJarCheck asserts the two agree. kotlin-daemon-compiler = "2.3.21" kotlin-coroutines = "1.9.0" kotlinxCoroutinesCore = "1.10.2" From 524d9e6fa41e61d5c9b309e6ce8fa8437e33f70d Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 13 Aug 2026 05:20:33 -0700 Subject: [PATCH 05/22] ADFA-4931: follow the distribution's versions for the daemon's last three deps gson, asm and the coroutines the compiler drags in were each one minor version away from the copy gradle-9.6.1 ships. Moved onto the distribution's versions and they link like the rest, taking the daemon zip from 6,167,300 to 4,371,879 bytes - 4,371,879 against 61,954,394 where ADFA-4931 started. The versions are not ours to pick any more, so the build says so. gson gets its own catalog entry rather than sharing the app-wide google-gson, because an app-side bump would otherwise silently break the link; asm has no other consumer, so its existing entry moves and carries the reason; coroutines arrives transitively and is pinned by a constraint naming the -jvm platform module, since the compiler depends on that directly and a constraint on the KMP root does not govern it. That last one is worth recording because the gate caught it: the first attempt constrained the root, resolution stayed on 1.8.0, and quickBuildDistJarCheck refused the build rather than letting a jar be excluded that nothing would put back. Suites after the bump: 2,031 tests, 0 failures - protocol, core and daemon included, since gson sits on the wire codec between them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE --- app/build.gradle.kts | 9 +++++++++ gradle/libs.versions.toml | 13 ++++++++++++- quickbuild/daemon/build.gradle.kts | 14 +++++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1e43bb3999..a6b1a91195 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -534,6 +534,15 @@ val quickBuildDistLinkedJars = "222fc10a3c22e74d30369bf8a0ca6c4f9372eac7d4a0081e290c256fa4d30fb2", "kotlin-script-runtime-$KOTLIN_VERSION.jar" to "ec61bf1229c837fa9f4255f34ca69816138b73158e7ae9b8d964cf92ca6dfd2e", + // These three carry their own version rather than KOTLIN_VERSION, and the daemon pins + // each to what the distribution ships (libs.versions.toml, :quickbuild:daemon) purely + // so it can be linked from there. + "kotlinx-coroutines-core-jvm-1.10.2.jar" to + "5ca175b38df331fd64155b35cd8cae1251fa9ee369709b36d42e0a288ccce3fd", + "gson-2.13.1.jar" to + "94855942d4992f112946d3de1c334e709237b8126d8130bf07807c018a4a2120", + "asm-9.9.jar" to + "03d99a74ad1ee5c71334ef67437f4ef4fe3488caa7c96d8645abc73c8e2017d4", ) /** The distribution the hashes above were read from; see [quickBuildDistLinkedJars]. */ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ae3d2c9d6..dc3f7d1f1c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -247,6 +247,15 @@ androidx-work-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = " # Google google-material = { module = "com.google.android.material:material", version = "1.12.0" } google-gson = { module = "com.google.code.gson:gson", version = "2.12.1" } +# Quick Build's daemon runs its own JVM and takes this from the on-device Gradle +# distribution rather than the APK, so its version is the distribution's choice, not ours +# (:app's quickBuildDistJarCheck asserts the bytes match). Kept separate from google-gson +# above so an app-wide bump cannot silently break that link. +gson-quickBuildDaemon = { module = "com.google.code.gson:gson", version = "2.13.1" } +# Same story for the coroutines the Kotlin compiler pulls in transitively. Names the -jvm +# platform module rather than the KMP root because that is what the compiler depends on +# directly, and a constraint on the root would not govern it. +kotlinx-coroutines-core-jvm-quickBuildDaemon = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm", version = "1.10.2" } google-guava = { module = "com.google.guava:guava", version = "33.4.0-android" } google-auto-value-annotations = { module = "com.google.auto.value:auto-value-annotations", version = "1.11.0" } google-auto-value-ap = { module = "com.google.auto.value:auto-value", version = "1.11.0" } @@ -281,7 +290,9 @@ kotlin-buildToolsImpl = { module = "org.jetbrains.kotlin:kotlin-build-tools-impl kotlin-composeCompilerPluginEmbeddable = { module = "org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable", version.ref = "kotlin-daemon-compiler" } # Compose runtime for the daemon's compose compile tests (see :quickbuild:daemon) composeRuntimeDaemonTests = { module = "androidx.compose.runtime:runtime-android", version = "1.7.3" } -ow2-asm = { module = "org.ow2.asm:asm", version = "9.7.1" } +# Only :quickbuild:daemon uses this, and it takes the jar from the on-device Gradle +# distribution rather than the APK - so the version is the distribution's, not ours. +ow2-asm = { module = "org.ow2.asm:asm", version = "9.9" } # Tests tests-junit = { module = "junit:junit", version = "4.13.2" } diff --git a/quickbuild/daemon/build.gradle.kts b/quickbuild/daemon/build.gradle.kts index 6d390a56f6..1478ea4da6 100644 --- a/quickbuild/daemon/build.gradle.kts +++ b/quickbuild/daemon/build.gradle.kts @@ -103,9 +103,21 @@ dependencies { api(projects.quickbuild.protocol) implementation(libs.kotlin.buildToolsApi) - implementation(libs.google.gson) + // gson and asm follow the on-device Gradle distribution rather than the app: ADFA-4931 + // links both from there instead of shipping them in the APK, so the version is the + // distribution's choice. Hence gson-quickBuildDaemon and not the app-wide google-gson. + implementation(libs.gson.quickBuildDaemon) // ACC_FINAL stripping on recompiled payload classes (proxies extend user classes). implementation(libs.ow2.asm) + + constraints { + // kotlin-compiler-embeddable asks for coroutines 1.8.0; the distribution runs that same + // compiler against 1.10.2, which is both why the upgrade is safe and what makes the jar + // linkable at all. + runtimeOnly(libs.kotlinx.coroutines.core.jvm.quickBuildDaemon) { + because("the on-device Gradle distribution ships this version, which ADFA-4931 links") + } + } // The BTA implementation + its runtime deps are staged alongside the daemon jar on // device and loaded from there. The compiler itself is the exception: ADFA-4931 // excludes it from the zip and links the on-device Gradle distribution's copy in, so From 81f4d972713fdd71a1d508c6fcbd573d3b9b5ecb Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 13 Aug 2026 05:56:53 -0700 Subject: [PATCH 06/22] ADFA-4931: collapse jars the two on-device stores both extracted The offline Maven repo and the Gradle distribution are harvested separately and overlap by construction: a user project resolves its Kotlin plugin from the repo, and the distribution embeds the same Kotlin to run its own builds. Once the toolchain bump lands they will hold the same kotlin-compiler-embeddable, ~60 MB of identical bytes on a phone that has little to spare. DistJarDeduplicator runs from BaseAssetsInstaller.postInstall - the one hook both installers reach after everything is extracted, and where the build-tools exec bits are already fixed up - and replaces a Maven-repo jar with a symlink to the distribution's copy. Identity is the bytes, never the name. The two stores version independently, so a matching file name proves nothing, and linking a same-named jar that is not the same jar would break a user's build a long way from the cause. Size is only a pre-filter; the decision is SHA-256. That is also what makes this safe to land before the regenerated Maven repo exists: today it collapses three small jars totalling 808,072 bytes, and it will collapse the compiler the moment the bytes match, with no further change here. Two deliberate refusals to fail. A missing distribution, an unreadable jar, or a filesystem that refuses symlinks all leave the duplicate on disk - this buys space, not correctness, and no one should lose an install over it. And the link is built beside the file and moved over it, so a failure part-way leaves the original copy exactly where it was. JarLinks now holds the symlink attempt the deduplicator shares with QuickBuildArtifactStager. Only the attempt: the stager must end up with the bytes, so it copies when it cannot link, while the deduplicator already has them and leaves the copy alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE --- .../androidide/assets/BaseAssetsInstaller.kt | 19 +- .../androidide/assets/DistJarDeduplicator.kt | 134 ++++++++++++ .../com/itsaky/androidide/assets/JarLinks.kt | 39 ++++ .../quickbuild/QuickBuildArtifactStager.kt | 13 +- .../assets/DistJarDeduplicatorTest.kt | 192 ++++++++++++++++++ 5 files changed, 387 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/assets/DistJarDeduplicator.kt create mode 100644 app/src/main/java/com/itsaky/androidide/assets/JarLinks.kt create mode 100644 app/src/test/java/com/itsaky/androidide/assets/DistJarDeduplicatorTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/assets/BaseAssetsInstaller.kt b/app/src/main/java/com/itsaky/androidide/assets/BaseAssetsInstaller.kt index 455606c22d..637ff66baf 100644 --- a/app/src/main/java/com/itsaky/androidide/assets/BaseAssetsInstaller.kt +++ b/app/src/main/java/com/itsaky/androidide/assets/BaseAssetsInstaller.kt @@ -4,6 +4,9 @@ import android.content.Context import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FeatureFlags import com.termux.shared.termux.TermuxConstants +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.adfa.constants.GRADLE_DISTRIBUTION_NAME import org.slf4j.LoggerFactory import java.io.File import java.nio.file.Path @@ -22,13 +25,23 @@ abstract class BaseAssetsInstaller : AssetsInstaller { "aapt", "aapt2", "aidl", - "apksigner", - "d8", + "apksigner", + "d8", "dexdump", "split-select", "zipalign", )) { Environment.setExecutable(Environment.BUILD_TOOLS_DIR.resolve(bin)) } - } + + // Both stores are on disk by now, which is the first point their overlap can be seen. + // This also runs after a FAILED install (the caller's finally), so the tree may be half + // extracted - safe, because a partial jar cannot hash equal to the distribution's. + withContext(Dispatchers.IO) { + DistJarDeduplicator.deduplicate( + mavenRepo = Environment.LOCAL_MAVEN_DIR, + distLib = File(Environment.GRADLE_DISTS, "$GRADLE_DISTRIBUTION_NAME/lib"), + ) + } + } } diff --git a/app/src/main/java/com/itsaky/androidide/assets/DistJarDeduplicator.kt b/app/src/main/java/com/itsaky/androidide/assets/DistJarDeduplicator.kt new file mode 100644 index 0000000000..61d227b03c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/assets/DistJarDeduplicator.kt @@ -0,0 +1,134 @@ +package com.itsaky.androidide.assets + +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest + +/** + * Replaces jars in the extracted offline Maven repo with symlinks to the identical jar in the + * extracted Gradle distribution, once both are on disk. + * + * The two stores are harvested independently and overlap by construction: a user project resolves + * its Kotlin plugin from the Maven repo, and the distribution embeds the same Kotlin to run its own + * builds. Post-toolchain-bump the largest single overlap is `kotlin-compiler-embeddable`, around + * 60 MB of identical bytes on a device that has little to spare. + * + * **Identity is decided by content, never by name.** The two stores version independently, so a + * matching file name means nothing on its own - and linking a same-named jar that is not the same + * jar would corrupt a user's build in a way that surfaces far from here. Size is only a cheap + * pre-filter; the decision is SHA-256. + * + * This is a disk-space optimisation, not a correctness requirement, so it never fails an install. + * A missing distribution, an unreadable jar, or a filesystem that refuses symlinks all mean the + * duplicate simply stays on disk. + */ +object DistJarDeduplicator { + private val log = LoggerFactory.getLogger("QB-DistDedup") + + /** Result of one pass, for logging and for the tests to assert on. */ + data class Outcome( + val linked: Int, + val bytesReclaimed: Long, + ) + + /** + * @param mavenRepo the extracted `localMvnRepository` tree, walked recursively. + * @param distLib the extracted distribution's `lib/`, whose jars are the link targets. + */ + fun deduplicate( + mavenRepo: File, + distLib: File, + ): Outcome { + if (!mavenRepo.isDirectory || !distLib.isDirectory) { + log.info("Nothing to deduplicate: mavenRepo={} distLib={}", mavenRepo, distLib) + return Outcome(0, 0) + } + + // Size buckets first: hashing 200 MB of Maven repo against 150 MB of distribution would + // cost more than the disk it saves, and a size mismatch already rules a pair out. + val distBySize = mutableMapOf>() + distLib.listFiles()?.forEach { candidate -> + if (candidate.isFile && candidate.name.endsWith(".jar")) { + distBySize.getOrPut(candidate.length()) { mutableListOf() }.add(candidate) + } + } + if (distBySize.isEmpty()) { + log.info("No jars in {}", distLib) + return Outcome(0, 0) + } + + val hashes = mutableMapOf() + var linked = 0 + var reclaimed = 0L + + mavenRepo.walkTopDown().forEach { candidate -> + if (!candidate.isFile || !candidate.name.endsWith(".jar")) return@forEach + // Already linked by an earlier pass; installs re-extract and re-run, so this is the + // common case on every install after the first. + if (Files.isSymbolicLink(candidate.toPath())) return@forEach + + val sameSize = distBySize[candidate.length()] ?: return@forEach + val candidateHash = hashes.getOrPut(candidate) { sha256(candidate) } ?: return@forEach + val twin = + sameSize.firstOrNull { hashes.getOrPut(it) { sha256(it) } == candidateHash } + ?: return@forEach + + if (replaceWithLink(candidate, twin)) { + linked++ + reclaimed += twin.length() + } + } + + log.info("Linked {} duplicate jars to {}, reclaiming {} bytes", linked, distLib, reclaimed) + return Outcome(linked, reclaimed) + } + + /** + * Swap [duplicate] for a symlink to [source] without ever having neither on disk: the link is + * built beside the file and moved over it, so a failure at any point leaves the original copy + * exactly where it was. + */ + private fun replaceWithLink( + duplicate: File, + source: File, + ): Boolean { + val staging = File(duplicate.parentFile, "${duplicate.name}.dedup") + staging.delete() + if (!JarLinks.trySymlink(staging, source)) { + return false + } + return try { + Files.move( + staging.toPath(), + duplicate.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + true + } catch (e: IOException) { + log.warn("Could not swap {} for a link to {} ({})", duplicate, source, e.toString()) + staging.delete() + false + } + } + + private fun sha256(file: File): String? = + try { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(1 shl 16) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + digest.digest().joinToString("") { "%02x".format(it) } + } catch (e: IOException) { + log.warn("Could not read {} ({})", file, e.toString()) + null + } +} diff --git a/app/src/main/java/com/itsaky/androidide/assets/JarLinks.kt b/app/src/main/java/com/itsaky/androidide/assets/JarLinks.kt new file mode 100644 index 0000000000..fe9328c4ef --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/assets/JarLinks.kt @@ -0,0 +1,39 @@ +package com.itsaky.androidide.assets + +import org.slf4j.LoggerFactory +import java.io.File +import java.nio.file.Files + +/** + * Symlinking one staged jar at another, for the two places that deduplicate jars across the + * on-device tool stores: [com.itsaky.androidide.quickbuild.QuickBuildArtifactStager], which links + * jars the APK deliberately does not carry, and [DistJarDeduplicator], which collapses copies that + * were extracted twice. + * + * The two want different things when a link is impossible - the stager must have the bytes, so it + * copies; the deduplicator already has them, so it leaves the copy alone - which is why what is + * shared here is the attempt, not the fallback. + */ +internal object JarLinks { + private val log = LoggerFactory.getLogger("QB-JarLinks") + + /** + * Try to make [target] a symlink to [source]. + * + * @return false when the filesystem refuses links, leaving [target] untouched. Callers decide + * what that costs them. + */ + fun trySymlink( + target: File, + source: File, + ): Boolean = + try { + Files.createSymbolicLink(target.toPath(), source.toPath()) + true + } catch (e: Exception) { + // UnsupportedOperationException / IOException / SecurityException all mean the same + // thing here: no symlink on this filesystem. + log.warn("Symlink {} -> {} failed ({})", target, source, e.toString()) + false + } +} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt index 5fb61a008e..3caa4f4212 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.quickbuild import android.content.Context import androidx.core.content.pm.PackageInfoCompat +import com.itsaky.androidide.assets.JarLinks import com.itsaky.androidide.utils.Environment import org.adfa.constants.GRADLE_DISTRIBUTION_NAME import org.slf4j.LoggerFactory @@ -9,7 +10,6 @@ import java.io.File import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream -import java.nio.file.Files import java.util.zip.ZipInputStream /** @@ -179,13 +179,12 @@ object QuickBuildArtifactStager { } val target = File(daemonDir, jarName) - try { - Files.createSymbolicLink(target.toPath(), source.toPath()) + if (JarLinks.trySymlink(target, source)) { log.info("Linked {} -> {}", target, source) - } catch (e: Exception) { - // UnsupportedOperationException / IOException / SecurityException all mean the - // same thing here: no symlink, so pay the bytes. - log.warn("Symlink to {} failed ({}); copying instead", source, e.toString()) + } else { + // The daemon has to have these bytes one way or another, so a filesystem that + // refuses links costs us the copy. + log.info("Copying {} into the daemon dir instead", source) source.inputStream().use { input -> target.outputStream().use { input.copyTo(it) } } } } diff --git a/app/src/test/java/com/itsaky/androidide/assets/DistJarDeduplicatorTest.kt b/app/src/test/java/com/itsaky/androidide/assets/DistJarDeduplicatorTest.kt new file mode 100644 index 0000000000..a481c90bae --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/assets/DistJarDeduplicatorTest.kt @@ -0,0 +1,192 @@ +package com.itsaky.androidide.assets + +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files + +/** + * The offline Maven repo and the Gradle distribution are harvested independently and overlap by + * construction, most expensively at `kotlin-compiler-embeddable`. Collapsing that overlap is only + * safe if identity is decided by content: the two stores version independently, so a shared file + * name proves nothing, and linking a same-named jar that is not the same jar would break a user's + * build a long way from here. + */ +class DistJarDeduplicatorTest { + @get:Rule + val temp = TemporaryFolder() + + private lateinit var mavenRepo: File + private lateinit var distLib: File + + private fun setUpTrees() { + mavenRepo = temp.newFolder("localMvnRepository") + distLib = temp.newFolder("gradle-dist-lib") + } + + /** A jar at a realistic Maven coordinate path, since the walk has to recurse to find it. */ + private fun mavenJar( + coordinate: String, + name: String, + content: String, + ): File { + val dir = File(mavenRepo, coordinate) + dir.mkdirs() + return File(dir, name).apply { writeText(content) } + } + + private fun distJar( + name: String, + content: String, + ): File = File(distLib, name).apply { writeText(content) } + + @Test + fun `an identical jar becomes a link to the distribution copy`() { + setUpTrees() + val duplicate = + mavenJar("org/jetbrains/kotlin/kotlin-stdlib/2.3.21", "kotlin-stdlib-2.3.21.jar", "same-bytes") + val source = distJar("kotlin-stdlib-2.3.21.jar", "same-bytes") + + val outcome = DistJarDeduplicator.deduplicate(mavenRepo, distLib) + + assertThat(Files.isSymbolicLink(duplicate.toPath())).isTrue() + // Content still reachable at the Maven coordinate: Gradle resolves through this path and + // must not be able to tell the difference. + assertThat(duplicate.readText()).isEqualTo("same-bytes") + assertThat(Files.readSymbolicLink(duplicate.toPath()).toFile().canonicalFile) + .isEqualTo(source.canonicalFile) + assertThat(outcome.linked).isEqualTo(1) + assertThat(outcome.bytesReclaimed).isEqualTo("same-bytes".length.toLong()) + } + + @Test + fun `a same-named jar with different content is left alone`() { + setUpTrees() + // The case that makes name-matching unsafe, and the reason the pre-filter is size while + // the decision is the hash: same name, same length, different bytes. + val duplicate = + mavenJar("org/jetbrains/kotlin/kotlin-stdlib/2.3.21", "kotlin-stdlib-2.3.21.jar", "aaaaaaaaa") + distJar("kotlin-stdlib-2.3.21.jar", "bbbbbbbbb") + + val outcome = DistJarDeduplicator.deduplicate(mavenRepo, distLib) + + assertThat(Files.isSymbolicLink(duplicate.toPath())).isFalse() + assertThat(duplicate.readText()).isEqualTo("aaaaaaaaa") + assertThat(outcome.linked).isEqualTo(0) + } + + @Test + fun `identical content under a different name is still linked`() { + setUpTrees() + // Identity is the bytes, not the coordinate. A repackaged-but-identical artifact under + // another name is just as safe to collapse. + val duplicate = mavenJar("com/example/thing/1.0", "thing-1.0.jar", "identical") + distJar("some-other-name.jar", "identical") + + val outcome = DistJarDeduplicator.deduplicate(mavenRepo, distLib) + + assertThat(Files.isSymbolicLink(duplicate.toPath())).isTrue() + assertThat(outcome.linked).isEqualTo(1) + } + + @Test + fun `a jar with no counterpart is left alone`() { + setUpTrees() + val untouched = mavenJar("com/example/only-here/1.0", "only-here-1.0.jar", "unique-bytes") + distJar("something-else.jar", "different length entirely") + + val outcome = DistJarDeduplicator.deduplicate(mavenRepo, distLib) + + assertThat(Files.isSymbolicLink(untouched.toPath())).isFalse() + assertThat(untouched.readText()).isEqualTo("unique-bytes") + assertThat(outcome.linked).isEqualTo(0) + } + + @Test + fun `a missing distribution is a no-op rather than a failure`() { + setUpTrees() + val untouched = mavenJar("com/example/thing/1.0", "thing-1.0.jar", "bytes") + + // An install must not fail because a space optimisation had nothing to work with. + val outcome = DistJarDeduplicator.deduplicate(mavenRepo, File(temp.root, "never-extracted")) + + assertThat(outcome.linked).isEqualTo(0) + assertThat(untouched.readText()).isEqualTo("bytes") + } + + @Test + fun `a missing maven repo is a no-op rather than a failure`() { + setUpTrees() + distJar("kotlin-stdlib-2.3.21.jar", "bytes") + + val outcome = DistJarDeduplicator.deduplicate(File(temp.root, "never-extracted"), distLib) + + assertThat(outcome.linked).isEqualTo(0) + } + + @Test + fun `a second pass over an already-linked tree changes nothing`() { + setUpTrees() + val duplicate = mavenJar("com/example/thing/1.0", "thing-1.0.jar", "same-bytes") + distJar("thing-1.0.jar", "same-bytes") + + val first = DistJarDeduplicator.deduplicate(mavenRepo, distLib) + val second = DistJarDeduplicator.deduplicate(mavenRepo, distLib) + + // Every install re-runs this, so the steady state is "already linked". Re-linking a link + // would be harmless but re-counting it would make the reclaimed figure a fiction. + assertThat(first.linked).isEqualTo(1) + assertThat(second.linked).isEqualTo(0) + assertThat(second.bytesReclaimed).isEqualTo(0) + assertThat(duplicate.readText()).isEqualTo("same-bytes") + } + + @Test + fun `non-jar files are never touched`() { + setUpTrees() + val pom = mavenJar("com/example/thing/1.0", "thing-1.0.pom", "same-bytes") + distJar("thing-1.0.jar", "same-bytes") + + val outcome = DistJarDeduplicator.deduplicate(mavenRepo, distLib) + + // POMs and .module files are small and Gradle reads them constantly; there is nothing to + // win and a needless way to break resolution. + assertThat(Files.isSymbolicLink(pom.toPath())).isFalse() + assertThat(outcome.linked).isEqualTo(0) + } + + @Test + fun `several duplicates in one pass are all collapsed and counted`() { + setUpTrees() + val one = mavenJar("a/b/1.0", "one-1.0.jar", "first-content") + val two = mavenJar("c/d/2.0", "two-2.0.jar", "second-content-longer") + val notADuplicate = mavenJar("e/f/3.0", "three-3.0.jar", "no twin here at all") + distJar("one-1.0.jar", "first-content") + distJar("two-2.0.jar", "second-content-longer") + + val outcome = DistJarDeduplicator.deduplicate(mavenRepo, distLib) + + assertThat(Files.isSymbolicLink(one.toPath())).isTrue() + assertThat(Files.isSymbolicLink(two.toPath())).isTrue() + assertThat(Files.isSymbolicLink(notADuplicate.toPath())).isFalse() + assertThat(outcome.linked).isEqualTo(2) + assertThat(outcome.bytesReclaimed) + .isEqualTo(("first-content".length + "second-content-longer".length).toLong()) + } + + @Test + fun `no staging leftovers survive a pass`() { + setUpTrees() + mavenJar("com/example/thing/1.0", "thing-1.0.jar", "same-bytes") + distJar("thing-1.0.jar", "same-bytes") + + DistJarDeduplicator.deduplicate(mavenRepo, distLib) + + // The swap builds the link beside the file before moving it over; a leftover .dedup entry + // would mean a partial swap that a later pass could trip over. + val strays = mavenRepo.walkTopDown().filter { it.name.endsWith(".dedup") }.toList() + assertThat(strays).isEmpty() + } +} From 1666b3282baf2d471f7bfbb88a04d2ccb0dc2968 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 18 Sep 2026 00:48:48 -0700 Subject: [PATCH 07/22] ADFA-4931: pin that staging runs the link, not just that the link works QuickBuildArtifactStagerDistLinkTest calls linkDistJars directly, so its five tests stayed green through a rebase that left the production call unreachable behind a `return`. They pin the link; nothing pinned the wiring. The new test asserts from stageDaemonIfNeeded -- the only path that reaches the link in production -- that the listed jar is readable in the daemon dir afterwards. Watched go red with the call removed: java.io.FileNotFoundException: .../home/daemon/ kotlin-compiler-embeddable-2.3.21.jar (No such file or directory) One of ten, which is the point: the other nine pass either way. stageDaemonIfNeeded takes gradleDists for the same reason linkDistJars already did -- Environment.GRADLE_DISTS is a mutable static that is null until Environment.init() runs, so an off-device test has nowhere to link from. The existing staging tests needed it once the call moved onto their path; they were passing only because the call was dead. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XN91S41dghWHoXAoWhJAnm --- .../quickbuild/QuickBuildArtifactStager.kt | 5 +- .../QuickBuildArtifactStagerTest.kt | 56 ++++++++++++++++--- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt index 3caa4f4212..16e0dc5dfd 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt @@ -73,6 +73,8 @@ object QuickBuildArtifactStager { * [installStamp] - the stamp file matches and [daemonJar] is present. Internal so the JVM * test can watch the skip, and the wipe, without an Android [Context]. * + * @param gradleDists passed through to [linkDistJars]; parameterised only so tests can point + * at a fake distribution, since [Environment.GRADLE_DISTS] is unset off-device. * @return whether an extraction ran. */ @Throws(IOException::class) @@ -80,6 +82,7 @@ object QuickBuildArtifactStager { installStamp: String, daemonDir: File, daemonJar: File, + gradleDists: File = Environment.GRADLE_DISTS, openZip: () -> InputStream, ): Boolean { val stamp = File(daemonDir, DAEMON_STAMP_FILE) @@ -96,7 +99,7 @@ object QuickBuildArtifactStager { // Before the stamp: the skip test above reads the stamp and the daemon jar, not the // linked jars, so stamping a staging whose link failed would skip re-staging forever // and leave the daemon without a compiler until the next install. - linkDistJars(daemonDir) + linkDistJars(daemonDir, gradleDists) stamp.writeText(installStamp) log.info("Staged {} daemon files into {}", count, daemonDir) return true diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt index 5398da20cf..b943d12520 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt @@ -1,6 +1,8 @@ package com.itsaky.androidide.quickbuild import com.google.common.truth.Truth.assertThat +import org.adfa.constants.GRADLE_DISTRIBUTION_NAME +import org.adfa.constants.KOTLIN_VERSION import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -105,10 +107,27 @@ class QuickBuildArtifactStagerTest { assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) } + /** The one jar [daemonZip]'s linked-jars list names, kept real because the link uses the name. */ + private val linkedJar = "kotlin-compiler-embeddable-$KOTLIN_VERSION.jar" + + /** + * A fake `gradle-dists/` holding the jar the staged list names. Staging links rather than + * bundles these (ADFA-4931), and `Environment.GRADLE_DISTS` is unset off-device, so every + * staging test has to point the link somewhere real. + */ + private val gradleDists: File by lazy { + val dists = tmp.newFolder("gradle-dists") + val lib = File(dists, "$GRADLE_DISTRIBUTION_NAME/lib") + assertThat(lib.mkdirs()).isTrue() + File(lib, linkedJar).writeText("linked-jar-bytes") + dists + } + private fun daemonZip(): ByteArrayInputStream = zipOf( "quickbuild-daemon.jar" to byteArrayOf(1, 2, 3), "lib/runtime.jar" to byteArrayOf(4, 5), + QuickBuildArtifactStager.LINKED_JARS_LIST to "$linkedJar\n".toByteArray(), ) @Test @@ -118,7 +137,7 @@ class QuickBuildArtifactStagerTest { var opened = 0 val ran = - QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar) { + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { opened++ daemonZip() } @@ -129,17 +148,36 @@ class QuickBuildArtifactStagerTest { assertThat(File(daemonDir, QuickBuildArtifactStager.DAEMON_STAMP_FILE).readText()).isEqualTo("7:1000") } + /** + * The wiring, not the link itself - [QuickBuildArtifactStagerDistLinkTest] covers + * `linkDistJars` directly, and passed throughout a rebase that left the call unreachable + * behind a `return`. Staging is the only path that runs it in production, so this asserts + * from there: the APK does not carry this jar, so if staging does not put it here the daemon + * jar's manifest Class-Path names a file that is not on disk. + */ + @Test + fun `staging links the distribution's jars into the daemon dir`() { + val daemonDir = File(tmp.newFolder("home"), "daemon") + val jar = File(daemonDir, "quickbuild-daemon.jar") + + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { daemonZip() } + + // Content rather than existence: a symlink and a copy are both acceptable, and both must + // leave the bytes readable at this path. + assertThat(File(daemonDir, linkedJar).readText()).isEqualTo("linked-jar-bytes") + } + @Test fun `a second stage for the same install leaves the directory untouched`() { val daemonDir = File(tmp.newFolder("home"), "daemon") val jar = File(daemonDir, "quickbuild-daemon.jar") - QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar) { daemonZip() } + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { daemonZip() } // A file the running daemon could depend on: gone means the directory was wiped. val planted = File(daemonDir, "opened-by-a-live-daemon.jar").apply { writeBytes(byteArrayOf(9)) } var opened = 0 val ran = - QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar) { + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { opened++ daemonZip() } @@ -153,10 +191,10 @@ class QuickBuildArtifactStagerTest { fun `a new install re-stages from scratch`() { val daemonDir = File(tmp.newFolder("home"), "daemon") val jar = File(daemonDir, "quickbuild-daemon.jar") - QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar) { daemonZip() } + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { daemonZip() } val stale = File(daemonDir, "from-the-old-install.jar").apply { writeBytes(byteArrayOf(9)) } - val ran = QuickBuildArtifactStager.stageDaemonIfNeeded("7:2000", daemonDir, jar) { daemonZip() } + val ran = QuickBuildArtifactStager.stageDaemonIfNeeded("7:2000", daemonDir, jar, gradleDists) { daemonZip() } assertThat(ran).isTrue() assertThat(stale.exists()).isFalse() @@ -167,10 +205,10 @@ class QuickBuildArtifactStagerTest { fun `a matching stamp without the daemon jar re-stages`() { val daemonDir = File(tmp.newFolder("home"), "daemon") val jar = File(daemonDir, "quickbuild-daemon.jar") - QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar) { daemonZip() } + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { daemonZip() } assertThat(jar.delete()).isTrue() - val ran = QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar) { daemonZip() } + val ran = QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { daemonZip() } assertThat(ran).isTrue() assertThat(jar.exists()).isTrue() @@ -183,12 +221,12 @@ class QuickBuildArtifactStagerTest { val thrown = runCatching { - QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar) { zipOf("lib/" to null) } + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { zipOf("lib/" to null) } }.exceptionOrNull() assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) assertThat(File(daemonDir, QuickBuildArtifactStager.DAEMON_STAMP_FILE).exists()).isFalse() - val ran = QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar) { daemonZip() } + val ran = QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { daemonZip() } assertThat(ran).isTrue() } } From f4a348220ced9015948036657b280b5b167c9315 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 18 Sep 2026 13:30:02 -0700 Subject: [PATCH 08/22] ADFA-4931: cover the dedup and link paths' remaining branches Six tests, no production code touched. Branch coverage on the lines this change adds goes 35/44 (79.5%) to 42/44 (95.5%); line 85/101 (84.2%) to 94/101 (93.1%). Each was watched failing first against a mutant confirmed applied by diff and confirmed compiling before the result was read - a mutant with invalid syntax prints no result line, and one whose edit silently did not apply prints a green one, and both read as "the tests do not catch this". The two branches left uncovered are named with their reasons. One is the tableswitch default arm of the compiler-generated invokeSuspend in BaseAssetsInstaller - the "call to 'resume' before 'invoke'" guard Kotlin emits for every suspend function, which no test can reach. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XN91S41dghWHoXAoWhJAnm --- .../BaseAssetsInstallerPostInstallTest.kt | 181 ++++++++++++++++++ .../assets/DistJarDeduplicatorTest.kt | 74 +++++++ .../itsaky/androidide/assets/JarLinksTest.kt | 53 +++++ .../QuickBuildArtifactStagerDistLinkTest.kt | 17 ++ 4 files changed, 325 insertions(+) create mode 100644 app/src/test/java/com/itsaky/androidide/assets/BaseAssetsInstallerPostInstallTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/assets/JarLinksTest.kt diff --git a/app/src/test/java/com/itsaky/androidide/assets/BaseAssetsInstallerPostInstallTest.kt b/app/src/test/java/com/itsaky/androidide/assets/BaseAssetsInstallerPostInstallTest.kt new file mode 100644 index 0000000000..838d07b3bf --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/assets/BaseAssetsInstallerPostInstallTest.kt @@ -0,0 +1,181 @@ +package com.itsaky.androidide.assets + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.itsaky.androidide.app.configuration.CpuArch +import com.itsaky.androidide.utils.Environment +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.adfa.constants.GRADLE_DISTRIBUTION_NAME +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files +import java.nio.file.Path + +/** + * [BaseAssetsInstaller.postInstall] is wiring, and wiring is where a silent no-op hides: every + * failure here looks like a successful install. A dedup pointed at the wrong directory links + * nothing and logs "nothing to deduplicate"; a build-tools binary missed by the list stays + * non-executable until a build fails a long way downstream. [DistJarDeduplicatorTest] pins the + * dedup algorithm against paths it is handed, so what is left to pin - and all this file pins - + * is that postInstall hands it the paths the extractor actually wrote to, and that the binary + * list is the one the toolchain needs. + * + * The `Environment` statics are unset off-device, so each test sets them at temp directories and + * restores them afterwards, the same way [com.itsaky.androidide.quickbuild.EnvironmentQuickBuildPathsD8Test] + * does. + */ +class BaseAssetsInstallerPostInstallTest { + @get:Rule + val temp = TemporaryFolder() + + /** + * postInstall is inherited, so any concrete installer exercises it. The other three members of + * the contract are not under test and are stubbed out rather than faked. + */ + private class PostInstallOnly : BaseAssetsInstaller() { + override suspend fun preInstall( + context: Context, + stagingDir: Path, + ) = Unit + + override suspend fun doInstall( + context: Context, + stagingDir: Path, + cpuArch: CpuArch, + entryName: String, + ) = Unit + + override fun expectedSize(entryName: String): Long = 0L + } + + /** postInstall never reads the context; it is here only to satisfy the signature. */ + private val context: Context = mockk(relaxed = true) + + private var originalBuildToolsDir: File? = null + private var originalMavenDir: File? = null + private var originalGradleDists: File? = null + + private lateinit var buildTools: File + private lateinit var mavenRepo: File + private lateinit var gradleDists: File + + @Before + fun redirectEnvironmentAtTempTrees() { + originalBuildToolsDir = Environment.BUILD_TOOLS_DIR + originalMavenDir = Environment.LOCAL_MAVEN_DIR + originalGradleDists = Environment.GRADLE_DISTS + + buildTools = temp.newFolder("build-tools") + mavenRepo = temp.newFolder("localMvnRepository") + gradleDists = temp.newFolder("gradle-dists") + + Environment.BUILD_TOOLS_DIR = buildTools + Environment.LOCAL_MAVEN_DIR = mavenRepo + Environment.GRADLE_DISTS = gradleDists + } + + @After + fun restoreEnvironment() { + Environment.BUILD_TOOLS_DIR = originalBuildToolsDir + Environment.LOCAL_MAVEN_DIR = originalMavenDir + Environment.GRADLE_DISTS = originalGradleDists + } + + private fun runPostInstall() = + runBlocking { + PostInstallOnly().postInstall(context, temp.root.toPath()) + } + + /** A binary as the extractor leaves it: present and readable, but not yet executable. */ + private fun buildToolsFile(name: String): File = + File(buildTools, name).apply { + writeText(name) + setExecutable(false, false) + } + + /** A jar at the exact path the distribution extractor writes to, which is what is under test. */ + private fun distLibJar( + name: String, + content: String, + ): File { + val lib = File(gradleDists, "$GRADLE_DISTRIBUTION_NAME/lib").apply { mkdirs() } + return File(lib, name).apply { writeText(content) } + } + + private fun mavenJar( + coordinate: String, + name: String, + content: String, + ): File { + val dir = File(mavenRepo, coordinate).apply { mkdirs() } + return File(dir, name).apply { writeText(content) } + } + + @Test + fun `every build tool the toolchain invokes is executable afterwards`() { + val tools = + listOf( + "aapt", + "aapt2", + "aidl", + "apksigner", + "d8", + "dexdump", + "split-select", + "zipalign", + ).associateWith(::buildToolsFile) + // Deliberately not in the list: the loop has to name its binaries rather than chmod the + // directory, because build-tools also holds plain data files. + val notABinary = buildToolsFile("NOTICE.txt") + + runPostInstall() + + for ((name, file) in tools) { + assertWithMessage("$name is invoked by the build and must be executable") + .that(file.canExecute()) + .isTrue() + } + assertThat(notABinary.canExecute()).isFalse() + } + + @Test + fun `a maven jar duplicating the bundled distribution is collapsed onto it`() { + val duplicate = + mavenJar( + "org/jetbrains/kotlin/kotlin-compiler-embeddable/2.3.21", + "kotlin-compiler-embeddable-2.3.21.jar", + "shared-bytes", + ) + val inDistribution = distLibJar("kotlin-compiler-embeddable-2.3.21.jar", "shared-bytes") + + runPostInstall() + + // The whole of the wiring: GRADLE_DISTS//lib is where the extractor puts the + // distribution's jars. A dedup aimed one directory off links nothing, reclaims nothing, and + // still completes the install - so only an actual link proves the path was composed right. + assertThat(Files.isSymbolicLink(duplicate.toPath())).isTrue() + assertThat(Files.readSymbolicLink(duplicate.toPath()).toFile().canonicalFile) + .isEqualTo(inDistribution.canonicalFile) + // Gradle resolves through the Maven coordinate and must not be able to tell the difference. + assertThat(duplicate.readText()).isEqualTo("shared-bytes") + } + + @Test + fun `a half-extracted tree leaves the install alone rather than failing it`() { + // postInstall also runs from the caller's `finally` after a FAILED install, so neither the + // distribution nor the build tools are guaranteed to be on disk. Returning normally here is + // the assertion: a space optimisation must not convert a failed install into a crash. + val untouched = mavenJar("com/example/thing/1.0", "thing-1.0.jar", "bytes") + + runPostInstall() + + assertThat(Files.isSymbolicLink(untouched.toPath())).isFalse() + assertThat(untouched.readText()).isEqualTo("bytes") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/assets/DistJarDeduplicatorTest.kt b/app/src/test/java/com/itsaky/androidide/assets/DistJarDeduplicatorTest.kt index a481c90bae..d99b10c288 100644 --- a/app/src/test/java/com/itsaky/androidide/assets/DistJarDeduplicatorTest.kt +++ b/app/src/test/java/com/itsaky/androidide/assets/DistJarDeduplicatorTest.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.assets import com.google.common.truth.Truth.assertThat +import org.junit.Assume.assumeTrue import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -189,4 +190,77 @@ class DistJarDeduplicatorTest { val strays = mavenRepo.walkTopDown().filter { it.name.endsWith(".dedup") }.toList() assertThat(strays).isEmpty() } + + @Test + fun `a distribution holding no jars is a no-op rather than a failure`() { + setUpTrees() + val untouched = mavenJar("com/example/thing/1.0", "thing-1.0.jar", "same-bytes") + // Same bytes, but neither entry is a jar: one is a plain file, the other a directory. An + // index built from either would make this jar look like a duplicate of something that is + // not a jar at all. + distJar("thing-1.0.pom", "same-bytes") + File(distLib, "plugins").mkdirs() + + val outcome = DistJarDeduplicator.deduplicate(mavenRepo, distLib) + + assertThat(outcome).isEqualTo(DistJarDeduplicator.Outcome(linked = 0, bytesReclaimed = 0)) + assertThat(Files.isSymbolicLink(untouched.toPath())).isFalse() + assertThat(untouched.readText()).isEqualTo("same-bytes") + } + + @Test + fun `two unreadable jars of the same size are never treated as identical`() { + setUpTrees() + // The danger in a half-extracted tree: if an unreadable file hashed to some sentinel instead + // of to nothing, two files nobody can read would look like twins and a user's jar would be + // linked onto bytes that are not its own. Equal lengths make them reach the hash comparison. + val unreadable = mavenJar("com/example/broken/1.0", "broken-1.0.jar", "AAAAAAAA") + val unreadableTwin = distJar("broken-twin.jar", "BBBBBBBB") + val readable = mavenJar("com/example/fine/1.0", "fine-1.0.jar", "readable-bytes") + distJar("fine-twin.jar", "readable-bytes") + assumeEnforcedPermission(unreadable.setReadable(false, false) && !unreadable.canRead()) + assumeEnforcedPermission(unreadableTwin.setReadable(false, false) && !unreadableTwin.canRead()) + + val outcome = DistJarDeduplicator.deduplicate(mavenRepo, distLib) + + assertThat(Files.isSymbolicLink(unreadable.toPath())).isFalse() + assertThat(unreadable.length()).isEqualTo("AAAAAAAA".length.toLong()) + // The pass steps over the unreadable pair and keeps collapsing the rest - an unreadable jar + // is a reason to skip one file, not to abandon the optimisation or fail the install. + assertThat(Files.isSymbolicLink(readable.toPath())).isTrue() + assertThat(outcome.linked).isEqualTo(1) + assertThat(outcome.bytesReclaimed).isEqualTo("readable-bytes".length.toLong()) + } + + @Test + fun `a coordinate directory that refuses new entries leaves the jar as a real file`() { + setUpTrees() + val duplicate = mavenJar("com/example/thing/1.0", "thing-1.0.jar", "same-bytes") + distJar("thing-1.0.jar", "same-bytes") + val coordinateDir = checkNotNull(duplicate.parentFile) + assumeEnforcedPermission(coordinateDir.setWritable(false, false) && !coordinateDir.canWrite()) + + val outcome = + try { + DistJarDeduplicator.deduplicate(mavenRepo, distLib) + } finally { + // Restored here rather than in @After so TemporaryFolder can still clean up. + coordinateDir.setWritable(true, false) + } + + // The staging link cannot be created, so the swap never happens. A pass that counted the + // attempt anyway would report bytes reclaimed that are still very much on disk. + assertThat(outcome).isEqualTo(DistJarDeduplicator.Outcome(linked = 0, bytesReclaimed = 0)) + assertThat(Files.isSymbolicLink(duplicate.toPath())).isFalse() + assertThat(duplicate.readText()).isEqualTo("same-bytes") + assertThat(File(coordinateDir, "thing-1.0.jar.dedup").exists()).isFalse() + } + + /** + * Skip rather than pass vacuously where the filesystem ignores permission bits (a root user, or + * a mount that does not enforce them) - a green run there would prove nothing. + */ + private fun assumeEnforcedPermission(enforced: Boolean) { + assumeTrue("needs a filesystem that enforces permission bits", enforced) + } } diff --git a/app/src/test/java/com/itsaky/androidide/assets/JarLinksTest.kt b/app/src/test/java/com/itsaky/androidide/assets/JarLinksTest.kt new file mode 100644 index 0000000000..d358d30eeb --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/assets/JarLinksTest.kt @@ -0,0 +1,53 @@ +package com.itsaky.androidide.assets + +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files + +/** + * [JarLinks.trySymlink] is the single place both jar stores ask whether a link is possible, and its + * two callers act on opposite answers - the stager copies, the deduplicator walks away - so a wrong + * answer is silent either way. A false "true" leaves the daemon a classpath entry that is not + * there; a "false" that damaged the target on the way out would destroy bytes the caller was + * relying on still being present. + */ +class JarLinksTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `a link is created and the source bytes read back through it`() { + val source = + File(temp.newFolder("dist-lib"), "kotlin-compiler-embeddable.jar") + .apply { writeText("compiler-bytes") } + val target = File(temp.newFolder("daemon"), "kotlin-compiler-embeddable.jar") + + assertThat(JarLinks.trySymlink(target, source)).isTrue() + + // The callers' contract is the bytes at [target], not the link itself: the daemon's manifest + // Class-Path names this path and the JVM has to be able to open it. + assertThat(Files.isSymbolicLink(target.toPath())).isTrue() + assertThat(Files.readSymbolicLink(target.toPath()).toFile().canonicalFile) + .isEqualTo(source.canonicalFile) + assertThat(target.readText()).isEqualTo("compiler-bytes") + } + + @Test + fun `a refused link reports false and leaves the occupied target intact`() { + val source = + File(temp.newFolder("dist-lib"), "kotlin-stdlib.jar").apply { writeText("distribution-bytes") } + // An occupied target is how a link gets refused in practice - it is exactly why + // DistJarDeduplicator deletes its staging path before calling this. Whatever the cause, the + // documented contract is the same: report false, touch nothing. + val target = + File(temp.newFolder("daemon"), "kotlin-stdlib.jar").apply { writeText("existing-bytes") } + + assertThat(JarLinks.trySymlink(target, source)).isFalse() + + assertThat(Files.isSymbolicLink(target.toPath())).isFalse() + assertThat(target.readText()).isEqualTo("existing-bytes") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt index eef27b7d0b..f45bc4bae2 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt @@ -92,6 +92,23 @@ class QuickBuildArtifactStagerDistLinkTest { } } + @Test + fun `a jar that cannot be linked is copied instead, so the daemon still has the bytes`() { + val daemonDir = daemonDirListing() + // An occupied target is how a link gets refused in practice - a stale jar left by an earlier + // staging, or a filesystem that takes no links at all. Either way the daemon's manifest + // Class-Path still names this path, so the current bytes have to end up here. + val occupied = File(daemonDir, jarNames.first()).apply { writeText("stale-bytes") } + + QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith()) + + assertThat(Files.isSymbolicLink(occupied.toPath())).isFalse() + assertThat(occupied.readText()).isEqualTo("${jarNames.first()}-bytes") + // The fallback is per jar: one refused link must not cost the rest their cheap path. + assertThat(Files.isSymbolicLink(File(daemonDir, jarNames.last()).toPath())).isTrue() + assertThat(File(daemonDir, jarNames.last()).readText()).isEqualTo("${jarNames.last()}-bytes") + } + @Test fun `missing distribution fails loudly and names the path a human has to fix`() { val daemonDir = daemonDirListing() From a8e0fb3b366ecf58692f4133b1748ea47f29f299 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 22 Sep 2026 16:56:56 -0700 Subject: [PATCH 09/22] ADFA-4931: hash the jars the daemon loads, not the ones it was built against quickBuildDistJarCheck claimed to pin "the SHA-256 each has in gradle-9.6.1/lib" and to fail when the two sources were not "the same bytes". It did neither for the compiler: it hashed :quickbuild:daemon's Maven runtimeClasspath, and the pinned value for kotlin-compiler-embeddable was that same Maven jar. Both sides moved together, so the check could not observe the one mismatch it existed to find. They do not match. The distribution repacks kotlin-compiler-embeddable: its copy is 54,553,624 bytes (72ea34af...) against Maven's 59,720,376 (d3e70fb0...) at the same 2.3.21. Its zip entry is stamped 2026-09-12 where every other lib entry carries Gradle's canonical 1980 stamp. The other seven pinned jars are byte identical between the two sources, so the gate was honest for those and blind for the one that matters - the 57 MB one the ticket is about. The task now opens assets/gradle-9.6.1-bin.zip and hashes each jar as gradle-9.6.1/lib ships it, which is what QuickBuildArtifactStager links and what the daemon loads. All eight pins are re-derived from a fresh download of the asset URL, verified byte identical to the local copy. The Maven end is still checked, for the name rather than the bytes. That name is what quickBuildDaemonZip excludes, so a version bump that renamed a jar would leave the exclusion matching nothing and silently restore the copy the link replaces. Byte equality between the two ends is not a property that holds, so asserting it guards nothing; what makes the substitution safe is the version pin, which is asserted as before. A release build fetches the brotli-encoded distribution and never materialises this zip. That path now logs "UNHASHED - 0 of 8 linked jars read" with the reason and the command that fixes it, rather than sharing an output token with a real pass. Watched red before green: restoring the old Maven pin fails the task with "gradle-9.6.1/lib/kotlin-compiler-embeddable-2.3.21.jar has drifted from its pin ... pinned d3e70fb0..., gradle-9.6.1-bin.zip now ships 72ea34af... (54553624 bytes)" - the value the old gate passed with. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GwtUCGy8fKaSzu2uuUEUPY --- app/build.gradle.kts | 125 ++++++++++++++++++++++++++++++------------- 1 file changed, 88 insertions(+), 37 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a6b1a91195..caf5233ffe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -511,21 +511,26 @@ evaluationDependsOn(":quickbuild:runtime") evaluationDependsOn(":quickbuild:daemon") // ADFA-4931: jars the daemon loads from the on-device Gradle distribution instead of from the -// APK. The distribution ships each of these byte for byte, and `gradle-dists/` and -// `quickbuild/` are both under , so a second copy in the daemon zip bought -// nothing but download and disk. quickBuildDaemonZip leaves them out; -// QuickBuildArtifactStager links them into the daemon dir under exactly these names, which is -// what the daemon jar's manifest Class-Path still expects. +// APK. `gradle-dists/` and `quickbuild/` both sit under , so carrying a second +// copy in the daemon zip cost download and disk for jars already on the device. +// quickBuildDaemonZip leaves them out; QuickBuildArtifactStager links the distribution's copy +// into the daemon dir under exactly these names, which is what the daemon jar's manifest +// Class-Path still expects. // -// The values are the SHA-256 of each jar as gradle-$GRADLE_DISTRIBUTION_VERSION/lib/ ships it, -// asserted below against what the daemon actually resolves. Re-derive after any distribution -// bump (and move DIST_JAR_HASHES_FROM with it): +// Each value is the SHA-256 of the jar as gradle-$GRADLE_DISTRIBUTION_VERSION/lib/ ships it - +// the bytes the daemon loads, which are not always the bytes it was compiled against. +// kotlin-compiler-embeddable is the live case: the distribution repacks it, so its copy is +// 54,553,624 bytes where Maven's is 59,720,376 at the same version. Linking substitutes the +// distribution's build of a library for Maven's; it does not collapse two identical files, and +// what makes it safe is the version pin asserted below, not byte equality. +// +// Re-derive after any distribution bump (and move quickBuildDistHashesFrom with it): // unzip -p assets/$GRADLE_DISTRIBUTION_ARCHIVE_NAME \ // "$GRADLE_DISTRIBUTION_NAME/lib/" | shasum -a 256 val quickBuildDistLinkedJars = mapOf( "kotlin-compiler-embeddable-$KOTLIN_VERSION.jar" to - "d3e70fb011675e77ef00f1a68918d3cd91eed26058977d1e3ea2a37a293233af", + "72ea34af6586f49c36328ad14ca84fb5f9e3cebca2ee6434a19d58283b1ade67", "kotlin-stdlib-$KOTLIN_VERSION.jar" to "6f64eac736db9434dd6925b4a518b9d1d17177652320c37916cf9ba3ce7d7d7a", "kotlin-build-tools-impl-$KOTLIN_VERSION.jar" to @@ -549,8 +554,15 @@ val quickBuildDistLinkedJars = val quickBuildDistHashesFrom = "9.6.1" // Checked here rather than trusted: every failure this prevents surfaces on device as a -// NoClassDefFoundError partway into the user's first compile, a long way from the version -// bump that caused it. +// NoClassDefFoundError partway into the user's first compile, a long way from the version bump +// that caused it. +// +// The two ends of the link are checked for different things, because they are different bytes. +// The distribution's copy is what the daemon loads, so that is the one hashed, and a pin that +// stops matching is the drift this task exists to catch. The daemon's Maven runtime classpath +// is checked for the name only: that name is what quickBuildDaemonZip excludes, so a version +// bump that renames a jar would leave the exclusion matching nothing and quietly restore the +// copy the link was meant to replace. val quickBuildDistJarCheck = tasks.register("quickBuildDistJarCheck") { val pinned = @@ -558,7 +570,10 @@ val quickBuildDistJarCheck = .get() val expected = quickBuildDistLinkedJars val distVersion = GRADLE_DISTRIBUTION_VERSION + val distName = GRADLE_DISTRIBUTION_NAME + val archiveName = GRADLE_DISTRIBUTION_ARCHIVE_NAME val hashesFrom = quickBuildDistHashesFrom + val distArchive = rootProject.file("assets/$archiveName") val classpath = files( rootProject @@ -567,13 +582,19 @@ val quickBuildDistJarCheck = .named("runtimeClasspath"), ) inputs.files(classpath) + // Optional because release builds fetch the brotli-encoded distribution and never + // materialise this one. doLast reports that as unhashed rather than as a pass. + inputs + .files(distArchive) + .withPropertyName("gradleDistributionArchive") + .optional(true) doLast { if (pinned != KOTLIN_VERSION) { throw GradleException( "Quick Build's compiler pin (libs.versions.kotlin-daemon-compiler = $pinned) must equal " + "the Kotlin the on-device Gradle distribution embeds " + "(org.adfa.constants.KOTLIN_VERSION = $KOTLIN_VERSION). The daemon loads " + - "$GRADLE_DISTRIBUTION_NAME/lib/kotlin-compiler-embeddable-$KOTLIN_VERSION.jar at runtime.", + "$distName/lib/kotlin-compiler-embeddable-$KOTLIN_VERSION.jar at runtime.", ) } if (distVersion != hashesFrom) { @@ -586,36 +607,66 @@ val quickBuildDistJarCheck = } val resolved = classpath.files.associateBy { it.name } - val digest = MessageDigest.getInstance("SHA-256") - expected.forEach { (name, sha) -> - val jar = - resolved[name] ?: throw GradleException( - "$name is listed as linked from the Gradle distribution but is not on the " + - "daemon's runtime classpath. Excluding it from the daemon zip would drop a " + - "jar nothing puts back. Resolved: ${resolved.keys.sorted()}", + expected.keys.sorted().forEach { name -> + if (name !in resolved) { + throw GradleException( + "$name is linked from the Gradle distribution but is not on the daemon's " + + "runtime classpath, so quickBuildDaemonZip's exclusion of that name " + + "matches nothing and the zip carries a copy the stager then links over. " + + "Point quickBuildDistLinkedJars at the name the daemon now resolves. " + + "Resolved: ${resolved.keys.sorted()}", ) - digest.reset() - val actual = - jar.inputStream().use { input -> - val buffer = ByteArray(1 shl 16) - while (true) { - val read = input.read(buffer) - if (read < 0) break - digest.update(buffer, 0, read) + } + } + + if (!distArchive.isFile) { + logger.lifecycle( + "quickBuildDistJarCheck: UNHASHED - 0 of ${expected.size} linked jars read. " + + "${distArchive.absolutePath} is absent, so the bytes the daemon will load " + + "could not be compared with the pins. Release builds fetch $archiveName.br " + + "instead and take this path; run ./gradlew :app:assetsDownloadDebug to hash " + + "them here.", + ) + return@doLast + } + + val digest = MessageDigest.getInstance("SHA-256") + ZipFile(distArchive).use { zip -> + expected.forEach { (name, sha) -> + val entryName = "$distName/lib/$name" + val entry = + zip.getEntry(entryName) ?: throw GradleException( + "$entryName is missing from ${distArchive.name}. quickBuildDaemonZip " + + "excludes $name on the promise that the distribution supplies it, so " + + "the daemon would start with no copy at all. Either drop it from " + + "quickBuildDistLinkedJars and let the zip carry it, or correct the name.", + ) + digest.reset() + val actual = + zip.getInputStream(entry).use { input -> + val buffer = ByteArray(1 shl 16) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + digest.digest().joinToString("") { "%02x".format(it) } } - digest.digest().joinToString("") { "%02x".format(it) } + if (actual != sha) { + throw GradleException( + "$entryName has drifted from its pin in app/build.gradle.kts: pinned $sha, " + + "${distArchive.name} now ships $actual (${entry.size} bytes). The daemon " + + "loads this jar to compile the user's code, so confirm the new " + + "distribution is the one we mean to ship, then re-derive the pin with: " + + "unzip -p assets/$archiveName \"$entryName\" | shasum -a 256", + ) } - if (actual != sha) { - throw GradleException( - "$name no longer matches the copy gradle-$distVersion ships " + - "(expected $sha, resolved $actual from $jar). Quick Build links the " + - "distribution's copy in place of this one, so they must be the same bytes. " + - "Either drop it from quickBuildDistLinkedJars and let the zip carry it, or " + - "re-derive the hash if the distribution genuinely changed.", - ) } } - logger.lifecycle("quickBuildDistJarCheck: verified ${expected.size} jars against gradle-$distVersion") + logger.lifecycle( + "quickBuildDistJarCheck: hashed ${expected.size} of ${expected.size} linked jars " + + "against $distName/lib in ${distArchive.name}", + ) } } From ff4e759550757de7323c3b94de4d5fcf9dd934a9 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 24 Sep 2026 10:14:58 -0700 Subject: [PATCH 10/22] ADFA-4931: link the distribution's Kotlin jars instead of shipping a second copy The Quick Build daemon zip carried its own copy of the Kotlin compiler and seven other jars that the on-device Gradle distribution already ships. Both live under ANDROIDIDE_HOME, so the stager can link the distribution's copies into the daemon dir under the names the daemon's manifest Class-Path expects, and the zip can leave them out. The debug v8 APK drops 57.6 MB, 254.0 MB to 196.4 MB. Two build-time checks replace the shipped bytes: the daemon's Kotlin must equal the distribution's, and every linked name must exist in both the distribution archive and the daemon classpath. A rename or a version bump that would break the link fails the build rather than surfacing on device as a NoClassDefFoundError partway into the user's first compile. No hashing. An earlier revision pinned SHA-256 for each linked jar; the hash input was optional, so release builds skipped the comparison entirely, and nothing at runtime ever read a hash. DistJarDeduplicator and JarLinks are removed. The deduplicator saved 0.8 MB of on-device storage for ~200 lines plus tests, which is not worth its weight beside the 57.6 MB the link saves; JarLinks had one caller left once it went, so its body is inlined into QuickBuildArtifactStager. Comments in the touched build files were shortened to the repo's bar in the same pass: 1-2 sentences of description, one line per param. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GwtUCGy8fKaSzu2uuUEUPY --- app/build.gradle.kts | 140 +++------ .../androidide/assets/BaseAssetsInstaller.kt | 19 +- .../androidide/assets/DistJarDeduplicator.kt | 134 --------- .../com/itsaky/androidide/assets/JarLinks.kt | 39 --- .../quickbuild/QuickBuildArtifactStager.kt | 66 ++--- .../BaseAssetsInstallerPostInstallTest.kt | 181 ------------ .../assets/DistJarDeduplicatorTest.kt | 266 ------------------ .../itsaky/androidide/assets/JarLinksTest.kt | 53 ---- .../QuickBuildArtifactStagerDistLinkTest.kt | 42 +-- .../QuickBuildArtifactStagerTest.kt | 26 +- build-info/build.gradle.kts | 7 +- gradle/libs.versions.toml | 20 +- quickbuild/daemon/build.gradle.kts | 33 +-- 13 files changed, 102 insertions(+), 924 deletions(-) delete mode 100644 app/src/main/java/com/itsaky/androidide/assets/DistJarDeduplicator.kt delete mode 100644 app/src/main/java/com/itsaky/androidide/assets/JarLinks.kt delete mode 100644 app/src/test/java/com/itsaky/androidide/assets/BaseAssetsInstallerPostInstallTest.kt delete mode 100644 app/src/test/java/com/itsaky/androidide/assets/DistJarDeduplicatorTest.kt delete mode 100644 app/src/test/java/com/itsaky/androidide/assets/JarLinksTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index caf5233ffe..fbe2abd1b4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -510,69 +510,35 @@ dependencies { evaluationDependsOn(":quickbuild:runtime") evaluationDependsOn(":quickbuild:daemon") -// ADFA-4931: jars the daemon loads from the on-device Gradle distribution instead of from the -// APK. `gradle-dists/` and `quickbuild/` both sit under , so carrying a second -// copy in the daemon zip cost download and disk for jars already on the device. -// quickBuildDaemonZip leaves them out; QuickBuildArtifactStager links the distribution's copy -// into the daemon dir under exactly these names, which is what the daemon jar's manifest -// Class-Path still expects. -// -// Each value is the SHA-256 of the jar as gradle-$GRADLE_DISTRIBUTION_VERSION/lib/ ships it - -// the bytes the daemon loads, which are not always the bytes it was compiled against. -// kotlin-compiler-embeddable is the live case: the distribution repacks it, so its copy is -// 54,553,624 bytes where Maven's is 59,720,376 at the same version. Linking substitutes the -// distribution's build of a library for Maven's; it does not collapse two identical files, and -// what makes it safe is the version pin asserted below, not byte equality. -// -// Re-derive after any distribution bump (and move quickBuildDistHashesFrom with it): -// unzip -p assets/$GRADLE_DISTRIBUTION_ARCHIVE_NAME \ -// "$GRADLE_DISTRIBUTION_NAME/lib/" | shasum -a 256 +// ADFA-4931: jars the daemon loads from the on-device Gradle distribution instead of shipping its +// own copy, since `gradle-dists/` and `quickbuild/` both sit under . +// quickBuildDaemonZip leaves these out and QuickBuildArtifactStager links the distribution's copy +// in under exactly these names, which is what the daemon jar's manifest Class-Path expects. val quickBuildDistLinkedJars = - mapOf( - "kotlin-compiler-embeddable-$KOTLIN_VERSION.jar" to - "72ea34af6586f49c36328ad14ca84fb5f9e3cebca2ee6434a19d58283b1ade67", - "kotlin-stdlib-$KOTLIN_VERSION.jar" to - "6f64eac736db9434dd6925b4a518b9d1d17177652320c37916cf9ba3ce7d7d7a", - "kotlin-build-tools-impl-$KOTLIN_VERSION.jar" to - "2cd70396404a0e43c05aaf79a8b35ff3a8ff2e296aedf770f6e5923fbfacae3b", - "kotlin-daemon-embeddable-$KOTLIN_VERSION.jar" to - "222fc10a3c22e74d30369bf8a0ca6c4f9372eac7d4a0081e290c256fa4d30fb2", - "kotlin-script-runtime-$KOTLIN_VERSION.jar" to - "ec61bf1229c837fa9f4255f34ca69816138b73158e7ae9b8d964cf92ca6dfd2e", - // These three carry their own version rather than KOTLIN_VERSION, and the daemon pins - // each to what the distribution ships (libs.versions.toml, :quickbuild:daemon) purely - // so it can be linked from there. - "kotlinx-coroutines-core-jvm-1.10.2.jar" to - "5ca175b38df331fd64155b35cd8cae1251fa9ee369709b36d42e0a288ccce3fd", - "gson-2.13.1.jar" to - "94855942d4992f112946d3de1c334e709237b8126d8130bf07807c018a4a2120", - "asm-9.9.jar" to - "03d99a74ad1ee5c71334ef67437f4ef4fe3488caa7c96d8645abc73c8e2017d4", + listOf( + "kotlin-compiler-embeddable-$KOTLIN_VERSION.jar", + "kotlin-stdlib-$KOTLIN_VERSION.jar", + "kotlin-build-tools-impl-$KOTLIN_VERSION.jar", + "kotlin-daemon-embeddable-$KOTLIN_VERSION.jar", + "kotlin-script-runtime-$KOTLIN_VERSION.jar", + // These three carry their own version rather than KOTLIN_VERSION, pinned in + // libs.versions.toml to what the distribution ships so they can be linked from there. + "kotlinx-coroutines-core-jvm-1.10.2.jar", + "gson-2.13.1.jar", + "asm-9.9.jar", ) -/** The distribution the hashes above were read from; see [quickBuildDistLinkedJars]. */ -val quickBuildDistHashesFrom = "9.6.1" - -// Checked here rather than trusted: every failure this prevents surfaces on device as a -// NoClassDefFoundError partway into the user's first compile, a long way from the version bump -// that caused it. -// -// The two ends of the link are checked for different things, because they are different bytes. -// The distribution's copy is what the daemon loads, so that is the one hashed, and a pin that -// stops matching is the drift this task exists to catch. The daemon's Maven runtime classpath -// is checked for the name only: that name is what quickBuildDaemonZip excludes, so a version -// bump that renames a jar would leave the exclusion matching nothing and quietly restore the -// copy the link was meant to replace. +// A broken link surfaces on device as a NoClassDefFoundError partway into the user's first +// compile, far from the version bump that caused it, so check both ends at build time: the +// distribution must carry each name, and the daemon must resolve each name too. val quickBuildDistJarCheck = tasks.register("quickBuildDistJarCheck") { val pinned = libs.versions.kotlin.daemon.compiler .get() val expected = quickBuildDistLinkedJars - val distVersion = GRADLE_DISTRIBUTION_VERSION val distName = GRADLE_DISTRIBUTION_NAME val archiveName = GRADLE_DISTRIBUTION_ARCHIVE_NAME - val hashesFrom = quickBuildDistHashesFrom val distArchive = rootProject.file("assets/$archiveName") val classpath = files( @@ -583,7 +549,7 @@ val quickBuildDistJarCheck = ) inputs.files(classpath) // Optional because release builds fetch the brotli-encoded distribution and never - // materialise this one. doLast reports that as unhashed rather than as a pass. + // materialise this one. doLast reports that as unchecked rather than as a pass. inputs .files(distArchive) .withPropertyName("gradleDistributionArchive") @@ -597,17 +563,8 @@ val quickBuildDistJarCheck = "$distName/lib/kotlin-compiler-embeddable-$KOTLIN_VERSION.jar at runtime.", ) } - if (distVersion != hashesFrom) { - throw GradleException( - "The Gradle distribution moved to $distVersion but the linked-jar hashes in " + - "app/build.gradle.kts were read from $hashesFrom. Re-derive them from the new " + - "distribution and move quickBuildDistHashesFrom, or the daemon may link a jar " + - "that is no longer the one it was built against.", - ) - } - val resolved = classpath.files.associateBy { it.name } - expected.keys.sorted().forEach { name -> + expected.sorted().forEach { name -> if (name !in resolved) { throw GradleException( "$name is linked from the Gradle distribution but is not on the daemon's " + @@ -621,51 +578,28 @@ val quickBuildDistJarCheck = if (!distArchive.isFile) { logger.lifecycle( - "quickBuildDistJarCheck: UNHASHED - 0 of ${expected.size} linked jars read. " + - "${distArchive.absolutePath} is absent, so the bytes the daemon will load " + - "could not be compared with the pins. Release builds fetch $archiveName.br " + - "instead and take this path; run ./gradlew :app:assetsDownloadDebug to hash " + - "them here.", + "quickBuildDistJarCheck: UNCHECKED - 0 of ${expected.size} linked jars looked up. " + + "${distArchive.absolutePath} is absent, so the distribution could not be " + + "read. Release builds fetch $archiveName.br instead and take this path; run " + + "./gradlew :app:assetsDownloadDebug to check them here.", ) return@doLast } - val digest = MessageDigest.getInstance("SHA-256") ZipFile(distArchive).use { zip -> - expected.forEach { (name, sha) -> + expected.forEach { name -> val entryName = "$distName/lib/$name" - val entry = - zip.getEntry(entryName) ?: throw GradleException( - "$entryName is missing from ${distArchive.name}. quickBuildDaemonZip " + - "excludes $name on the promise that the distribution supplies it, so " + - "the daemon would start with no copy at all. Either drop it from " + - "quickBuildDistLinkedJars and let the zip carry it, or correct the name.", - ) - digest.reset() - val actual = - zip.getInputStream(entry).use { input -> - val buffer = ByteArray(1 shl 16) - while (true) { - val read = input.read(buffer) - if (read < 0) break - digest.update(buffer, 0, read) - } - digest.digest().joinToString("") { "%02x".format(it) } - } - if (actual != sha) { - throw GradleException( - "$entryName has drifted from its pin in app/build.gradle.kts: pinned $sha, " + - "${distArchive.name} now ships $actual (${entry.size} bytes). The daemon " + - "loads this jar to compile the user's code, so confirm the new " + - "distribution is the one we mean to ship, then re-derive the pin with: " + - "unzip -p assets/$archiveName \"$entryName\" | shasum -a 256", - ) - } + zip.getEntry(entryName) ?: throw GradleException( + "$entryName is missing from ${distArchive.name}. quickBuildDaemonZip " + + "excludes $name on the promise that the distribution supplies it, so " + + "the daemon would start with no copy at all. Either drop it from " + + "quickBuildDistLinkedJars and let the zip carry it, or correct the name.", + ) } } logger.lifecycle( - "quickBuildDistJarCheck: hashed ${expected.size} of ${expected.size} linked jars " + - "against $distName/lib in ${distArchive.name}", + "quickBuildDistJarCheck: found ${expected.size} of ${expected.size} linked jars " + + "in $distName/lib of ${distArchive.name}", ) } } @@ -674,7 +608,7 @@ val quickBuildDistJarCheck = // list rather than repeating it. val quickBuildDistLinkedJarList = tasks.register("quickBuildDistLinkedJarList") { - val names = quickBuildDistLinkedJars.keys.sorted() + val names = quickBuildDistLinkedJars.sorted() val listFile = layout.buildDirectory.file("intermediates/quickbuild/dist-linked-jars.txt") inputs.property("names", names) outputs.file(listFile) @@ -694,9 +628,9 @@ val quickBuildDaemonZip = // The daemon jar's manifest Class-Path names these by file name; they must sit // next to the jar after extraction. from(daemonProject.configurations.named("runtimeClasspath")) - // ADFA-4931: except the ones the distribution already carries. The stager links those - // in at provision time, reading the list this task stages beside them. - val linked = quickBuildDistLinkedJars.keys + // ADFA-4931: except the ones the distribution already carries; the stager links those + // in at provision time. + val linked = quickBuildDistLinkedJars.toSet() exclude { it.name in linked } from(quickBuildDistLinkedJarList) // Compose compiler plugin, version-matched to the daemon's compiler; the stable diff --git a/app/src/main/java/com/itsaky/androidide/assets/BaseAssetsInstaller.kt b/app/src/main/java/com/itsaky/androidide/assets/BaseAssetsInstaller.kt index 637ff66baf..455606c22d 100644 --- a/app/src/main/java/com/itsaky/androidide/assets/BaseAssetsInstaller.kt +++ b/app/src/main/java/com/itsaky/androidide/assets/BaseAssetsInstaller.kt @@ -4,9 +4,6 @@ import android.content.Context import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FeatureFlags import com.termux.shared.termux.TermuxConstants -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.adfa.constants.GRADLE_DISTRIBUTION_NAME import org.slf4j.LoggerFactory import java.io.File import java.nio.file.Path @@ -25,23 +22,13 @@ abstract class BaseAssetsInstaller : AssetsInstaller { "aapt", "aapt2", "aidl", - "apksigner", - "d8", + "apksigner", + "d8", "dexdump", "split-select", "zipalign", )) { Environment.setExecutable(Environment.BUILD_TOOLS_DIR.resolve(bin)) } - - // Both stores are on disk by now, which is the first point their overlap can be seen. - // This also runs after a FAILED install (the caller's finally), so the tree may be half - // extracted - safe, because a partial jar cannot hash equal to the distribution's. - withContext(Dispatchers.IO) { - DistJarDeduplicator.deduplicate( - mavenRepo = Environment.LOCAL_MAVEN_DIR, - distLib = File(Environment.GRADLE_DISTS, "$GRADLE_DISTRIBUTION_NAME/lib"), - ) - } - } + } } diff --git a/app/src/main/java/com/itsaky/androidide/assets/DistJarDeduplicator.kt b/app/src/main/java/com/itsaky/androidide/assets/DistJarDeduplicator.kt deleted file mode 100644 index 61d227b03c..0000000000 --- a/app/src/main/java/com/itsaky/androidide/assets/DistJarDeduplicator.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.itsaky.androidide.assets - -import org.slf4j.LoggerFactory -import java.io.File -import java.io.IOException -import java.nio.file.Files -import java.nio.file.StandardCopyOption -import java.security.MessageDigest - -/** - * Replaces jars in the extracted offline Maven repo with symlinks to the identical jar in the - * extracted Gradle distribution, once both are on disk. - * - * The two stores are harvested independently and overlap by construction: a user project resolves - * its Kotlin plugin from the Maven repo, and the distribution embeds the same Kotlin to run its own - * builds. Post-toolchain-bump the largest single overlap is `kotlin-compiler-embeddable`, around - * 60 MB of identical bytes on a device that has little to spare. - * - * **Identity is decided by content, never by name.** The two stores version independently, so a - * matching file name means nothing on its own - and linking a same-named jar that is not the same - * jar would corrupt a user's build in a way that surfaces far from here. Size is only a cheap - * pre-filter; the decision is SHA-256. - * - * This is a disk-space optimisation, not a correctness requirement, so it never fails an install. - * A missing distribution, an unreadable jar, or a filesystem that refuses symlinks all mean the - * duplicate simply stays on disk. - */ -object DistJarDeduplicator { - private val log = LoggerFactory.getLogger("QB-DistDedup") - - /** Result of one pass, for logging and for the tests to assert on. */ - data class Outcome( - val linked: Int, - val bytesReclaimed: Long, - ) - - /** - * @param mavenRepo the extracted `localMvnRepository` tree, walked recursively. - * @param distLib the extracted distribution's `lib/`, whose jars are the link targets. - */ - fun deduplicate( - mavenRepo: File, - distLib: File, - ): Outcome { - if (!mavenRepo.isDirectory || !distLib.isDirectory) { - log.info("Nothing to deduplicate: mavenRepo={} distLib={}", mavenRepo, distLib) - return Outcome(0, 0) - } - - // Size buckets first: hashing 200 MB of Maven repo against 150 MB of distribution would - // cost more than the disk it saves, and a size mismatch already rules a pair out. - val distBySize = mutableMapOf>() - distLib.listFiles()?.forEach { candidate -> - if (candidate.isFile && candidate.name.endsWith(".jar")) { - distBySize.getOrPut(candidate.length()) { mutableListOf() }.add(candidate) - } - } - if (distBySize.isEmpty()) { - log.info("No jars in {}", distLib) - return Outcome(0, 0) - } - - val hashes = mutableMapOf() - var linked = 0 - var reclaimed = 0L - - mavenRepo.walkTopDown().forEach { candidate -> - if (!candidate.isFile || !candidate.name.endsWith(".jar")) return@forEach - // Already linked by an earlier pass; installs re-extract and re-run, so this is the - // common case on every install after the first. - if (Files.isSymbolicLink(candidate.toPath())) return@forEach - - val sameSize = distBySize[candidate.length()] ?: return@forEach - val candidateHash = hashes.getOrPut(candidate) { sha256(candidate) } ?: return@forEach - val twin = - sameSize.firstOrNull { hashes.getOrPut(it) { sha256(it) } == candidateHash } - ?: return@forEach - - if (replaceWithLink(candidate, twin)) { - linked++ - reclaimed += twin.length() - } - } - - log.info("Linked {} duplicate jars to {}, reclaiming {} bytes", linked, distLib, reclaimed) - return Outcome(linked, reclaimed) - } - - /** - * Swap [duplicate] for a symlink to [source] without ever having neither on disk: the link is - * built beside the file and moved over it, so a failure at any point leaves the original copy - * exactly where it was. - */ - private fun replaceWithLink( - duplicate: File, - source: File, - ): Boolean { - val staging = File(duplicate.parentFile, "${duplicate.name}.dedup") - staging.delete() - if (!JarLinks.trySymlink(staging, source)) { - return false - } - return try { - Files.move( - staging.toPath(), - duplicate.toPath(), - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.ATOMIC_MOVE, - ) - true - } catch (e: IOException) { - log.warn("Could not swap {} for a link to {} ({})", duplicate, source, e.toString()) - staging.delete() - false - } - } - - private fun sha256(file: File): String? = - try { - val digest = MessageDigest.getInstance("SHA-256") - file.inputStream().use { input -> - val buffer = ByteArray(1 shl 16) - while (true) { - val read = input.read(buffer) - if (read < 0) break - digest.update(buffer, 0, read) - } - } - digest.digest().joinToString("") { "%02x".format(it) } - } catch (e: IOException) { - log.warn("Could not read {} ({})", file, e.toString()) - null - } -} diff --git a/app/src/main/java/com/itsaky/androidide/assets/JarLinks.kt b/app/src/main/java/com/itsaky/androidide/assets/JarLinks.kt deleted file mode 100644 index fe9328c4ef..0000000000 --- a/app/src/main/java/com/itsaky/androidide/assets/JarLinks.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.itsaky.androidide.assets - -import org.slf4j.LoggerFactory -import java.io.File -import java.nio.file.Files - -/** - * Symlinking one staged jar at another, for the two places that deduplicate jars across the - * on-device tool stores: [com.itsaky.androidide.quickbuild.QuickBuildArtifactStager], which links - * jars the APK deliberately does not carry, and [DistJarDeduplicator], which collapses copies that - * were extracted twice. - * - * The two want different things when a link is impossible - the stager must have the bytes, so it - * copies; the deduplicator already has them, so it leaves the copy alone - which is why what is - * shared here is the attempt, not the fallback. - */ -internal object JarLinks { - private val log = LoggerFactory.getLogger("QB-JarLinks") - - /** - * Try to make [target] a symlink to [source]. - * - * @return false when the filesystem refuses links, leaving [target] untouched. Callers decide - * what that costs them. - */ - fun trySymlink( - target: File, - source: File, - ): Boolean = - try { - Files.createSymbolicLink(target.toPath(), source.toPath()) - true - } catch (e: Exception) { - // UnsupportedOperationException / IOException / SecurityException all mean the same - // thing here: no symlink on this filesystem. - log.warn("Symlink {} -> {} failed ({})", target, source, e.toString()) - false - } -} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt index 16e0dc5dfd..8378231ffd 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt @@ -2,7 +2,6 @@ package com.itsaky.androidide.quickbuild import android.content.Context import androidx.core.content.pm.PackageInfoCompat -import com.itsaky.androidide.assets.JarLinks import com.itsaky.androidide.utils.Environment import org.adfa.constants.GRADLE_DISTRIBUTION_NAME import org.slf4j.LoggerFactory @@ -10,6 +9,7 @@ import java.io.File import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream +import java.nio.file.Files import java.util.zip.ZipInputStream /** @@ -73,8 +73,7 @@ object QuickBuildArtifactStager { * [installStamp] - the stamp file matches and [daemonJar] is present. Internal so the JVM * test can watch the skip, and the wipe, without an Android [Context]. * - * @param gradleDists passed through to [linkDistJars]; parameterised only so tests can point - * at a fake distribution, since [Environment.GRADLE_DISTS] is unset off-device. + * @param gradleDists exists so tests can point at a fake distribution. * @return whether an extraction ran. */ @Throws(IOException::class) @@ -96,9 +95,8 @@ object QuickBuildArtifactStager { Environment.mkdirIfNotExists(daemonDir) val count = extractDaemonZip(openZip(), daemonDir) - // Before the stamp: the skip test above reads the stamp and the daemon jar, not the - // linked jars, so stamping a staging whose link failed would skip re-staging forever - // and leave the daemon without a compiler until the next install. + // Must run before the stamp is written, or a failed link would be stamped as done and + // never retried. linkDistJars(daemonDir, gradleDists) stamp.writeText(installStamp) log.info("Staged {} daemon files into {}", count, daemonDir) @@ -146,25 +144,12 @@ object QuickBuildArtifactStager { } /** - * Put the jars the APK deliberately does not carry where the daemon jar's manifest - * Class-Path expects them. + * Links the jars the APK leaves out into the daemon dir, where its manifest Class-Path + * expects them. * - * ADFA-4931: the on-device Gradle distribution already ships these artifacts at the same - * versions, byte for byte - the compiler alone is ~57 MB - so a second copy in the daemon - * zip was pure duplication. `gradle-dists/` and `quickbuild/` are always on one filesystem - * (both under ``), so a symlink costs nothing; a copy is the fallback for - * a filesystem that refuses one. - * - * The names come from [LINKED_JARS_LIST], staged into the daemon dir by :app's - * `quickBuildDistLinkedJarList`, so the build's exclusion list and this link step cannot - * drift apart. - * - * @param gradleDists parameterised only so tests can point at a fake distribution; production - * callers take the default. - * @throws FileNotFoundException when the list is absent, or when the distribution has not - * been extracted yet. There is deliberately no bundled fallback: the alternative to - * failing here is a NoClassDefFoundError partway into the user's first compile, which is - * far harder to read. + * @param gradleDists exists so tests can point at a fake distribution. + * @throws FileNotFoundException if a jar or the list is missing, which fails loudly here + * rather than as a NoClassDefFoundError during the user's first compile. */ @Throws(IOException::class) internal fun linkDistJars( @@ -182,34 +167,31 @@ object QuickBuildArtifactStager { } val target = File(daemonDir, jarName) - if (JarLinks.trySymlink(target, source)) { - log.info("Linked {} -> {}", target, source) - } else { - // The daemon has to have these bytes one way or another, so a filesystem that - // refuses links costs us the copy. - log.info("Copying {} into the daemon dir instead", source) + try { + Files.createSymbolicLink(target.toPath(), source.toPath()) + } catch (e: Exception) { + // A filesystem without symlinks still needs these bytes, so copy instead. + log.warn("Symlink {} -> {} failed ({}), copying", target, source, e.toString()) source.inputStream().use { input -> target.outputStream().use { input.copyTo(it) } } } } } - /** - * The jar names the build left out of the zip for the distribution to supply. A missing or - * empty list means the zip was built without the staging task, which would otherwise show up - * as a first compile failing on a jar nobody linked. - */ + /** The jar names the build left out of the zip for the distribution to supply. */ private fun readLinkedJarNames(daemonDir: File): List { val list = File(daemonDir, LINKED_JARS_LIST) - if (!list.isFile) { + val names = + if (list.isFile) { + list.readLines().map(String::trim).filter(String::isNotEmpty) + } else { + emptyList() + } + if (names.isEmpty()) { throw FileNotFoundException( - "$LINKED_JARS_LIST missing from the staged daemon at $daemonDir. It names the jars " + - "Quick Build links from the Gradle distribution rather than shipping in the APK.", + "$LINKED_JARS_LIST at $list names no jars. It lists the jars Quick Build links " + + "from the Gradle distribution rather than shipping in the APK.", ) } - val names = list.readLines().map(String::trim).filter(String::isNotEmpty) - if (names.isEmpty()) { - throw FileNotFoundException("$LINKED_JARS_LIST at $list names no jars") - } return names } } diff --git a/app/src/test/java/com/itsaky/androidide/assets/BaseAssetsInstallerPostInstallTest.kt b/app/src/test/java/com/itsaky/androidide/assets/BaseAssetsInstallerPostInstallTest.kt deleted file mode 100644 index 838d07b3bf..0000000000 --- a/app/src/test/java/com/itsaky/androidide/assets/BaseAssetsInstallerPostInstallTest.kt +++ /dev/null @@ -1,181 +0,0 @@ -package com.itsaky.androidide.assets - -import android.content.Context -import com.google.common.truth.Truth.assertThat -import com.google.common.truth.Truth.assertWithMessage -import com.itsaky.androidide.app.configuration.CpuArch -import com.itsaky.androidide.utils.Environment -import io.mockk.mockk -import kotlinx.coroutines.runBlocking -import org.adfa.constants.GRADLE_DISTRIBUTION_NAME -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder -import java.io.File -import java.nio.file.Files -import java.nio.file.Path - -/** - * [BaseAssetsInstaller.postInstall] is wiring, and wiring is where a silent no-op hides: every - * failure here looks like a successful install. A dedup pointed at the wrong directory links - * nothing and logs "nothing to deduplicate"; a build-tools binary missed by the list stays - * non-executable until a build fails a long way downstream. [DistJarDeduplicatorTest] pins the - * dedup algorithm against paths it is handed, so what is left to pin - and all this file pins - - * is that postInstall hands it the paths the extractor actually wrote to, and that the binary - * list is the one the toolchain needs. - * - * The `Environment` statics are unset off-device, so each test sets them at temp directories and - * restores them afterwards, the same way [com.itsaky.androidide.quickbuild.EnvironmentQuickBuildPathsD8Test] - * does. - */ -class BaseAssetsInstallerPostInstallTest { - @get:Rule - val temp = TemporaryFolder() - - /** - * postInstall is inherited, so any concrete installer exercises it. The other three members of - * the contract are not under test and are stubbed out rather than faked. - */ - private class PostInstallOnly : BaseAssetsInstaller() { - override suspend fun preInstall( - context: Context, - stagingDir: Path, - ) = Unit - - override suspend fun doInstall( - context: Context, - stagingDir: Path, - cpuArch: CpuArch, - entryName: String, - ) = Unit - - override fun expectedSize(entryName: String): Long = 0L - } - - /** postInstall never reads the context; it is here only to satisfy the signature. */ - private val context: Context = mockk(relaxed = true) - - private var originalBuildToolsDir: File? = null - private var originalMavenDir: File? = null - private var originalGradleDists: File? = null - - private lateinit var buildTools: File - private lateinit var mavenRepo: File - private lateinit var gradleDists: File - - @Before - fun redirectEnvironmentAtTempTrees() { - originalBuildToolsDir = Environment.BUILD_TOOLS_DIR - originalMavenDir = Environment.LOCAL_MAVEN_DIR - originalGradleDists = Environment.GRADLE_DISTS - - buildTools = temp.newFolder("build-tools") - mavenRepo = temp.newFolder("localMvnRepository") - gradleDists = temp.newFolder("gradle-dists") - - Environment.BUILD_TOOLS_DIR = buildTools - Environment.LOCAL_MAVEN_DIR = mavenRepo - Environment.GRADLE_DISTS = gradleDists - } - - @After - fun restoreEnvironment() { - Environment.BUILD_TOOLS_DIR = originalBuildToolsDir - Environment.LOCAL_MAVEN_DIR = originalMavenDir - Environment.GRADLE_DISTS = originalGradleDists - } - - private fun runPostInstall() = - runBlocking { - PostInstallOnly().postInstall(context, temp.root.toPath()) - } - - /** A binary as the extractor leaves it: present and readable, but not yet executable. */ - private fun buildToolsFile(name: String): File = - File(buildTools, name).apply { - writeText(name) - setExecutable(false, false) - } - - /** A jar at the exact path the distribution extractor writes to, which is what is under test. */ - private fun distLibJar( - name: String, - content: String, - ): File { - val lib = File(gradleDists, "$GRADLE_DISTRIBUTION_NAME/lib").apply { mkdirs() } - return File(lib, name).apply { writeText(content) } - } - - private fun mavenJar( - coordinate: String, - name: String, - content: String, - ): File { - val dir = File(mavenRepo, coordinate).apply { mkdirs() } - return File(dir, name).apply { writeText(content) } - } - - @Test - fun `every build tool the toolchain invokes is executable afterwards`() { - val tools = - listOf( - "aapt", - "aapt2", - "aidl", - "apksigner", - "d8", - "dexdump", - "split-select", - "zipalign", - ).associateWith(::buildToolsFile) - // Deliberately not in the list: the loop has to name its binaries rather than chmod the - // directory, because build-tools also holds plain data files. - val notABinary = buildToolsFile("NOTICE.txt") - - runPostInstall() - - for ((name, file) in tools) { - assertWithMessage("$name is invoked by the build and must be executable") - .that(file.canExecute()) - .isTrue() - } - assertThat(notABinary.canExecute()).isFalse() - } - - @Test - fun `a maven jar duplicating the bundled distribution is collapsed onto it`() { - val duplicate = - mavenJar( - "org/jetbrains/kotlin/kotlin-compiler-embeddable/2.3.21", - "kotlin-compiler-embeddable-2.3.21.jar", - "shared-bytes", - ) - val inDistribution = distLibJar("kotlin-compiler-embeddable-2.3.21.jar", "shared-bytes") - - runPostInstall() - - // The whole of the wiring: GRADLE_DISTS//lib is where the extractor puts the - // distribution's jars. A dedup aimed one directory off links nothing, reclaims nothing, and - // still completes the install - so only an actual link proves the path was composed right. - assertThat(Files.isSymbolicLink(duplicate.toPath())).isTrue() - assertThat(Files.readSymbolicLink(duplicate.toPath()).toFile().canonicalFile) - .isEqualTo(inDistribution.canonicalFile) - // Gradle resolves through the Maven coordinate and must not be able to tell the difference. - assertThat(duplicate.readText()).isEqualTo("shared-bytes") - } - - @Test - fun `a half-extracted tree leaves the install alone rather than failing it`() { - // postInstall also runs from the caller's `finally` after a FAILED install, so neither the - // distribution nor the build tools are guaranteed to be on disk. Returning normally here is - // the assertion: a space optimisation must not convert a failed install into a crash. - val untouched = mavenJar("com/example/thing/1.0", "thing-1.0.jar", "bytes") - - runPostInstall() - - assertThat(Files.isSymbolicLink(untouched.toPath())).isFalse() - assertThat(untouched.readText()).isEqualTo("bytes") - } -} diff --git a/app/src/test/java/com/itsaky/androidide/assets/DistJarDeduplicatorTest.kt b/app/src/test/java/com/itsaky/androidide/assets/DistJarDeduplicatorTest.kt deleted file mode 100644 index d99b10c288..0000000000 --- a/app/src/test/java/com/itsaky/androidide/assets/DistJarDeduplicatorTest.kt +++ /dev/null @@ -1,266 +0,0 @@ -package com.itsaky.androidide.assets - -import com.google.common.truth.Truth.assertThat -import org.junit.Assume.assumeTrue -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder -import java.io.File -import java.nio.file.Files - -/** - * The offline Maven repo and the Gradle distribution are harvested independently and overlap by - * construction, most expensively at `kotlin-compiler-embeddable`. Collapsing that overlap is only - * safe if identity is decided by content: the two stores version independently, so a shared file - * name proves nothing, and linking a same-named jar that is not the same jar would break a user's - * build a long way from here. - */ -class DistJarDeduplicatorTest { - @get:Rule - val temp = TemporaryFolder() - - private lateinit var mavenRepo: File - private lateinit var distLib: File - - private fun setUpTrees() { - mavenRepo = temp.newFolder("localMvnRepository") - distLib = temp.newFolder("gradle-dist-lib") - } - - /** A jar at a realistic Maven coordinate path, since the walk has to recurse to find it. */ - private fun mavenJar( - coordinate: String, - name: String, - content: String, - ): File { - val dir = File(mavenRepo, coordinate) - dir.mkdirs() - return File(dir, name).apply { writeText(content) } - } - - private fun distJar( - name: String, - content: String, - ): File = File(distLib, name).apply { writeText(content) } - - @Test - fun `an identical jar becomes a link to the distribution copy`() { - setUpTrees() - val duplicate = - mavenJar("org/jetbrains/kotlin/kotlin-stdlib/2.3.21", "kotlin-stdlib-2.3.21.jar", "same-bytes") - val source = distJar("kotlin-stdlib-2.3.21.jar", "same-bytes") - - val outcome = DistJarDeduplicator.deduplicate(mavenRepo, distLib) - - assertThat(Files.isSymbolicLink(duplicate.toPath())).isTrue() - // Content still reachable at the Maven coordinate: Gradle resolves through this path and - // must not be able to tell the difference. - assertThat(duplicate.readText()).isEqualTo("same-bytes") - assertThat(Files.readSymbolicLink(duplicate.toPath()).toFile().canonicalFile) - .isEqualTo(source.canonicalFile) - assertThat(outcome.linked).isEqualTo(1) - assertThat(outcome.bytesReclaimed).isEqualTo("same-bytes".length.toLong()) - } - - @Test - fun `a same-named jar with different content is left alone`() { - setUpTrees() - // The case that makes name-matching unsafe, and the reason the pre-filter is size while - // the decision is the hash: same name, same length, different bytes. - val duplicate = - mavenJar("org/jetbrains/kotlin/kotlin-stdlib/2.3.21", "kotlin-stdlib-2.3.21.jar", "aaaaaaaaa") - distJar("kotlin-stdlib-2.3.21.jar", "bbbbbbbbb") - - val outcome = DistJarDeduplicator.deduplicate(mavenRepo, distLib) - - assertThat(Files.isSymbolicLink(duplicate.toPath())).isFalse() - assertThat(duplicate.readText()).isEqualTo("aaaaaaaaa") - assertThat(outcome.linked).isEqualTo(0) - } - - @Test - fun `identical content under a different name is still linked`() { - setUpTrees() - // Identity is the bytes, not the coordinate. A repackaged-but-identical artifact under - // another name is just as safe to collapse. - val duplicate = mavenJar("com/example/thing/1.0", "thing-1.0.jar", "identical") - distJar("some-other-name.jar", "identical") - - val outcome = DistJarDeduplicator.deduplicate(mavenRepo, distLib) - - assertThat(Files.isSymbolicLink(duplicate.toPath())).isTrue() - assertThat(outcome.linked).isEqualTo(1) - } - - @Test - fun `a jar with no counterpart is left alone`() { - setUpTrees() - val untouched = mavenJar("com/example/only-here/1.0", "only-here-1.0.jar", "unique-bytes") - distJar("something-else.jar", "different length entirely") - - val outcome = DistJarDeduplicator.deduplicate(mavenRepo, distLib) - - assertThat(Files.isSymbolicLink(untouched.toPath())).isFalse() - assertThat(untouched.readText()).isEqualTo("unique-bytes") - assertThat(outcome.linked).isEqualTo(0) - } - - @Test - fun `a missing distribution is a no-op rather than a failure`() { - setUpTrees() - val untouched = mavenJar("com/example/thing/1.0", "thing-1.0.jar", "bytes") - - // An install must not fail because a space optimisation had nothing to work with. - val outcome = DistJarDeduplicator.deduplicate(mavenRepo, File(temp.root, "never-extracted")) - - assertThat(outcome.linked).isEqualTo(0) - assertThat(untouched.readText()).isEqualTo("bytes") - } - - @Test - fun `a missing maven repo is a no-op rather than a failure`() { - setUpTrees() - distJar("kotlin-stdlib-2.3.21.jar", "bytes") - - val outcome = DistJarDeduplicator.deduplicate(File(temp.root, "never-extracted"), distLib) - - assertThat(outcome.linked).isEqualTo(0) - } - - @Test - fun `a second pass over an already-linked tree changes nothing`() { - setUpTrees() - val duplicate = mavenJar("com/example/thing/1.0", "thing-1.0.jar", "same-bytes") - distJar("thing-1.0.jar", "same-bytes") - - val first = DistJarDeduplicator.deduplicate(mavenRepo, distLib) - val second = DistJarDeduplicator.deduplicate(mavenRepo, distLib) - - // Every install re-runs this, so the steady state is "already linked". Re-linking a link - // would be harmless but re-counting it would make the reclaimed figure a fiction. - assertThat(first.linked).isEqualTo(1) - assertThat(second.linked).isEqualTo(0) - assertThat(second.bytesReclaimed).isEqualTo(0) - assertThat(duplicate.readText()).isEqualTo("same-bytes") - } - - @Test - fun `non-jar files are never touched`() { - setUpTrees() - val pom = mavenJar("com/example/thing/1.0", "thing-1.0.pom", "same-bytes") - distJar("thing-1.0.jar", "same-bytes") - - val outcome = DistJarDeduplicator.deduplicate(mavenRepo, distLib) - - // POMs and .module files are small and Gradle reads them constantly; there is nothing to - // win and a needless way to break resolution. - assertThat(Files.isSymbolicLink(pom.toPath())).isFalse() - assertThat(outcome.linked).isEqualTo(0) - } - - @Test - fun `several duplicates in one pass are all collapsed and counted`() { - setUpTrees() - val one = mavenJar("a/b/1.0", "one-1.0.jar", "first-content") - val two = mavenJar("c/d/2.0", "two-2.0.jar", "second-content-longer") - val notADuplicate = mavenJar("e/f/3.0", "three-3.0.jar", "no twin here at all") - distJar("one-1.0.jar", "first-content") - distJar("two-2.0.jar", "second-content-longer") - - val outcome = DistJarDeduplicator.deduplicate(mavenRepo, distLib) - - assertThat(Files.isSymbolicLink(one.toPath())).isTrue() - assertThat(Files.isSymbolicLink(two.toPath())).isTrue() - assertThat(Files.isSymbolicLink(notADuplicate.toPath())).isFalse() - assertThat(outcome.linked).isEqualTo(2) - assertThat(outcome.bytesReclaimed) - .isEqualTo(("first-content".length + "second-content-longer".length).toLong()) - } - - @Test - fun `no staging leftovers survive a pass`() { - setUpTrees() - mavenJar("com/example/thing/1.0", "thing-1.0.jar", "same-bytes") - distJar("thing-1.0.jar", "same-bytes") - - DistJarDeduplicator.deduplicate(mavenRepo, distLib) - - // The swap builds the link beside the file before moving it over; a leftover .dedup entry - // would mean a partial swap that a later pass could trip over. - val strays = mavenRepo.walkTopDown().filter { it.name.endsWith(".dedup") }.toList() - assertThat(strays).isEmpty() - } - - @Test - fun `a distribution holding no jars is a no-op rather than a failure`() { - setUpTrees() - val untouched = mavenJar("com/example/thing/1.0", "thing-1.0.jar", "same-bytes") - // Same bytes, but neither entry is a jar: one is a plain file, the other a directory. An - // index built from either would make this jar look like a duplicate of something that is - // not a jar at all. - distJar("thing-1.0.pom", "same-bytes") - File(distLib, "plugins").mkdirs() - - val outcome = DistJarDeduplicator.deduplicate(mavenRepo, distLib) - - assertThat(outcome).isEqualTo(DistJarDeduplicator.Outcome(linked = 0, bytesReclaimed = 0)) - assertThat(Files.isSymbolicLink(untouched.toPath())).isFalse() - assertThat(untouched.readText()).isEqualTo("same-bytes") - } - - @Test - fun `two unreadable jars of the same size are never treated as identical`() { - setUpTrees() - // The danger in a half-extracted tree: if an unreadable file hashed to some sentinel instead - // of to nothing, two files nobody can read would look like twins and a user's jar would be - // linked onto bytes that are not its own. Equal lengths make them reach the hash comparison. - val unreadable = mavenJar("com/example/broken/1.0", "broken-1.0.jar", "AAAAAAAA") - val unreadableTwin = distJar("broken-twin.jar", "BBBBBBBB") - val readable = mavenJar("com/example/fine/1.0", "fine-1.0.jar", "readable-bytes") - distJar("fine-twin.jar", "readable-bytes") - assumeEnforcedPermission(unreadable.setReadable(false, false) && !unreadable.canRead()) - assumeEnforcedPermission(unreadableTwin.setReadable(false, false) && !unreadableTwin.canRead()) - - val outcome = DistJarDeduplicator.deduplicate(mavenRepo, distLib) - - assertThat(Files.isSymbolicLink(unreadable.toPath())).isFalse() - assertThat(unreadable.length()).isEqualTo("AAAAAAAA".length.toLong()) - // The pass steps over the unreadable pair and keeps collapsing the rest - an unreadable jar - // is a reason to skip one file, not to abandon the optimisation or fail the install. - assertThat(Files.isSymbolicLink(readable.toPath())).isTrue() - assertThat(outcome.linked).isEqualTo(1) - assertThat(outcome.bytesReclaimed).isEqualTo("readable-bytes".length.toLong()) - } - - @Test - fun `a coordinate directory that refuses new entries leaves the jar as a real file`() { - setUpTrees() - val duplicate = mavenJar("com/example/thing/1.0", "thing-1.0.jar", "same-bytes") - distJar("thing-1.0.jar", "same-bytes") - val coordinateDir = checkNotNull(duplicate.parentFile) - assumeEnforcedPermission(coordinateDir.setWritable(false, false) && !coordinateDir.canWrite()) - - val outcome = - try { - DistJarDeduplicator.deduplicate(mavenRepo, distLib) - } finally { - // Restored here rather than in @After so TemporaryFolder can still clean up. - coordinateDir.setWritable(true, false) - } - - // The staging link cannot be created, so the swap never happens. A pass that counted the - // attempt anyway would report bytes reclaimed that are still very much on disk. - assertThat(outcome).isEqualTo(DistJarDeduplicator.Outcome(linked = 0, bytesReclaimed = 0)) - assertThat(Files.isSymbolicLink(duplicate.toPath())).isFalse() - assertThat(duplicate.readText()).isEqualTo("same-bytes") - assertThat(File(coordinateDir, "thing-1.0.jar.dedup").exists()).isFalse() - } - - /** - * Skip rather than pass vacuously where the filesystem ignores permission bits (a root user, or - * a mount that does not enforce them) - a green run there would prove nothing. - */ - private fun assumeEnforcedPermission(enforced: Boolean) { - assumeTrue("needs a filesystem that enforces permission bits", enforced) - } -} diff --git a/app/src/test/java/com/itsaky/androidide/assets/JarLinksTest.kt b/app/src/test/java/com/itsaky/androidide/assets/JarLinksTest.kt deleted file mode 100644 index d358d30eeb..0000000000 --- a/app/src/test/java/com/itsaky/androidide/assets/JarLinksTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.itsaky.androidide.assets - -import com.google.common.truth.Truth.assertThat -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder -import java.io.File -import java.nio.file.Files - -/** - * [JarLinks.trySymlink] is the single place both jar stores ask whether a link is possible, and its - * two callers act on opposite answers - the stager copies, the deduplicator walks away - so a wrong - * answer is silent either way. A false "true" leaves the daemon a classpath entry that is not - * there; a "false" that damaged the target on the way out would destroy bytes the caller was - * relying on still being present. - */ -class JarLinksTest { - @get:Rule - val temp = TemporaryFolder() - - @Test - fun `a link is created and the source bytes read back through it`() { - val source = - File(temp.newFolder("dist-lib"), "kotlin-compiler-embeddable.jar") - .apply { writeText("compiler-bytes") } - val target = File(temp.newFolder("daemon"), "kotlin-compiler-embeddable.jar") - - assertThat(JarLinks.trySymlink(target, source)).isTrue() - - // The callers' contract is the bytes at [target], not the link itself: the daemon's manifest - // Class-Path names this path and the JVM has to be able to open it. - assertThat(Files.isSymbolicLink(target.toPath())).isTrue() - assertThat(Files.readSymbolicLink(target.toPath()).toFile().canonicalFile) - .isEqualTo(source.canonicalFile) - assertThat(target.readText()).isEqualTo("compiler-bytes") - } - - @Test - fun `a refused link reports false and leaves the occupied target intact`() { - val source = - File(temp.newFolder("dist-lib"), "kotlin-stdlib.jar").apply { writeText("distribution-bytes") } - // An occupied target is how a link gets refused in practice - it is exactly why - // DistJarDeduplicator deletes its staging path before calling this. Whatever the cause, the - // documented contract is the same: report false, touch nothing. - val target = - File(temp.newFolder("daemon"), "kotlin-stdlib.jar").apply { writeText("existing-bytes") } - - assertThat(JarLinks.trySymlink(target, source)).isFalse() - - assertThat(Files.isSymbolicLink(target.toPath())).isFalse() - assertThat(target.readText()).isEqualTo("existing-bytes") - } -} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt index f45bc4bae2..e6ab4f9777 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt @@ -11,21 +11,14 @@ import java.io.FileNotFoundException import java.nio.file.Files /** - * ADFA-4931 removed five jars from the APK - the Kotlin compiler and four smaller artifacts the - * on-device Gradle distribution already ships - so the daemon now gets them from the - * distribution. That makes the link step load-bearing: if it silently does nothing, the daemon's - * manifest Class-Path names jars that are not there and the first compile dies deep inside the - * Build Tools API. + * The daemon now gets several jars from the Gradle distribution instead of the APK, so a link + * step that silently does nothing leaves its classpath naming files that are not there. */ class QuickBuildArtifactStagerDistLinkTest { @get:Rule val temp = TemporaryFolder() - /** - * Stand-ins for the real list, which the build generates. Two entries rather than one so a - * link step that handles only the first is visible; the compiler name is kept real because - * it is the one whose absence is most expensive. - */ + /** Stand-ins for the build-generated list, with two entries so a partial link step shows up. */ private val jarNames = listOf( "kotlin-compiler-embeddable-$KOTLIN_VERSION.jar", @@ -57,8 +50,7 @@ class QuickBuildArtifactStagerDistLinkTest { QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith()) - // Asserting the CONTENT rather than mere existence: a symlink and a copy are both - // acceptable outcomes, and both must leave the bytes reachable at this path. + // Asserts content, not existence, because a symlink and a copy are both acceptable. jarNames.forEach { name -> assertThat(File(daemonDir, name).readText()).isEqualTo("$name-bytes") } @@ -68,8 +60,7 @@ class QuickBuildArtifactStagerDistLinkTest { fun `nothing beyond the listed jars is staged`() { val daemonDir = daemonDirListing() - // The distribution carries far more than the daemon links; linking the whole lib/ would - // bury the daemon's classpath in Gradle's own jars. + // Linking the whole lib/ would bury the daemon's classpath in Gradle's own jars. QuickBuildArtifactStager.linkDistJars( daemonDir, gradleDistsWith(jarNames + "gradle-core-api-9.6.1.jar"), @@ -84,9 +75,7 @@ class QuickBuildArtifactStagerDistLinkTest { QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith()) - // The whole point of ADFA-4931 is to stop carrying two copies. A copy is a legitimate - // fallback, but on any filesystem that supports links (every JVM this test runs on) we - // should not be paying the bytes twice. + // Where links work, we must not be paying for the bytes twice. jarNames.forEach { name -> assertThat(Files.isSymbolicLink(File(daemonDir, name).toPath())).isTrue() } @@ -95,16 +84,14 @@ class QuickBuildArtifactStagerDistLinkTest { @Test fun `a jar that cannot be linked is copied instead, so the daemon still has the bytes`() { val daemonDir = daemonDirListing() - // An occupied target is how a link gets refused in practice - a stale jar left by an earlier - // staging, or a filesystem that takes no links at all. Either way the daemon's manifest - // Class-Path still names this path, so the current bytes have to end up here. + // However a link is refused, the current bytes still have to end up at this path. val occupied = File(daemonDir, jarNames.first()).apply { writeText("stale-bytes") } QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith()) assertThat(Files.isSymbolicLink(occupied.toPath())).isFalse() assertThat(occupied.readText()).isEqualTo("${jarNames.first()}-bytes") - // The fallback is per jar: one refused link must not cost the rest their cheap path. + // The fallback is per jar, so one refused link must not cost the rest theirs. assertThat(Files.isSymbolicLink(File(daemonDir, jarNames.last()).toPath())).isTrue() assertThat(File(daemonDir, jarNames.last()).readText()).isEqualTo("${jarNames.last()}-bytes") } @@ -118,8 +105,7 @@ class QuickBuildArtifactStagerDistLinkTest { runCatching { QuickBuildArtifactStager.linkDistJars(daemonDir, emptyDists) } .exceptionOrNull() - // Failing here is deliberate: the alternative is a NoClassDefFoundError partway into the - // user's first compile, a long way from the missing file that caused it. + // Failing here is deliberate, because the alternative surfaces mid-compile instead. assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) assertThat(thrown!!).hasMessageThat().contains(GRADLE_DISTRIBUTION_NAME) assertThat(thrown).hasMessageThat().contains(jarNames.first()) @@ -130,8 +116,7 @@ class QuickBuildArtifactStagerDistLinkTest { fun `one listed jar missing from the distribution fails the whole staging`() { val daemonDir = daemonDirListing() - // A partial link is the worst outcome: the daemon starts, and dies on whichever class - // happened to live in the jar that was skipped. + // A partial link is worse than none: the daemon starts and dies on the missing class. val thrown = runCatching { QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith(jarNames.take(1))) @@ -148,8 +133,7 @@ class QuickBuildArtifactStagerDistLinkTest { File(dists, "gradle-0.0.1/lib").mkdirs() jarNames.forEach { File(dists, "gradle-0.0.1/lib/$it").writeText("wrong distribution") } - // Guards the version-derived path: the jars must come from the distribution this build - // actually bundles, not from whatever else happens to be extracted alongside it. + // The jars must come from the distribution this build bundles, not a neighbouring one. val thrown = runCatching { QuickBuildArtifactStager.linkDistJars(daemonDir, dists) } .exceptionOrNull() @@ -161,9 +145,7 @@ class QuickBuildArtifactStagerDistLinkTest { fun `a daemon zip built without the list fails rather than linking nothing`() { val daemonDir = temp.newFolder("daemon") - // The list is how the build tells the device which jars it left out. Without it there is - // no way to tell "the build shipped everything" from "the build shipped nothing", and - // guessing the friendly answer means a compile that dies much later. + // Without the list there is no way to tell a complete build from an empty one. val thrown = runCatching { QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith()) } .exceptionOrNull() diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt index b943d12520..38b8a03425 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt @@ -15,12 +15,8 @@ import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream /** - * The zip-slip guard is a security control: the daemon zip is a bundled asset today, but the - * extraction must never write outside the daemon dir no matter what the archive says. These - * tests watch the guard go red - a `../` entry must throw BEFORE any byte lands outside. - * - * The staging tests pin the other invariant: an already-staged daemon directory is left alone - * for the same install, because a rebaseline stages while the compile daemon is running off it. + * Covers two invariants: extraction never writes outside the daemon dir, and an already-staged + * directory is left alone for the same install. */ class QuickBuildArtifactStagerTest { @get:Rule @@ -107,14 +103,10 @@ class QuickBuildArtifactStagerTest { assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) } - /** The one jar [daemonZip]'s linked-jars list names, kept real because the link uses the name. */ + /** The one jar [daemonZip]'s linked-jars list names. */ private val linkedJar = "kotlin-compiler-embeddable-$KOTLIN_VERSION.jar" - /** - * A fake `gradle-dists/` holding the jar the staged list names. Staging links rather than - * bundles these (ADFA-4931), and `Environment.GRADLE_DISTS` is unset off-device, so every - * staging test has to point the link somewhere real. - */ + /** A fake `gradle-dists/` for the link to point at, since the real one is unset off-device. */ private val gradleDists: File by lazy { val dists = tmp.newFolder("gradle-dists") val lib = File(dists, "$GRADLE_DISTRIBUTION_NAME/lib") @@ -149,11 +141,8 @@ class QuickBuildArtifactStagerTest { } /** - * The wiring, not the link itself - [QuickBuildArtifactStagerDistLinkTest] covers - * `linkDistJars` directly, and passed throughout a rebase that left the call unreachable - * behind a `return`. Staging is the only path that runs it in production, so this asserts - * from there: the APK does not carry this jar, so if staging does not put it here the daemon - * jar's manifest Class-Path names a file that is not on disk. + * Checks that staging actually calls the link step, which a rebase once left unreachable + * behind a `return` while the direct tests stayed green. */ @Test fun `staging links the distribution's jars into the daemon dir`() { @@ -162,8 +151,7 @@ class QuickBuildArtifactStagerTest { QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { daemonZip() } - // Content rather than existence: a symlink and a copy are both acceptable, and both must - // leave the bytes readable at this path. + // Asserts content, not existence, because a symlink and a copy are both acceptable. assertThat(File(daemonDir, linkedJar).readText()).isEqualTo("linked-jar-bytes") } diff --git a/build-info/build.gradle.kts b/build-info/build.gradle.kts index 2b6e9f61c4..738c794c11 100644 --- a/build-info/build.gradle.kts +++ b/build-info/build.gradle.kts @@ -76,11 +76,8 @@ tasks.create("generateBuildInfo") { "AGP_VERSION_LATEST" to libs.versions.agp.tooling .get(), - // The Gradle version AGP_VERSION_LATEST gets exercised against: the - // distribution the IDE bundles. Read from the constant rather than - // repeated, because AGP refuses to configure on a distribution older - // than it expects (8.11 needed 8.13; 9.3 needs 9.5), so a stale copy - // here fails every TestKit test at plugin-application time. + // Read from the constant rather than repeated: AGP refuses to configure on a Gradle + // distribution older than it expects, so a stale copy here fails every TestKit test. "AGP_VERSION_GRADLE_LATEST" to GRADLE_DISTRIBUTION_VERSION, "SNAPSHOTS_REPOSITORY" to VersionUtils.SONATYPE_SNAPSHOTS_REPO, "PUBLIC_REPOSITORY" to VersionUtils.SONATYPE_PUBLIC_REPO, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc3f7d1f1c..fde17d1d56 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,10 +20,9 @@ junit-jupiter = "5.10.2" anroidx-test-core = "2.2.0" koinAndroid = "4.1.1" kotlin = "2.3.0" -# The Quick Build daemon's compiler. Must equal org.adfa.constants.KOTLIN_VERSION -- the -# Kotlin the on-device Gradle distribution embeds -- because the daemon loads that -# distribution's kotlin-compiler-embeddable instead of shipping its own copy. :app's -# quickBuildDistJarCheck asserts the two agree. +# The Quick Build daemon's compiler, which it loads from the on-device Gradle distribution +# rather than shipping its own. Must equal org.adfa.constants.KOTLIN_VERSION (the Kotlin that +# distribution embeds); :app's quickBuildDistJarCheck fails the build if they differ. kotlin-daemon-compiler = "2.3.21" kotlin-coroutines = "1.9.0" kotlinxCoroutinesCore = "1.10.2" @@ -247,14 +246,13 @@ androidx-work-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = " # Google google-material = { module = "com.google.android.material:material", version = "1.12.0" } google-gson = { module = "com.google.code.gson:gson", version = "2.12.1" } -# Quick Build's daemon runs its own JVM and takes this from the on-device Gradle -# distribution rather than the APK, so its version is the distribution's choice, not ours -# (:app's quickBuildDistJarCheck asserts the bytes match). Kept separate from google-gson -# above so an app-wide bump cannot silently break that link. +# The Quick Build daemon takes this from the on-device Gradle distribution rather than the APK, +# so the version is the distribution's choice, not ours. Kept separate from google-gson above so +# an app-wide bump cannot silently break that link. gson-quickBuildDaemon = { module = "com.google.code.gson:gson", version = "2.13.1" } -# Same story for the coroutines the Kotlin compiler pulls in transitively. Names the -jvm -# platform module rather than the KMP root because that is what the compiler depends on -# directly, and a constraint on the root would not govern it. +# Same story for the coroutines the Kotlin compiler pulls in. Names the -jvm module rather than +# the KMP root because that is what the compiler depends on, and a constraint on the root would +# not govern it. kotlinx-coroutines-core-jvm-quickBuildDaemon = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm", version = "1.10.2" } google-guava = { module = "com.google.guava:guava", version = "33.4.0-android" } google-auto-value-annotations = { module = "com.google.auto.value:auto-value-annotations", version = "1.11.0" } diff --git a/quickbuild/daemon/build.gradle.kts b/quickbuild/daemon/build.gradle.kts index 1478ea4da6..0a1c41de58 100644 --- a/quickbuild/daemon/build.gradle.kts +++ b/quickbuild/daemon/build.gradle.kts @@ -103,40 +103,23 @@ dependencies { api(projects.quickbuild.protocol) implementation(libs.kotlin.buildToolsApi) - // gson and asm follow the on-device Gradle distribution rather than the app: ADFA-4931 - // links both from there instead of shipping them in the APK, so the version is the - // distribution's choice. Hence gson-quickBuildDaemon and not the app-wide google-gson. + // Pinned to the on-device Gradle distribution's version, not the app's, because ADFA-4931 + // links gson from the distribution instead of shipping it in the APK. implementation(libs.gson.quickBuildDaemon) // ACC_FINAL stripping on recompiled payload classes (proxies extend user classes). implementation(libs.ow2.asm) constraints { - // kotlin-compiler-embeddable asks for coroutines 1.8.0; the distribution runs that same - // compiler against 1.10.2, which is both why the upgrade is safe and what makes the jar - // linkable at all. + // kotlin-compiler-embeddable asks for 1.8.0, but the on-device distribution already runs + // that compiler against 1.10.2, so the upgrade is safe. runtimeOnly(libs.kotlinx.coroutines.core.jvm.quickBuildDaemon) { because("the on-device Gradle distribution ships this version, which ADFA-4931 links") } } - // The BTA implementation + its runtime deps are staged alongside the daemon jar on - // device and loaded from there. The compiler itself is the exception: ADFA-4931 - // excludes it from the zip and links the on-device Gradle distribution's copy in, so - // this must stay pinned to the distribution's Kotlin (libs.versions.toml's - // kotlin-daemon-compiler, asserted against org.adfa.constants.KOTLIN_VERSION). - // - // kotlin-compiler-runner exists solely to launch/talk to a separate long-lived - // "Kotlin compile daemon" JVM over RMI, which IncrementalCompiler never does here - // (it always calls useInProcessStrategy()) - dead weight (~17 KB, ADFA-4128 size audit). - // - // Two others look like the same dead weight and are NOT. Both re-tested by exclusion on - // the 2.3.21 graph (ADFA-4931), because the note here used to name kotlin-daemon-client, - // which this graph no longer resolves at all: - // - kotlin-daemon-embeddable carries - // org.jetbrains.kotlin.daemon.common.CompileIterationResult, referenced by - // kotlin-build-tools-impl's own BuildToolsApiBuildICReporter on the in-process path. - // - kotlin-reflect - 1.6.10, which is what the 2.3.21 compiler's POM declares rather - // than a stale pin of ours - is needed by the compiler itself. Excluding it fails - // every daemon test that runs a real compile with NoClassDefFoundError. + // Pinned to the on-device Gradle distribution's Kotlin, whose compiler ADFA-4931 links + // instead of shipping one. kotlin-compiler-runner only drives a separate compile-daemon + // JVM over RMI, which this daemon never does; do not also exclude kotlin-daemon-embeddable + // or kotlin-reflect, which look just as unused but break every real compile. runtimeOnly(libs.kotlin.buildToolsImpl) { exclude(group = "org.jetbrains.kotlin", module = "kotlin-compiler-runner") } From a3f721f15df24ea679ad715bc2b16170401fff40 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 24 Sep 2026 10:15:12 -0700 Subject: [PATCH 11/22] ADFA-4931: document that the daemon loads the distribution's compiler ADR 0016 and the quickbuild docs described the Quick Build daemon as shipping its own Kotlin compiler, which stopped being true with the jar linking. Each is a line or two, at the point where the claim is made. The daemon README's exclusion bullet named kotlin-daemon-client, which the 2.3.21 dependency graph no longer resolves at all. It now names the two modules this branch re-tested by exclusion, matching the note in build.gradle.kts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GwtUCGy8fKaSzu2uuUEUPY --- ARCHITECTURE.md | 1 + docs/adr/0016-quick-build-compiles-outside-gradle.md | 2 +- quickbuild/daemon/README.md | 12 +++++++++--- quickbuild/docs/debugging.md | 2 +- quickbuild/docs/pipeline.md | 4 ++-- 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fd60cebe40..fc9eb607b0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -97,6 +97,7 @@ These structural facts shape every module. Day-to-day build *commands* live in ` - **SDK levels** (`build-logic/.../build/config/BuildConfig.kt`): `COMPILE_SDK=36`, `MIN_SDK=28`, `TARGET_SDK=28`. **`TARGET_SDK` is deliberately pinned at 28:** higher targets enforce W^X (write-xor-execute), which blocks executing code from app-writable files. That is fatal for an on-device IDE that compiles and runs code (Gradle, `javac`, Termux binaries), so it is a hard requirement, not tech debt. `MIN_SDK_FOR_APPS_BUILT_WITH_COGO=16` is the floor for the apps a *user* builds with CoGo — distinct from CoGo's own `MIN_SDK`. - **Native asset bundling.** The on-device LLM (`llama-impl`) ships as a per-flavor native AAR, wired through the root `build.gradle.kts` (`bundleLlamaV8Assets` / `assembleV8Assets`, …); prebuilt per-flavor assets live under `assets/release/v7/` and `assets/release/v8/`. - **Native lib compression** (ADFA-2306, ADFA-4729). The app manifest hard-codes `android:extractNativeLibs="true"` (required: the installer must materialize libs in `nativeLibraryDir`, e.g. `libshizuku.so` is an executable the adb shell runs from there). That attribute overrides the `jniLibs.useLegacyPackaging` DSL, so AGP packages `lib//*.so` deflate-compressed in **every** APK — ~5.9 MB smaller (`libtree-sitter-kotlin.so` alone is 4.18 MB → 339 kB). The trap is the `recompressApk` post-step (release always, debug in CI only): its no-compress lists in `app/build.gradle.kts` must NOT contain `"so"`, or it silently re-stores the libs and undoes the saving — which is what ADFA-2306 fixed for release and ADFA-4729 for CI debug. Locally built debug APKs (including the e2e farm's) never run that step and were always fine. +- **Quick Build shares the Gradle distribution's toolchain** (ADFA-4931). `:app`'s `quickBuildDaemonZip` leaves the Kotlin compiler and seven other jars out of the daemon zip, because the on-device Gradle distribution already carries them; `QuickBuildArtifactStager` links them in at provision time. That makes the daemon's Kotlin version the distribution's: `libs.versions.toml`'s `kotlin-daemon-compiler` must equal `org.adfa.constants.KOTLIN_VERSION`, and `:app:quickBuildDistJarCheck` fails the build when it does not. See [ADR 0016](docs/adr/0016-quick-build-compiles-outside-gradle.md). - **`app` package layout is by concern, not feature:** `activities`, `fragments`, `services`, `di`, `agent`, `viewmodel(s)`, `repositories`, `roomData`, `localWebServer`, `preferences`, `ui` (Compose screens live under `ui/compose`), `templates/manager` (the Manager screen's `.cgt`-parsing data layer, with direct filesystem access to `Environment.TEMPLATES_DIR` — distinct from the plugin-facing `IdeTemplateService` in `plugin-api`/`plugin-manager`), `utils`, …. ## Sibling Repositories & Asset Provenance diff --git a/docs/adr/0016-quick-build-compiles-outside-gradle.md b/docs/adr/0016-quick-build-compiles-outside-gradle.md index f655661209..04a75f160d 100644 --- a/docs/adr/0016-quick-build-compiles-outside-gradle.md +++ b/docs/adr/0016-quick-build-compiles-outside-gradle.md @@ -25,7 +25,7 @@ The first three cannot be sped up or skipped. **Gradle handles what live reload cannot.** It still provisions the proxy app through the existing Tooling API path, and still builds every edit the classifier declines. Nothing a user installs or ships comes out of the daemon. -**One compiler, not two.** Quick Build needs Kotlin 2.3.x for faster, more robust incremental compilation. Until the rest of CoGo moves up, the APK carries two Kotlin compilers. The move is in review as ADFA-2602; unifying them is ADFA-4931. +**One compiler, not two (ADFA-4931).** The daemon no longer ships a Kotlin compiler. `:app`'s `quickBuildDaemonZip` leaves `kotlin-compiler-embeddable` and seven other jars out of the daemon zip, and `QuickBuildArtifactStager` links the on-device Gradle distribution's copies in at provision time - 57.6 MB off the debug v8 APK. The price is a version tie: the daemon's Kotlin must equal the distribution's, and `:app:quickBuildDistJarCheck` fails the build when it does not. ## Consequences diff --git a/quickbuild/daemon/README.md b/quickbuild/daemon/README.md index df0817fd75..49ed7848a3 100644 --- a/quickbuild/daemon/README.md +++ b/quickbuild/daemon/README.md @@ -125,9 +125,15 @@ mid-construction cannot leave a fingerprint describing snapshots that were never ## Traps - **Never print to stdout.** Use the injected `log` / `warn` channels, which reach stderr. -- **`kotlin-daemon-client` and `kotlin-daemon-embeddable` look like dead weight and are not.** - Excluding them throws `NoClassDefFoundError` from inside the in-process path. `build.gradle.kts` - records which exclusion is safe and why. +- **Eight of the jars beside the daemon jar on device are not in the APK.** `:app`'s + `quickBuildDaemonZip` leaves out `kotlin-compiler-embeddable` and seven others, and + `QuickBuildArtifactStager` links the on-device Gradle distribution's copies in under the same + names, which the manifest `Class-Path` matches by exact file name. So the daemon's Kotlin version + must equal the distribution's; `:app:quickBuildDistJarCheck` fails the build when it does not. +- **`kotlin-daemon-embeddable` and `kotlin-reflect` look like dead weight and are not.** + Excluding either throws `NoClassDefFoundError` from inside the in-process path. `build.gradle.kts` + records which exclusion is safe and why. (`kotlin-daemon-client`, which this bullet used to name, + is not on the 2.3.21 graph at all.) - **A `Result.Failed` diagnostic list is bounded.** kotlinc emits one unresolved-reference error per use site, so a deleted dependency yields hundreds; the whole list rides one protocol line into a phone-screen panel. kotlinc, javac and aapt2 diagnostics cap at 50 entries each with a "+K more diff --git a/quickbuild/docs/debugging.md b/quickbuild/docs/debugging.md index e04f432353..defc383de8 100644 --- a/quickbuild/docs/debugging.md +++ b/quickbuild/docs/debugging.md @@ -179,7 +179,7 @@ to CoGo. | Path | What | How to read it | | --- | --- | --- | | `files/home/.cg/quickbuild/quickbuild-runtime.aar` | staged runtime AAR, re-staged every provision | `run-as com.itsaky.androidide` | -| `files/home/.cg/quickbuild/daemon/` | daemon jar, its full runtime classpath, `compose-compiler-plugin.jar`; deleted and re-extracted every provision | `run-as com.itsaky.androidide` | +| `files/home/.cg/quickbuild/daemon/` | daemon jar, its full runtime classpath, `compose-compiler-plugin.jar`; deleted and re-extracted every provision. The jars named in `dist-linked-jars.txt`, the Kotlin compiler among them, are symlinks into the Gradle distribution's `lib/` rather than extracted files - `ls -l` here, since a broken one surfaces as a `NoClassDefFoundError` on the first compile | `run-as com.itsaky.androidide` | | `files/home/.cg/quickbuild/bench-events.jsonl` | session event log; bench flag only | `run-as com.itsaky.androidide` | | `no_backup/quickbuild-scratch/-<16 hex>/work` | executor payload staging | `run-as com.itsaky.androidide`; **deleted on teardown** | | `no_backup/quickbuild-scratch/-<16 hex>/out` | daemon output: classes, dex, relinked resources | as above | diff --git a/quickbuild/docs/pipeline.md b/quickbuild/docs/pipeline.md index 10447e183e..bb0f30b6ac 100644 --- a/quickbuild/docs/pipeline.md +++ b/quickbuild/docs/pipeline.md @@ -356,7 +356,7 @@ flowchart TB Step 7's rebuild fallback re-enters the same runner through `rebuildProxyApp`. -1. [QuickBuildArtifactStager](../../app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt) extracts the runtime AAR and the daemon zip from CoGo's APK assets into `/quickbuild/`. It re-extracts on **every** provision on purpose: a version-keyed marker serves a stale bundle when content changes without a version bump. +1. [QuickBuildArtifactStager](../../app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt) extracts the runtime AAR and the daemon zip from CoGo's APK assets into `/quickbuild/`. The zip leaves out the eight jars the on-device Gradle distribution already carries, the Kotlin compiler among them; the stager reads their names from `dist-linked-jars.txt` and symlinks the distribution's copies in beside the daemon jar, copying instead if the filesystem refuses a symlink. It re-extracts on **every** provision on purpose: a version-keyed marker serves a stale bundle when content changes without a version bump. 2. [GradleQuickBuildProvisioner](../../app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt) runs the proxy app build through CoGo's existing `BuildService.executeTasks`, then parses `setup.json` into [ProxyAppInfo](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt). 3. [QuickBuildScratch](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt) creates `noBackupFilesDir/quickbuild-scratch//{work,out}` and enforces the free-space floor. It must stay on app-private storage - see the README's "Build Scratch Lives in Faster Private Storage". 4. [QuickBuildClobberCheck](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt) plus [RealIdInstall](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/RealIdInstall.kt) read the installed package's `android:appComponentFactory` to decide whether the tap clobbers a Standard Run install. Stateless: an install or uninstall outside CoGo cannot leave it stale. @@ -637,7 +637,7 @@ Resources are relinked by [Aapt2Link](../daemon/src/main/kotlin/org/appdevforall ### Shrinking the daemon: what works, and what breaks it -The daemon ships as a ~62 MB zip inside CoGo's APK, almost all of it `kotlin-compiler-embeddable`. Findings from the ADFA-4128 shrink spike, kept here because the build task that implemented them has been removed as an unexecuted follow-up - redo the surgery from this if the size is worth reclaiming, and gate it on a green corpus run. +ADFA-4931 moved `kotlin-compiler-embeddable` and seven other jars out of the daemon zip; the daemon links the on-device Gradle distribution's copies instead. The findings below are from the ADFA-4128 shrink spike, kept here because the build task that implemented them has been removed as an unexecuted follow-up. They now target a jar inside that distribution, which CoGo downloads rather than builds - so reclaiming the space means repackaging the distribution, and it still needs a green corpus run. - **R8 is ruled out.** `R8 --classfile` shrank the compiler to 37 MB and produced a non-functional daemon: every Kotlin compile died with a `NoClassDefFoundError` initializing a core CLI diagnostics class, because tree-shaking cut a static-init dependency reached only reflectively. A compiler is exactly the kind of code R8 cannot reason about. - **Dropping whole never-loaded backend subtrees does work**, and is a different operation - plain jar surgery, not tree-shaking. Every class that survives is byte-identical, so nothing that remains can break on a reflective lookup. It removed **~5.7 MB from the compressed daemon zip**. From 27032b4c483e808672a4abb8da2fb8aada7ad86a Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 24 Sep 2026 11:33:32 -0700 Subject: [PATCH 12/22] ADFA-4931: stop a re-stage from truncating the distribution's own jars Code review found that linkDistJars could destroy the on-device Gradle distribution. createSymbolicLink refuses an existing path, the broad catch swallowed that, and the copy fallback then opened the stale link for write -- which truncates the distribution's jar, not the daemon's copy. Every Gradle build on the device breaks, and the stamp records it as success. Deleting the target before linking removes the class of failure. The rest is the same review's smaller findings: - link failures leave no stamp, now covered by a test that goes red when the stamp is written first - names read from dist-linked-jars.txt must be bare file names, the guard extractDaemonZip already applies twelve lines up - a missing list and an empty one now say which one happened - the copy fallback is reached by injecting the link operation, so the test named for a symlink-less filesystem tests one - the daemon zip's exclusion is scoped to the classpath it is about - two docs bullets said the daemon re-extracts every provision; it re-extracts when the installed APK changed Tested: 304 app quickbuild tests green, spotlessCheck green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GwtUCGy8fKaSzu2uuUEUPY --- app/build.gradle.kts | 6 +- .../quickbuild/QuickBuildArtifactStager.kt | 44 +++++++++--- .../QuickBuildArtifactStagerDistLinkTest.kt | 69 +++++++++++++++++-- .../QuickBuildArtifactStagerTest.kt | 19 +++++ quickbuild/docs/debugging.md | 2 +- quickbuild/docs/pipeline.md | 2 +- 6 files changed, 119 insertions(+), 23 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fbe2abd1b4..2b1f207586 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,7 +8,6 @@ import org.adfa.constants.GRADLE_API_NAME_JAR_BR import org.adfa.constants.GRADLE_API_NAME_JAR_ZIP import org.adfa.constants.GRADLE_DISTRIBUTION_ARCHIVE_NAME import org.adfa.constants.GRADLE_DISTRIBUTION_NAME -import org.adfa.constants.GRADLE_DISTRIBUTION_VERSION import org.adfa.constants.KOTLIN_VERSION import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform import org.json.JSONObject @@ -627,11 +626,10 @@ val quickBuildDaemonZip = from(daemonProject.tasks.named("daemonJar")) // The daemon jar's manifest Class-Path names these by file name; they must sit // next to the jar after extraction. - from(daemonProject.configurations.named("runtimeClasspath")) // ADFA-4931: except the ones the distribution already carries; the stager links those - // in at provision time. + // in at provision time. Scoped to this spec so it cannot filter the other from()s. val linked = quickBuildDistLinkedJars.toSet() - exclude { it.name in linked } + from(daemonProject.configurations.named("runtimeClasspath")) { exclude { it.name in linked } } from(quickBuildDistLinkedJarList) // Compose compiler plugin, version-matched to the daemon's compiler; the stable // name is the contract EnvironmentQuickBuildPaths.composeCompilerPlugin reads. diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt index 8378231ffd..86f5c2410e 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt @@ -10,6 +10,7 @@ import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream import java.nio.file.Files +import java.nio.file.Path import java.util.zip.ZipInputStream /** @@ -148,6 +149,7 @@ object QuickBuildArtifactStager { * expects them. * * @param gradleDists exists so tests can point at a fake distribution. + * @param createLink exists so tests can force the copy fallback. * @throws FileNotFoundException if a jar or the list is missing, which fails loudly here * rather than as a NoClassDefFoundError during the user's first compile. */ @@ -155,6 +157,7 @@ object QuickBuildArtifactStager { internal fun linkDistJars( daemonDir: File, gradleDists: File = Environment.GRADLE_DISTS, + createLink: (Path, Path) -> Unit = { link, existing -> Files.createSymbolicLink(link, existing) }, ) { val distLib = File(File(gradleDists, GRADLE_DISTRIBUTION_NAME), "lib") for (jarName in readLinkedJarNames(daemonDir)) { @@ -167,31 +170,50 @@ object QuickBuildArtifactStager { } val target = File(daemonDir, jarName) + // Clear the path first: a link an earlier staging left would make the copy fallback + // follow it and truncate the distribution's own jar. + Files.deleteIfExists(target.toPath()) try { - Files.createSymbolicLink(target.toPath(), source.toPath()) - } catch (e: Exception) { - // A filesystem without symlinks still needs these bytes, so copy instead. - log.warn("Symlink {} -> {} failed ({}), copying", target, source, e.toString()) - source.inputStream().use { input -> target.outputStream().use { input.copyTo(it) } } + createLink(target.toPath(), source.toPath()) + } catch (e: IOException) { + copyInstead(source, target, e) + } catch (e: UnsupportedOperationException) { + copyInstead(source, target, e) } } } + /** Copies [source] to [target] when the filesystem refused a link, so the bytes still arrive. */ + private fun copyInstead( + source: File, + target: File, + cause: Exception, + ) { + log.warn("Symlink {} -> {} failed ({}), copying", target, source, cause.toString()) + source.inputStream().use { input -> target.outputStream().use { input.copyTo(it) } } + } + /** The jar names the build left out of the zip for the distribution to supply. */ private fun readLinkedJarNames(daemonDir: File): List { val list = File(daemonDir, LINKED_JARS_LIST) - val names = - if (list.isFile) { - list.readLines().map(String::trim).filter(String::isNotEmpty) - } else { - emptyList() - } + if (!list.isFile) { + throw FileNotFoundException( + "$LINKED_JARS_LIST is missing from $daemonDir, so the build never wrote it. It " + + "lists the jars Quick Build links from the Gradle distribution.", + ) + } + val names = list.readLines().map(String::trim).filter(String::isNotEmpty) if (names.isEmpty()) { throw FileNotFoundException( "$LINKED_JARS_LIST at $list names no jars. It lists the jars Quick Build links " + "from the Gradle distribution rather than shipping in the APK.", ) } + // Each name is used as a path on both sides of the link, so the same boundary + // extractDaemonZip guards applies here: anything but a bare file name escapes. + names.firstOrNull { File(it).name != it }?.let { name -> + throw IOException("Refusing linked jar name that is not a bare file name: $name") + } return names } } diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt index e6ab4f9777..a885b75340 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt @@ -8,6 +8,7 @@ import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File import java.io.FileNotFoundException +import java.io.IOException import java.nio.file.Files /** @@ -84,18 +85,73 @@ class QuickBuildArtifactStagerDistLinkTest { @Test fun `a jar that cannot be linked is copied instead, so the daemon still has the bytes`() { val daemonDir = daemonDirListing() - // However a link is refused, the current bytes still have to end up at this path. - val occupied = File(daemonDir, jarNames.first()).apply { writeText("stale-bytes") } + val refused = jarNames.first() - QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith()) + // Stands in for a filesystem that has no symlinks, which throws rather than returning. + QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith()) { link, existing -> + if (link.fileName.toString() == refused) throw UnsupportedOperationException("no symlinks here") + Files.createSymbolicLink(link, existing) + } - assertThat(Files.isSymbolicLink(occupied.toPath())).isFalse() - assertThat(occupied.readText()).isEqualTo("${jarNames.first()}-bytes") + assertThat(Files.isSymbolicLink(File(daemonDir, refused).toPath())).isFalse() + assertThat(File(daemonDir, refused).readText()).isEqualTo("$refused-bytes") // The fallback is per jar, so one refused link must not cost the rest theirs. assertThat(Files.isSymbolicLink(File(daemonDir, jarNames.last()).toPath())).isTrue() assertThat(File(daemonDir, jarNames.last()).readText()).isEqualTo("${jarNames.last()}-bytes") } + @Test + fun `a link refused with an IO error is copied too, not just an unsupported one`() { + val daemonDir = daemonDirListing() + val refused = jarNames.first() + + // Stands in for a filesystem that supports links but refuses this one, such as a + // read-only mount or a denied permission. + QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith()) { link, existing -> + if (link.fileName.toString() == refused) throw IOException("permission denied") + Files.createSymbolicLink(link, existing) + } + + assertThat(Files.isSymbolicLink(File(daemonDir, refused).toPath())).isFalse() + assertThat(File(daemonDir, refused).readText()).isEqualTo("$refused-bytes") + } + + @Test + fun `a listed name that is a path rather than a file name is refused`() { + // Resolves to a jar that is really there, so only the guard stops the link being made + // one directory above the daemon dir. + val escaping = "../lib/${jarNames.first()}" + val daemonDir = daemonDirListing(names = listOf(escaping)) + + // The list is extracted from the daemon zip, which extractDaemonZip already refuses to + // let escape; a name used as a path deserves the same guard. + val thrown = + runCatching { QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith()) } + .exceptionOrNull() + + assertThat(thrown).isInstanceOf(IOException::class.java) + assertThat(thrown!!).hasMessageThat().contains("bare file name") + assertThat(thrown).hasMessageThat().contains(escaping) + assertThat(stagedNames(daemonDir)).isEmpty() + } + + @Test + fun `staging twice leaves the distribution's own jars intact`() { + val daemonDir = daemonDirListing() + val dists = gradleDistsWith() + QuickBuildArtifactStager.linkDistJars(daemonDir, dists) + + // Linking onto the previous run's links: the copy fallback would otherwise open them for + // write, truncating the distribution itself and breaking every Gradle build on the device. + QuickBuildArtifactStager.linkDistJars(daemonDir, dists) + + jarNames.forEach { name -> + val inDistribution = File(dists, "$GRADLE_DISTRIBUTION_NAME/lib/$name") + assertThat(inDistribution.readText()).isEqualTo("$name-bytes") + assertThat(File(daemonDir, name).readText()).isEqualTo("$name-bytes") + } + } + @Test fun `missing distribution fails loudly and names the path a human has to fix`() { val daemonDir = daemonDirListing() @@ -116,7 +172,8 @@ class QuickBuildArtifactStagerDistLinkTest { fun `one listed jar missing from the distribution fails the whole staging`() { val daemonDir = daemonDirListing() - // A partial link is worse than none: the daemon starts and dies on the missing class. + // The throw is what protects the caller here: the stamp is left unwritten, so the next + // staging wipes the jars this one already linked. val thrown = runCatching { QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith(jarNames.take(1))) diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt index 38b8a03425..2bb8dc6a08 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt @@ -202,6 +202,25 @@ class QuickBuildArtifactStagerTest { assertThat(jar.exists()).isTrue() } + @Test + fun `a failed link leaves no stamp so the next stage retries`() { + val daemonDir = File(tmp.newFolder("home-link-fail"), "daemon") + val jar = File(daemonDir, "quickbuild-daemon.jar") + val emptyDists = tmp.newFolder("gradle-dists-empty") + + // Stamping a failed link would serve a daemon whose classpath names jars that are not + // there, and the early return would never stage again. + val thrown = + runCatching { + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, emptyDists) { daemonZip() } + }.exceptionOrNull() + assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) + assertThat(File(daemonDir, QuickBuildArtifactStager.DAEMON_STAMP_FILE).exists()).isFalse() + + val ran = QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { daemonZip() } + assertThat(ran).isTrue() + } + @Test fun `a failed extraction leaves no stamp so the next stage retries`() { val daemonDir = File(tmp.newFolder("home"), "daemon") diff --git a/quickbuild/docs/debugging.md b/quickbuild/docs/debugging.md index defc383de8..991e6f1b6f 100644 --- a/quickbuild/docs/debugging.md +++ b/quickbuild/docs/debugging.md @@ -179,7 +179,7 @@ to CoGo. | Path | What | How to read it | | --- | --- | --- | | `files/home/.cg/quickbuild/quickbuild-runtime.aar` | staged runtime AAR, re-staged every provision | `run-as com.itsaky.androidide` | -| `files/home/.cg/quickbuild/daemon/` | daemon jar, its full runtime classpath, `compose-compiler-plugin.jar`; deleted and re-extracted every provision. The jars named in `dist-linked-jars.txt`, the Kotlin compiler among them, are symlinks into the Gradle distribution's `lib/` rather than extracted files - `ls -l` here, since a broken one surfaces as a `NoClassDefFoundError` on the first compile | `run-as com.itsaky.androidide` | +| `files/home/.cg/quickbuild/daemon/` | daemon jar, its full runtime classpath, `compose-compiler-plugin.jar`; deleted and re-extracted when the installed APK changed. The jars named in `dist-linked-jars.txt`, the Kotlin compiler among them, are symlinks into the Gradle distribution's `lib/` rather than extracted files - `ls -l` here, since a broken one surfaces as a `NoClassDefFoundError` on the first compile | `run-as com.itsaky.androidide` | | `files/home/.cg/quickbuild/bench-events.jsonl` | session event log; bench flag only | `run-as com.itsaky.androidide` | | `no_backup/quickbuild-scratch/-<16 hex>/work` | executor payload staging | `run-as com.itsaky.androidide`; **deleted on teardown** | | `no_backup/quickbuild-scratch/-<16 hex>/out` | daemon output: classes, dex, relinked resources | as above | diff --git a/quickbuild/docs/pipeline.md b/quickbuild/docs/pipeline.md index bb0f30b6ac..5101bfe2a3 100644 --- a/quickbuild/docs/pipeline.md +++ b/quickbuild/docs/pipeline.md @@ -356,7 +356,7 @@ flowchart TB Step 7's rebuild fallback re-enters the same runner through `rebuildProxyApp`. -1. [QuickBuildArtifactStager](../../app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt) extracts the runtime AAR and the daemon zip from CoGo's APK assets into `/quickbuild/`. The zip leaves out the eight jars the on-device Gradle distribution already carries, the Kotlin compiler among them; the stager reads their names from `dist-linked-jars.txt` and symlinks the distribution's copies in beside the daemon jar, copying instead if the filesystem refuses a symlink. It re-extracts on **every** provision on purpose: a version-keyed marker serves a stale bundle when content changes without a version bump. +1. [QuickBuildArtifactStager](../../app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt) extracts the runtime AAR and the daemon zip from CoGo's APK assets into `/quickbuild/`. The zip leaves out the eight jars the on-device Gradle distribution already carries, the Kotlin compiler among them; the stager reads their names from `dist-linked-jars.txt` and symlinks the distribution's copies in beside the daemon jar, copying instead if the filesystem refuses a symlink. It re-extracts when the installed APK changed, which a stamp file beside the daemon jar records. 2. [GradleQuickBuildProvisioner](../../app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt) runs the proxy app build through CoGo's existing `BuildService.executeTasks`, then parses `setup.json` into [ProxyAppInfo](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt). 3. [QuickBuildScratch](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt) creates `noBackupFilesDir/quickbuild-scratch//{work,out}` and enforces the free-space floor. It must stay on app-private storage - see the README's "Build Scratch Lives in Faster Private Storage". 4. [QuickBuildClobberCheck](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt) plus [RealIdInstall](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/RealIdInstall.kt) read the installed package's `android:appComponentFactory` to decide whether the tap clobbers a Standard Run install. Stateless: an install or uninstall outside CoGo cannot leave it stale. From 214155ba83f10f2775850ee082ae8f253e474004 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 2 Oct 2026 07:36:28 -0700 Subject: [PATCH 13/22] ADFA-4931: run the distribution jar check after the asset downloads Nothing ordered quickBuildDistJarCheck after assetsDownloadDebug, so a build that brings in a new distribution could check the old archive, or find none and pass. The release download gets the same edge, since the check now reads the release archive too. Review: PR #2090, Daniel 4161297297. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N1o4KqT54Vp2zpakJ54sbU --- app/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2b1f207586..aa6c257682 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -532,6 +532,8 @@ val quickBuildDistLinkedJars = // distribution must carry each name, and the daemon must resolve each name too. val quickBuildDistJarCheck = tasks.register("quickBuildDistJarCheck") { + // Without the ordering, a build that downloads a new distribution can check the old one. + mustRunAfter("assetsDownloadDebug", "assetsDownloadRelease") val pinned = libs.versions.kotlin.daemon.compiler .get() From 939fe5e80290e5f411405c9c5a23d970b88fafea Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 2 Oct 2026 07:36:35 -0700 Subject: [PATCH 14/22] ADFA-4931: take the linked jars' versions from the catalog The coroutines, gson and asm names repeated the versions in libs.versions.toml, so a catalog bump needed a second edit here that only the jar check would ask for. Building the names from the catalog entries the daemon resolves keeps one copy. Review: PR #2090, Daniel 4161297312. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N1o4KqT54Vp2zpakJ54sbU --- app/build.gradle.kts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aa6c257682..ee5f12048d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -522,9 +522,9 @@ val quickBuildDistLinkedJars = "kotlin-script-runtime-$KOTLIN_VERSION.jar", // These three carry their own version rather than KOTLIN_VERSION, pinned in // libs.versions.toml to what the distribution ships so they can be linked from there. - "kotlinx-coroutines-core-jvm-1.10.2.jar", - "gson-2.13.1.jar", - "asm-9.9.jar", + "kotlinx-coroutines-core-jvm-${libs.kotlinx.coroutines.core.jvm.quickBuildDaemon.get().version}.jar", + "gson-${libs.gson.quickBuildDaemon.get().version}.jar", + "asm-${libs.ow2.asm.get().version}.jar", ) // A broken link surfaces on device as a NoClassDefFoundError partway into the user's first From c7712768da9160a82950f1765006f95b47ea1b27 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 2 Oct 2026 07:39:30 -0700 Subject: [PATCH 15/22] ADFA-4931: re-stage when a linked jar no longer resolves A matching stamp used to mean daemon/ was self-contained. Its linked jars now point into the Gradle distribution, which the assets installers delete and re-extract, so the stamp alone could skip staging over a broken link and leave the daemon to fail mid-compile. The skip now also requires every name in dist-linked-jars.txt to pass isFile() in the daemon dir. Review: PR #2090, Daniel 4161297302. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N1o4KqT54Vp2zpakJ54sbU --- .../quickbuild/QuickBuildArtifactStager.kt | 21 +++++++++++-- .../QuickBuildArtifactStagerTest.kt | 30 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt index 86f5c2410e..dfe3fe9c77 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt @@ -71,8 +71,8 @@ object QuickBuildArtifactStager { /** * Wipes and re-extracts [daemonDir] unless it already holds a complete extraction for - * [installStamp] - the stamp file matches and [daemonJar] is present. Internal so the JVM - * test can watch the skip, and the wipe, without an Android [Context]. + * [installStamp]: the stamp matches, [daemonJar] is present and every linked jar resolves. + * Internal so the JVM test can watch the skip, and the wipe, without an Android [Context]. * * @param gradleDists exists so tests can point at a fake distribution. * @return whether an extraction ran. @@ -86,7 +86,7 @@ object QuickBuildArtifactStager { openZip: () -> InputStream, ): Boolean { val stamp = File(daemonDir, DAEMON_STAMP_FILE) - if (daemonJar.isFile && stamp.isFile && stamp.readText() == installStamp) { + if (daemonJar.isFile && stamp.isFile && stamp.readText() == installStamp && linkedJarsResolve(daemonDir)) { log.info("Daemon already staged for this install at {}", daemonDir) return false } @@ -193,6 +193,21 @@ object QuickBuildArtifactStager { source.inputStream().use { input -> target.outputStream().use { input.copyTo(it) } } } + /** + * Whether every linked jar in [daemonDir] still reaches a file. The stamp alone cannot say, + * since the assets installers delete and re-extract the distribution the links point into. + */ + private fun linkedJarsResolve(daemonDir: File): Boolean { + // An unreadable list re-stages too, and the re-stage's own read reports why. + val names = + try { + readLinkedJarNames(daemonDir) + } catch (e: IOException) { + return false + } + return names.all { File(daemonDir, it).isFile } + } + /** The jar names the build left out of the zip for the distribution to supply. */ private fun readLinkedJarNames(daemonDir: File): List { val list = File(daemonDir, LINKED_JARS_LIST) diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt index 2bb8dc6a08..9e4c4796e7 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt @@ -202,6 +202,36 @@ class QuickBuildArtifactStagerTest { assertThat(jar.exists()).isTrue() } + @Test + fun `a matching stamp whose linked jar is gone re-stages`() { + val daemonDir = File(tmp.newFolder("home"), "daemon") + val jar = File(daemonDir, "quickbuild-daemon.jar") + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { daemonZip() } + assertThat(File(daemonDir, linkedJar).delete()).isTrue() + + val ran = QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { daemonZip() } + + assertThat(ran).isTrue() + assertThat(File(daemonDir, linkedJar).readText()).isEqualTo("linked-jar-bytes") + } + + @Test + fun `a matching stamp over a vanished distribution fails at staging, not mid-compile`() { + val daemonDir = File(tmp.newFolder("home"), "daemon") + val jar = File(daemonDir, "quickbuild-daemon.jar") + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { daemonZip() } + // What an assets reinstall leaves for a moment: the link survives, its target does not. + assertThat(File(gradleDists, "$GRADLE_DISTRIBUTION_NAME/lib/$linkedJar").delete()).isTrue() + + val thrown = + runCatching { + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { daemonZip() } + }.exceptionOrNull() + + assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) + assertThat(thrown).hasMessageThat().contains(linkedJar) + } + @Test fun `a failed link leaves no stamp so the next stage retries`() { val daemonDir = File(tmp.newFolder("home-link-fail"), "daemon") From d01addc06e161e6b6bd78e9a143d50cf8b3e20f7 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 2 Oct 2026 07:41:07 -0700 Subject: [PATCH 16/22] ADFA-4931: link kotlin-reflect from the distribution too The daemon resolved kotlin-reflect 1.6.10, which kotlin-compiler-embeddable's POM pins, so the zip carried its own older copy while the distribution ships 2.3.21 in lib/. A constraint now takes it to KOTLIN_VERSION and the jar joins the linked list. The full :quickbuild:daemon suite passes on 2.3.21 (234 of 234, 0 skipped). Review: PR #2090, Daniel 4161297307. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N1o4KqT54Vp2zpakJ54sbU --- ARCHITECTURE.md | 2 +- app/build.gradle.kts | 1 + docs/adr/0016-quick-build-compiles-outside-gradle.md | 2 +- quickbuild/daemon/README.md | 4 ++-- quickbuild/daemon/build.gradle.kts | 7 +++++++ quickbuild/docs/pipeline.md | 4 ++-- 6 files changed, 14 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fc9eb607b0..e98cfdea4f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -97,7 +97,7 @@ These structural facts shape every module. Day-to-day build *commands* live in ` - **SDK levels** (`build-logic/.../build/config/BuildConfig.kt`): `COMPILE_SDK=36`, `MIN_SDK=28`, `TARGET_SDK=28`. **`TARGET_SDK` is deliberately pinned at 28:** higher targets enforce W^X (write-xor-execute), which blocks executing code from app-writable files. That is fatal for an on-device IDE that compiles and runs code (Gradle, `javac`, Termux binaries), so it is a hard requirement, not tech debt. `MIN_SDK_FOR_APPS_BUILT_WITH_COGO=16` is the floor for the apps a *user* builds with CoGo — distinct from CoGo's own `MIN_SDK`. - **Native asset bundling.** The on-device LLM (`llama-impl`) ships as a per-flavor native AAR, wired through the root `build.gradle.kts` (`bundleLlamaV8Assets` / `assembleV8Assets`, …); prebuilt per-flavor assets live under `assets/release/v7/` and `assets/release/v8/`. - **Native lib compression** (ADFA-2306, ADFA-4729). The app manifest hard-codes `android:extractNativeLibs="true"` (required: the installer must materialize libs in `nativeLibraryDir`, e.g. `libshizuku.so` is an executable the adb shell runs from there). That attribute overrides the `jniLibs.useLegacyPackaging` DSL, so AGP packages `lib//*.so` deflate-compressed in **every** APK — ~5.9 MB smaller (`libtree-sitter-kotlin.so` alone is 4.18 MB → 339 kB). The trap is the `recompressApk` post-step (release always, debug in CI only): its no-compress lists in `app/build.gradle.kts` must NOT contain `"so"`, or it silently re-stores the libs and undoes the saving — which is what ADFA-2306 fixed for release and ADFA-4729 for CI debug. Locally built debug APKs (including the e2e farm's) never run that step and were always fine. -- **Quick Build shares the Gradle distribution's toolchain** (ADFA-4931). `:app`'s `quickBuildDaemonZip` leaves the Kotlin compiler and seven other jars out of the daemon zip, because the on-device Gradle distribution already carries them; `QuickBuildArtifactStager` links them in at provision time. That makes the daemon's Kotlin version the distribution's: `libs.versions.toml`'s `kotlin-daemon-compiler` must equal `org.adfa.constants.KOTLIN_VERSION`, and `:app:quickBuildDistJarCheck` fails the build when it does not. See [ADR 0016](docs/adr/0016-quick-build-compiles-outside-gradle.md). +- **Quick Build shares the Gradle distribution's toolchain** (ADFA-4931). `:app`'s `quickBuildDaemonZip` leaves the Kotlin compiler and eight other jars out of the daemon zip, because the on-device Gradle distribution already carries them; `QuickBuildArtifactStager` links them in at provision time. That makes the daemon's Kotlin version the distribution's: `libs.versions.toml`'s `kotlin-daemon-compiler` must equal `org.adfa.constants.KOTLIN_VERSION`, and `:app:quickBuildDistJarCheck` fails the build when it does not. See [ADR 0016](docs/adr/0016-quick-build-compiles-outside-gradle.md). - **`app` package layout is by concern, not feature:** `activities`, `fragments`, `services`, `di`, `agent`, `viewmodel(s)`, `repositories`, `roomData`, `localWebServer`, `preferences`, `ui` (Compose screens live under `ui/compose`), `templates/manager` (the Manager screen's `.cgt`-parsing data layer, with direct filesystem access to `Environment.TEMPLATES_DIR` — distinct from the plugin-facing `IdeTemplateService` in `plugin-api`/`plugin-manager`), `utils`, …. ## Sibling Repositories & Asset Provenance diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ee5f12048d..884dc74d8d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -520,6 +520,7 @@ val quickBuildDistLinkedJars = "kotlin-build-tools-impl-$KOTLIN_VERSION.jar", "kotlin-daemon-embeddable-$KOTLIN_VERSION.jar", "kotlin-script-runtime-$KOTLIN_VERSION.jar", + "kotlin-reflect-$KOTLIN_VERSION.jar", // These three carry their own version rather than KOTLIN_VERSION, pinned in // libs.versions.toml to what the distribution ships so they can be linked from there. "kotlinx-coroutines-core-jvm-${libs.kotlinx.coroutines.core.jvm.quickBuildDaemon.get().version}.jar", diff --git a/docs/adr/0016-quick-build-compiles-outside-gradle.md b/docs/adr/0016-quick-build-compiles-outside-gradle.md index 04a75f160d..d7ae3b2029 100644 --- a/docs/adr/0016-quick-build-compiles-outside-gradle.md +++ b/docs/adr/0016-quick-build-compiles-outside-gradle.md @@ -25,7 +25,7 @@ The first three cannot be sped up or skipped. **Gradle handles what live reload cannot.** It still provisions the proxy app through the existing Tooling API path, and still builds every edit the classifier declines. Nothing a user installs or ships comes out of the daemon. -**One compiler, not two (ADFA-4931).** The daemon no longer ships a Kotlin compiler. `:app`'s `quickBuildDaemonZip` leaves `kotlin-compiler-embeddable` and seven other jars out of the daemon zip, and `QuickBuildArtifactStager` links the on-device Gradle distribution's copies in at provision time - 57.6 MB off the debug v8 APK. The price is a version tie: the daemon's Kotlin must equal the distribution's, and `:app:quickBuildDistJarCheck` fails the build when it does not. +**One compiler, not two (ADFA-4931).** The daemon no longer ships a Kotlin compiler. `:app`'s `quickBuildDaemonZip` leaves `kotlin-compiler-embeddable` and eight other jars out of the daemon zip, and `QuickBuildArtifactStager` links the on-device Gradle distribution's copies in at provision time - 57.6 MB off the debug v8 APK. The price is a version tie: the daemon's Kotlin must equal the distribution's, and `:app:quickBuildDistJarCheck` fails the build when it does not. ## Consequences diff --git a/quickbuild/daemon/README.md b/quickbuild/daemon/README.md index 49ed7848a3..2a42b7f7c6 100644 --- a/quickbuild/daemon/README.md +++ b/quickbuild/daemon/README.md @@ -125,8 +125,8 @@ mid-construction cannot leave a fingerprint describing snapshots that were never ## Traps - **Never print to stdout.** Use the injected `log` / `warn` channels, which reach stderr. -- **Eight of the jars beside the daemon jar on device are not in the APK.** `:app`'s - `quickBuildDaemonZip` leaves out `kotlin-compiler-embeddable` and seven others, and +- **Nine of the jars beside the daemon jar on device are not in the APK.** `:app`'s + `quickBuildDaemonZip` leaves out `kotlin-compiler-embeddable` and eight others, and `QuickBuildArtifactStager` links the on-device Gradle distribution's copies in under the same names, which the manifest `Class-Path` matches by exact file name. So the daemon's Kotlin version must equal the distribution's; `:app:quickBuildDistJarCheck` fails the build when it does not. diff --git a/quickbuild/daemon/build.gradle.kts b/quickbuild/daemon/build.gradle.kts index 0a1c41de58..43b89b11c8 100644 --- a/quickbuild/daemon/build.gradle.kts +++ b/quickbuild/daemon/build.gradle.kts @@ -1,3 +1,5 @@ +import org.adfa.constants.KOTLIN_VERSION + plugins { id("java-library") id("org.jetbrains.kotlin.jvm") @@ -115,6 +117,11 @@ dependencies { runtimeOnly(libs.kotlinx.coroutines.core.jvm.quickBuildDaemon) { because("the on-device Gradle distribution ships this version, which ADFA-4931 links") } + // kotlin-compiler-embeddable's POM asks for 1.6.10; the distribution's compiler runs on its + // own 2.3.21 copy, which ADFA-4931 links instead of shipping an older one. + runtimeOnly("org.jetbrains.kotlin:kotlin-reflect:$KOTLIN_VERSION") { + because("the on-device Gradle distribution ships this version, which ADFA-4931 links") + } } // Pinned to the on-device Gradle distribution's Kotlin, whose compiler ADFA-4931 links // instead of shipping one. kotlin-compiler-runner only drives a separate compile-daemon diff --git a/quickbuild/docs/pipeline.md b/quickbuild/docs/pipeline.md index 5101bfe2a3..df7c35473e 100644 --- a/quickbuild/docs/pipeline.md +++ b/quickbuild/docs/pipeline.md @@ -356,7 +356,7 @@ flowchart TB Step 7's rebuild fallback re-enters the same runner through `rebuildProxyApp`. -1. [QuickBuildArtifactStager](../../app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt) extracts the runtime AAR and the daemon zip from CoGo's APK assets into `/quickbuild/`. The zip leaves out the eight jars the on-device Gradle distribution already carries, the Kotlin compiler among them; the stager reads their names from `dist-linked-jars.txt` and symlinks the distribution's copies in beside the daemon jar, copying instead if the filesystem refuses a symlink. It re-extracts when the installed APK changed, which a stamp file beside the daemon jar records. +1. [QuickBuildArtifactStager](../../app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt) extracts the runtime AAR and the daemon zip from CoGo's APK assets into `/quickbuild/`. The zip leaves out the nine jars the on-device Gradle distribution already carries, the Kotlin compiler among them; the stager reads their names from `dist-linked-jars.txt` and symlinks the distribution's copies in beside the daemon jar, copying instead if the filesystem refuses a symlink. It re-extracts when the installed APK changed, which a stamp file beside the daemon jar records. 2. [GradleQuickBuildProvisioner](../../app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt) runs the proxy app build through CoGo's existing `BuildService.executeTasks`, then parses `setup.json` into [ProxyAppInfo](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt). 3. [QuickBuildScratch](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt) creates `noBackupFilesDir/quickbuild-scratch//{work,out}` and enforces the free-space floor. It must stay on app-private storage - see the README's "Build Scratch Lives in Faster Private Storage". 4. [QuickBuildClobberCheck](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt) plus [RealIdInstall](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/RealIdInstall.kt) read the installed package's `android:appComponentFactory` to decide whether the tap clobbers a Standard Run install. Stateless: an install or uninstall outside CoGo cannot leave it stale. @@ -637,7 +637,7 @@ Resources are relinked by [Aapt2Link](../daemon/src/main/kotlin/org/appdevforall ### Shrinking the daemon: what works, and what breaks it -ADFA-4931 moved `kotlin-compiler-embeddable` and seven other jars out of the daemon zip; the daemon links the on-device Gradle distribution's copies instead. The findings below are from the ADFA-4128 shrink spike, kept here because the build task that implemented them has been removed as an unexecuted follow-up. They now target a jar inside that distribution, which CoGo downloads rather than builds - so reclaiming the space means repackaging the distribution, and it still needs a green corpus run. +ADFA-4931 moved `kotlin-compiler-embeddable` and eight other jars out of the daemon zip; the daemon links the on-device Gradle distribution's copies instead. The findings below are from the ADFA-4128 shrink spike, kept here because the build task that implemented them has been removed as an unexecuted follow-up. They now target a jar inside that distribution, which CoGo downloads rather than builds - so reclaiming the space means repackaging the distribution, and it still needs a green corpus run. - **R8 is ruled out.** `R8 --classfile` shrank the compiler to 37 MB and produced a non-functional daemon: every Kotlin compile died with a `NoClassDefFoundError` initializing a core CLI diagnostics class, because tree-shaking cut a static-init dependency reached only reflectively. A compiler is exactly the kind of code R8 cannot reason about. - **Dropping whole never-loaded backend subtrees does work**, and is a different operation - plain jar surgery, not tree-shaking. Every class that survives is byte-identical, so nothing that remains can break on a reflective lookup. It removed **~5.7 MB from the compressed daemon zip**. From 4fb244f91a0b8d54919dc54af278a74afd625696 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 2 Oct 2026 07:42:54 -0700 Subject: [PATCH 17/22] ADFA-4931: take the daemon's Kotlin at KOTLIN_VERSION and drop the catalog copy kotlin-daemon-compiler in libs.versions.toml was a second copy of KOTLIN_VERSION, and the pin half of quickBuildDistJarCheck existed only to keep the two equal. :quickbuild:daemon now asks for kotlin-build-tools-api, -impl and the Compose plugin at KOTLIN_VERSION, so the tie holds by construction. The catalog entries keep their coordinates without a version, and the pin check and its docs go. Review: PR #2090, Daniel 4161297318. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N1o4KqT54Vp2zpakJ54sbU --- ARCHITECTURE.md | 2 +- app/build.gradle.kts | 11 ----------- .../0016-quick-build-compiles-outside-gradle.md | 2 +- gradle/libs.versions.toml | 15 ++++++--------- quickbuild/daemon/README.md | 3 ++- quickbuild/daemon/build.gradle.kts | 15 ++++++++------- 6 files changed, 18 insertions(+), 30 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e98cfdea4f..192845752a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -97,7 +97,7 @@ These structural facts shape every module. Day-to-day build *commands* live in ` - **SDK levels** (`build-logic/.../build/config/BuildConfig.kt`): `COMPILE_SDK=36`, `MIN_SDK=28`, `TARGET_SDK=28`. **`TARGET_SDK` is deliberately pinned at 28:** higher targets enforce W^X (write-xor-execute), which blocks executing code from app-writable files. That is fatal for an on-device IDE that compiles and runs code (Gradle, `javac`, Termux binaries), so it is a hard requirement, not tech debt. `MIN_SDK_FOR_APPS_BUILT_WITH_COGO=16` is the floor for the apps a *user* builds with CoGo — distinct from CoGo's own `MIN_SDK`. - **Native asset bundling.** The on-device LLM (`llama-impl`) ships as a per-flavor native AAR, wired through the root `build.gradle.kts` (`bundleLlamaV8Assets` / `assembleV8Assets`, …); prebuilt per-flavor assets live under `assets/release/v7/` and `assets/release/v8/`. - **Native lib compression** (ADFA-2306, ADFA-4729). The app manifest hard-codes `android:extractNativeLibs="true"` (required: the installer must materialize libs in `nativeLibraryDir`, e.g. `libshizuku.so` is an executable the adb shell runs from there). That attribute overrides the `jniLibs.useLegacyPackaging` DSL, so AGP packages `lib//*.so` deflate-compressed in **every** APK — ~5.9 MB smaller (`libtree-sitter-kotlin.so` alone is 4.18 MB → 339 kB). The trap is the `recompressApk` post-step (release always, debug in CI only): its no-compress lists in `app/build.gradle.kts` must NOT contain `"so"`, or it silently re-stores the libs and undoes the saving — which is what ADFA-2306 fixed for release and ADFA-4729 for CI debug. Locally built debug APKs (including the e2e farm's) never run that step and were always fine. -- **Quick Build shares the Gradle distribution's toolchain** (ADFA-4931). `:app`'s `quickBuildDaemonZip` leaves the Kotlin compiler and eight other jars out of the daemon zip, because the on-device Gradle distribution already carries them; `QuickBuildArtifactStager` links them in at provision time. That makes the daemon's Kotlin version the distribution's: `libs.versions.toml`'s `kotlin-daemon-compiler` must equal `org.adfa.constants.KOTLIN_VERSION`, and `:app:quickBuildDistJarCheck` fails the build when it does not. See [ADR 0016](docs/adr/0016-quick-build-compiles-outside-gradle.md). +- **Quick Build shares the Gradle distribution's toolchain** (ADFA-4931). `:app`'s `quickBuildDaemonZip` leaves the Kotlin compiler and eight other jars out of the daemon zip, because the on-device Gradle distribution already carries them; `QuickBuildArtifactStager` links them in at provision time. That makes the daemon's Kotlin version the distribution's, so `:quickbuild:daemon` takes its Kotlin artifacts at `org.adfa.constants.KOTLIN_VERSION`, and `:app:quickBuildDistJarCheck` fails the build when the distribution it packages lacks a linked jar. See [ADR 0016](docs/adr/0016-quick-build-compiles-outside-gradle.md). - **`app` package layout is by concern, not feature:** `activities`, `fragments`, `services`, `di`, `agent`, `viewmodel(s)`, `repositories`, `roomData`, `localWebServer`, `preferences`, `ui` (Compose screens live under `ui/compose`), `templates/manager` (the Manager screen's `.cgt`-parsing data layer, with direct filesystem access to `Environment.TEMPLATES_DIR` — distinct from the plugin-facing `IdeTemplateService` in `plugin-api`/`plugin-manager`), `utils`, …. ## Sibling Repositories & Asset Provenance diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 884dc74d8d..72ba976fe5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -535,9 +535,6 @@ val quickBuildDistJarCheck = tasks.register("quickBuildDistJarCheck") { // Without the ordering, a build that downloads a new distribution can check the old one. mustRunAfter("assetsDownloadDebug", "assetsDownloadRelease") - val pinned = - libs.versions.kotlin.daemon.compiler - .get() val expected = quickBuildDistLinkedJars val distName = GRADLE_DISTRIBUTION_NAME val archiveName = GRADLE_DISTRIBUTION_ARCHIVE_NAME @@ -557,14 +554,6 @@ val quickBuildDistJarCheck = .withPropertyName("gradleDistributionArchive") .optional(true) doLast { - if (pinned != KOTLIN_VERSION) { - throw GradleException( - "Quick Build's compiler pin (libs.versions.kotlin-daemon-compiler = $pinned) must equal " + - "the Kotlin the on-device Gradle distribution embeds " + - "(org.adfa.constants.KOTLIN_VERSION = $KOTLIN_VERSION). The daemon loads " + - "$distName/lib/kotlin-compiler-embeddable-$KOTLIN_VERSION.jar at runtime.", - ) - } val resolved = classpath.files.associateBy { it.name } expected.sorted().forEach { name -> if (name !in resolved) { diff --git a/docs/adr/0016-quick-build-compiles-outside-gradle.md b/docs/adr/0016-quick-build-compiles-outside-gradle.md index d7ae3b2029..27f58febfc 100644 --- a/docs/adr/0016-quick-build-compiles-outside-gradle.md +++ b/docs/adr/0016-quick-build-compiles-outside-gradle.md @@ -25,7 +25,7 @@ The first three cannot be sped up or skipped. **Gradle handles what live reload cannot.** It still provisions the proxy app through the existing Tooling API path, and still builds every edit the classifier declines. Nothing a user installs or ships comes out of the daemon. -**One compiler, not two (ADFA-4931).** The daemon no longer ships a Kotlin compiler. `:app`'s `quickBuildDaemonZip` leaves `kotlin-compiler-embeddable` and eight other jars out of the daemon zip, and `QuickBuildArtifactStager` links the on-device Gradle distribution's copies in at provision time - 57.6 MB off the debug v8 APK. The price is a version tie: the daemon's Kotlin must equal the distribution's, and `:app:quickBuildDistJarCheck` fails the build when it does not. +**One compiler, not two (ADFA-4931).** The daemon no longer ships a Kotlin compiler. `:app`'s `quickBuildDaemonZip` leaves `kotlin-compiler-embeddable` and eight other jars out of the daemon zip, and `QuickBuildArtifactStager` links the on-device Gradle distribution's copies in at provision time - 57.6 MB off the debug v8 APK. The price is a version tie: the daemon's Kotlin must equal the distribution's, so `:quickbuild:daemon` takes its Kotlin artifacts at `KOTLIN_VERSION`, and `:app:quickBuildDistJarCheck` fails the build when the distribution lacks a linked jar. ## Consequences diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fde17d1d56..7aec580c07 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,10 +20,6 @@ junit-jupiter = "5.10.2" anroidx-test-core = "2.2.0" koinAndroid = "4.1.1" kotlin = "2.3.0" -# The Quick Build daemon's compiler, which it loads from the on-device Gradle distribution -# rather than shipping its own. Must equal org.adfa.constants.KOTLIN_VERSION (the Kotlin that -# distribution embeds); :app's quickBuildDistJarCheck fails the build if they differ. -kotlin-daemon-compiler = "2.3.21" kotlin-coroutines = "1.9.0" kotlinxCoroutinesCore = "1.10.2" lifecycleViewmodelKtx = "2.7.0" @@ -281,11 +277,12 @@ xml-jb-annotations = { module = "org.jetbrains:annotations", version = "24.1.0" # GIT git-jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version = "6.8.0.202311291450-r" } -# Quick Build daemon (ADFA-4128): Kotlin Build Tools API incremental engine -kotlin-buildToolsApi = { module = "org.jetbrains.kotlin:kotlin-build-tools-api", version.ref = "kotlin-daemon-compiler" } -kotlin-buildToolsImpl = { module = "org.jetbrains.kotlin:kotlin-build-tools-impl", version.ref = "kotlin-daemon-compiler" } -# Compose compiler plugin, version-matched to the daemon's compiler -kotlin-composeCompilerPluginEmbeddable = { module = "org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable", version.ref = "kotlin-daemon-compiler" } +# Quick Build daemon (ADFA-4128): Kotlin Build Tools API incremental engine, and the Compose +# compiler plugin matched to it. No version here: :quickbuild:daemon takes these at +# org.adfa.constants.KOTLIN_VERSION, the Kotlin the on-device Gradle distribution embeds. +kotlin-buildToolsApi = { module = "org.jetbrains.kotlin:kotlin-build-tools-api" } +kotlin-buildToolsImpl = { module = "org.jetbrains.kotlin:kotlin-build-tools-impl" } +kotlin-composeCompilerPluginEmbeddable = { module = "org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable" } # Compose runtime for the daemon's compose compile tests (see :quickbuild:daemon) composeRuntimeDaemonTests = { module = "androidx.compose.runtime:runtime-android", version = "1.7.3" } # Only :quickbuild:daemon uses this, and it takes the jar from the on-device Gradle diff --git a/quickbuild/daemon/README.md b/quickbuild/daemon/README.md index 2a42b7f7c6..d86eb4ef99 100644 --- a/quickbuild/daemon/README.md +++ b/quickbuild/daemon/README.md @@ -129,7 +129,8 @@ mid-construction cannot leave a fingerprint describing snapshots that were never `quickBuildDaemonZip` leaves out `kotlin-compiler-embeddable` and eight others, and `QuickBuildArtifactStager` links the on-device Gradle distribution's copies in under the same names, which the manifest `Class-Path` matches by exact file name. So the daemon's Kotlin version - must equal the distribution's; `:app:quickBuildDistJarCheck` fails the build when it does not. + must equal the distribution's, which is why `build.gradle.kts` takes the Kotlin artifacts at + `KOTLIN_VERSION` rather than a catalog version. - **`kotlin-daemon-embeddable` and `kotlin-reflect` look like dead weight and are not.** Excluding either throws `NoClassDefFoundError` from inside the in-process path. `build.gradle.kts` records which exclusion is safe and why. (`kotlin-daemon-client`, which this bullet used to name, diff --git a/quickbuild/daemon/build.gradle.kts b/quickbuild/daemon/build.gradle.kts index 43b89b11c8..61fa0fbf2f 100644 --- a/quickbuild/daemon/build.gradle.kts +++ b/quickbuild/daemon/build.gradle.kts @@ -104,7 +104,9 @@ dependencies { // against one protocol definition. api: the router/handler signatures expose them. api(projects.quickbuild.protocol) - implementation(libs.kotlin.buildToolsApi) + // The Kotlin artifacts below take KOTLIN_VERSION, not a catalog version: ADFA-4931 links the + // on-device Gradle distribution's compiler, so the two must match by construction. + implementation("${libs.kotlin.buildToolsApi.get().module}:$KOTLIN_VERSION") // Pinned to the on-device Gradle distribution's version, not the app's, because ADFA-4931 // links gson from the distribution instead of shipping it in the APK. implementation(libs.gson.quickBuildDaemon) @@ -123,17 +125,16 @@ dependencies { because("the on-device Gradle distribution ships this version, which ADFA-4931 links") } } - // Pinned to the on-device Gradle distribution's Kotlin, whose compiler ADFA-4931 links - // instead of shipping one. kotlin-compiler-runner only drives a separate compile-daemon - // JVM over RMI, which this daemon never does; do not also exclude kotlin-daemon-embeddable - // or kotlin-reflect, which look just as unused but break every real compile. - runtimeOnly(libs.kotlin.buildToolsImpl) { + // kotlin-compiler-runner only drives a separate compile-daemon JVM over RMI, which this + // daemon never does; do not also exclude kotlin-daemon-embeddable or kotlin-reflect, which + // look just as unused but break every real compile. + runtimeOnly("${libs.kotlin.buildToolsImpl.get().module}:$KOTLIN_VERSION") { exclude(group = "org.jetbrains.kotlin", module = "kotlin-compiler-runner") } // Staged next to the daemon jar on device and passed as -Xplugin when the user // project uses Compose. - composeCompilerPlugin(libs.kotlin.composeCompilerPluginEmbeddable) + composeCompilerPlugin("${libs.kotlin.composeCompilerPluginEmbeddable.get().module}:$KOTLIN_VERSION") // The compose compile tests resolve a classpath from this; classes.jar is extracted // from the AAR at build time and never shipped. Names the -android artifact rather // than the KMP umbrella, which redirects via available-at - a redirect a From d7c84253f7df3ddd4d262d6bea1ea1e5c14dae36 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 2 Oct 2026 07:48:47 -0700 Subject: [PATCH 18/22] ADFA-4931: check the distribution release builds package, not only the debug zip quickBuildDistJarCheck read only assets/gradle-9.6.1-bin.zip, which release and CI builds never have, so they logged UNCHECKED and passed (Copilot r4159241283, Daniel r4161297674). It now also stream-decodes the release .br, fails a release build whose .br is absent, and logs NOT PACKAGED for a debug build with no archive, whose device gets the distribution from the separate assets zip. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N1o4KqT54Vp2zpakJ54sbU --- app/build.gradle.kts | 91 +++++++++++++++++++++++++++++++------------- 1 file changed, 65 insertions(+), 26 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 72ba976fe5..1a54580608 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,7 @@ @file:Suppress("UnstableApiUsage") +import com.aayushatharva.brotli4j.Brotli4jLoader +import com.aayushatharva.brotli4j.decoder.BrotliInputStream import com.itsaky.androidide.build.config.BuildConfig import com.itsaky.androidide.desugaring.utils.JavaIOReplacements.applyJavaIOReplacements import com.itsaky.androidide.plugins.AndroidIDEAssetsPlugin @@ -29,6 +31,7 @@ import java.util.zip.CRC32 import java.util.zip.Deflater import java.util.zip.ZipEntry import java.util.zip.ZipFile +import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream plugins { @@ -529,8 +532,8 @@ val quickBuildDistLinkedJars = ) // A broken link surfaces on device as a NoClassDefFoundError partway into the user's first -// compile, far from the version bump that caused it, so check both ends at build time: the -// distribution must carry each name, and the daemon must resolve each name too. +// compile, far from the change that caused it, so check both ends at build time: the daemon must +// resolve each name, and the distribution this build packages must carry each one. val quickBuildDistJarCheck = tasks.register("quickBuildDistJarCheck") { // Without the ordering, a build that downloads a new distribution can check the old one. @@ -538,7 +541,12 @@ val quickBuildDistJarCheck = val expected = quickBuildDistLinkedJars val distName = GRADLE_DISTRIBUTION_NAME val archiveName = GRADLE_DISTRIBUTION_ARCHIVE_NAME - val distArchive = rootProject.file("assets/$archiveName") + val debugArchive = rootProject.file("assets/$archiveName") + val releaseArchive = rootProject.file("assets/release/common/data/common/$archiveName.br") + // Only a release APK bundles the distribution. A debug APK gets it from the separate assets + // zip, so a debug build with neither archive on disk has nothing of its own to check. + val releaseTask = Regex("""(assemble|bundle|package|install)\w*Release""") + val releaseBuild = gradle.startParameter.taskNames.any { releaseTask.containsMatchIn(it) } val classpath = files( rootProject @@ -547,11 +555,14 @@ val quickBuildDistJarCheck = .named("runtimeClasspath"), ) inputs.files(classpath) - // Optional because release builds fetch the brotli-encoded distribution and never - // materialise this one. doLast reports that as unchecked rather than as a pass. + inputs.property("releaseBuild", releaseBuild) inputs - .files(distArchive) - .withPropertyName("gradleDistributionArchive") + .files(debugArchive) + .withPropertyName("debugDistribution") + .optional(true) + inputs + .files(releaseArchive) + .withPropertyName("releaseDistribution") .optional(true) doLast { val resolved = classpath.files.associateBy { it.name } @@ -567,31 +578,59 @@ val quickBuildDistJarCheck = } } - if (!distArchive.isFile) { + fun requireLinkedJars( + entries: Set, + archive: File, + ) { + expected.forEach { name -> + val entryName = "$distName/lib/$name" + if (entryName !in entries) { + throw GradleException( + "$entryName is missing from ${archive.name}. quickBuildDaemonZip " + + "excludes $name on the promise that the distribution supplies it, so " + + "the daemon would start with no copy at all. Either drop it from " + + "quickBuildDistLinkedJars and let the zip carry it, or correct the name.", + ) + } + } logger.lifecycle( - "quickBuildDistJarCheck: UNCHECKED - 0 of ${expected.size} linked jars looked up. " + - "${distArchive.absolutePath} is absent, so the distribution could not be " + - "read. Release builds fetch $archiveName.br instead and take this path; run " + - "./gradlew :app:assetsDownloadDebug to check them here.", + "quickBuildDistJarCheck: found ${expected.size} of ${expected.size} linked jars " + + "in $distName/lib of ${archive.name}", ) - return@doLast } - ZipFile(distArchive).use { zip -> - expected.forEach { name -> - val entryName = "$distName/lib/$name" - zip.getEntry(entryName) ?: throw GradleException( - "$entryName is missing from ${distArchive.name}. quickBuildDaemonZip " + - "excludes $name on the promise that the distribution supplies it, so " + - "the daemon would start with no copy at all. Either drop it from " + - "quickBuildDistLinkedJars and let the zip carry it, or correct the name.", - ) + if (debugArchive.isFile) { + val entries = + ZipFile(debugArchive).use { zip -> + zip + .entries() + .asSequence() + .map { it.name } + .toSet() + } + requireLinkedJars(entries, debugArchive) + } + if (releaseArchive.isFile) { + Brotli4jLoader.ensureAvailability() + val entries = mutableSetOf() + ZipInputStream(BrotliInputStream(releaseArchive.inputStream().buffered())).use { zip -> + generateSequence { zip.nextEntry }.forEach { entries += it.name } } + requireLinkedJars(entries, releaseArchive) + } else if (releaseBuild) { + throw GradleException( + "${releaseArchive.absolutePath} is absent, so quickBuildDistJarCheck cannot read " + + "the Gradle distribution this release build packages. Run " + + ":app:assetsDownloadRelease first.", + ) + } + if (!debugArchive.isFile && !releaseArchive.isFile) { + logger.lifecycle( + "quickBuildDistJarCheck: NOT PACKAGED - 0 of ${expected.size} linked jars looked " + + "up. This debug build bundles no Gradle distribution; the device gets it " + + "from the assets zip, which ./gradlew :app:assetsDownloadDebug fetches to check.", + ) } - logger.lifecycle( - "quickBuildDistJarCheck: found ${expected.size} of ${expected.size} linked jars " + - "in $distName/lib of ${distArchive.name}", - ) } } From 9cc2621c36fd6a8f5af1d3aa9f3fcb93a164aa99 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 2 Oct 2026 07:55:07 -0700 Subject: [PATCH 19/22] ADFA-4931: correct the stager's class doc, which still said 62 MB The daemon zip extracts to 1.9 MB now that the distribution supplies the Kotlin jars, and the doc ran to two long paragraphs (Daniel r4161297314). It is two sentences now, and installStamp says for itself why it uses lastUpdateTime. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N1o4KqT54Vp2zpakJ54sbU --- .../quickbuild/QuickBuildArtifactStager.kt | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt index dfe3fe9c77..35b84b8e0a 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt @@ -14,19 +14,11 @@ import java.nio.file.Path import java.util.zip.ZipInputStream /** - * Extracts the quick-build artifacts from APK assets to `/quickbuild/` - the - * runtime AAR, and the daemon zip unpacked into `daemon/` (the daemon jar plus the runtime - * classpath its manifest Class-Path names). + * Stages the quick-build runtime AAR and daemon under `/quickbuild/`, linking + * the jars [LINKED_JARS_LIST] names in from the on-device Gradle distribution. * - * The AAR is copied on every call. The daemon directory is wiped and re-extracted only when - * the installed APK changed: a stamp file, written last so a crash mid-extract leaves none, - * records the package's versionCode and lastUpdateTime - not a version constant, which would - * serve a stale bundle when content changes without a bump; any install, an unchanged-version - * reinstall included, moves lastUpdateTime. That saves a 62 MB extraction per provision and - * rebaseline. It also keeps the one wipe path away from a live compile daemon, which loads - * the jars under `daemon/` lazily: the wipe runs only after an APK update, which force-stops - * the app and its child processes. No known path stages while a daemon is alive anyway - a - * rebaseline shuts it down before the Gradle build runs - so this is a guard, not a fix. + * The daemon is re-staged only when the APK was reinstalled or a linked jar stopped resolving, + * which skips a 1.9 MB extraction per provision and keeps the wipe away from a live daemon. */ object QuickBuildArtifactStager { private val log = LoggerFactory.getLogger("QB-ArtifactStager") @@ -52,7 +44,9 @@ object QuickBuildArtifactStager { } } - /** Identity of the installed APK; see the class doc for why lastUpdateTime and not a constant. */ + /** + * Identity of the installed APK. lastUpdateTime, unlike a version constant, moves on every reinstall. + */ private fun installStamp(context: Context): String { val info = context.packageManager.getPackageInfo(context.packageName, 0) return "${PackageInfoCompat.getLongVersionCode(info)}:${info.lastUpdateTime}" From fd5091c0cec18a768852deff98dcacad1a1e49f4 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 2 Oct 2026 08:09:17 -0700 Subject: [PATCH 20/22] ADFA-4931: cover the re-stage when the linked-jar list is unreadable linkedJarsResolve's catch was the one path of the new skip check no test reached (JaCoCo 4/6 lines). The test deletes dist-linked-jars.txt under a matching stamp and expects a re-stage; with the catch returning true it fails with "expected to be true". Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N1o4KqT54Vp2zpakJ54sbU --- .../quickbuild/QuickBuildArtifactStagerTest.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt index 9e4c4796e7..78518f4b5d 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt @@ -215,6 +215,19 @@ class QuickBuildArtifactStagerTest { assertThat(File(daemonDir, linkedJar).readText()).isEqualTo("linked-jar-bytes") } + @Test + fun `a matching stamp whose linked-jar list is gone re-stages`() { + val daemonDir = File(tmp.newFolder("home"), "daemon") + val jar = File(daemonDir, "quickbuild-daemon.jar") + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { daemonZip() } + assertThat(File(daemonDir, QuickBuildArtifactStager.LINKED_JARS_LIST).delete()).isTrue() + + val ran = QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { daemonZip() } + + assertThat(ran).isTrue() + assertThat(File(daemonDir, QuickBuildArtifactStager.LINKED_JARS_LIST).isFile).isTrue() + } + @Test fun `a matching stamp over a vanished distribution fails at staging, not mid-compile`() { val daemonDir = File(tmp.newFolder("home"), "daemon") From 465f2484ccb50a8df4a20d524d781e761812a0a0 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 2 Oct 2026 08:55:40 -0700 Subject: [PATCH 21/22] ADFA-4931: gate release variants on the distribution check, not task names quickBuildDistJarCheck decided "this is a release build" from the task names on the command line, so a release variant reached through any other name (copyV8ReleaseQuickBuildDaemonZip, mergeV8ReleaseAssets, an abbreviation such as aV8R, or a bare assemble) packaged the daemon zip with the distribution unchecked and the build green. A release-only gate task now fails when the .br is absent, and every release variant's copy of the daemon zip depends on it, so the release path is wired rather than inferred. requireLinkedJars gains a one-line KDoc. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N1o4KqT54Vp2zpakJ54sbU --- app/build.gradle.kts | 43 +++++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1a54580608..007bf402f7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -534,6 +534,9 @@ val quickBuildDistLinkedJars = // A broken link surfaces on device as a NoClassDefFoundError partway into the user's first // compile, far from the change that caused it, so check both ends at build time: the daemon must // resolve each name, and the distribution this build packages must carry each one. +val quickBuildReleaseDistribution = + rootProject.file("assets/release/common/data/common/$GRADLE_DISTRIBUTION_ARCHIVE_NAME.br") + val quickBuildDistJarCheck = tasks.register("quickBuildDistJarCheck") { // Without the ordering, a build that downloads a new distribution can check the old one. @@ -542,11 +545,7 @@ val quickBuildDistJarCheck = val distName = GRADLE_DISTRIBUTION_NAME val archiveName = GRADLE_DISTRIBUTION_ARCHIVE_NAME val debugArchive = rootProject.file("assets/$archiveName") - val releaseArchive = rootProject.file("assets/release/common/data/common/$archiveName.br") - // Only a release APK bundles the distribution. A debug APK gets it from the separate assets - // zip, so a debug build with neither archive on disk has nothing of its own to check. - val releaseTask = Regex("""(assemble|bundle|package|install)\w*Release""") - val releaseBuild = gradle.startParameter.taskNames.any { releaseTask.containsMatchIn(it) } + val releaseArchive = quickBuildReleaseDistribution val classpath = files( rootProject @@ -555,7 +554,6 @@ val quickBuildDistJarCheck = .named("runtimeClasspath"), ) inputs.files(classpath) - inputs.property("releaseBuild", releaseBuild) inputs .files(debugArchive) .withPropertyName("debugDistribution") @@ -578,6 +576,7 @@ val quickBuildDistJarCheck = } } + /** Fails unless every linked jar sits in the distribution's lib/ inside [archive]. */ fun requireLinkedJars( entries: Set, archive: File, @@ -617,18 +616,29 @@ val quickBuildDistJarCheck = generateSequence { zip.nextEntry }.forEach { entries += it.name } } requireLinkedJars(entries, releaseArchive) - } else if (releaseBuild) { - throw GradleException( - "${releaseArchive.absolutePath} is absent, so quickBuildDistJarCheck cannot read " + - "the Gradle distribution this release build packages. Run " + - ":app:assetsDownloadRelease first.", - ) } if (!debugArchive.isFile && !releaseArchive.isFile) { logger.lifecycle( "quickBuildDistJarCheck: NOT PACKAGED - 0 of ${expected.size} linked jars looked " + - "up. This debug build bundles no Gradle distribution; the device gets it " + - "from the assets zip, which ./gradlew :app:assetsDownloadDebug fetches to check.", + "up. No Gradle distribution archive is on disk; a debug APK gets it from the " + + "assets zip, which ./gradlew :app:assetsDownloadDebug fetches to check.", + ) + } + } + } + +// Release variants bundle the distribution, so they must not package the daemon zip until the +// check above has read it. Wired per variant below rather than inferred from task names. +val quickBuildReleaseDistGate = + tasks.register("quickBuildReleaseDistGate") { + dependsOn(quickBuildDistJarCheck) + val releaseArchive = quickBuildReleaseDistribution + doLast { + if (!releaseArchive.isFile) { + throw GradleException( + "${releaseArchive.absolutePath} is absent, so quickBuildDistJarCheck cannot read " + + "the Gradle distribution this release build packages. Run " + + ":app:assetsDownloadRelease first.", ) } } @@ -696,9 +706,14 @@ androidComponents.onVariants { variant -> AddFileToAssetsTask::outputDirectory, ) + // The build type AndroidModuleConf.hasBundledAssets packages assets/release into. + val bundlesDistribution = variant.buildType == "release" val copyDaemonZip = tasks.register("copy${variantName}QuickBuildDaemonZip") { dependsOn(quickBuildDaemonZip) + if (bundlesDistribution) { + dependsOn(quickBuildReleaseDistGate) + } inputFile.set(quickBuildDaemonZip.flatMap { it.archiveFile }) baseAssetsPath.set("data/common") } From ef3e5c4196255c58e0d9afa036fc94f59e8ab922 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 2 Oct 2026 09:45:40 -0700 Subject: [PATCH 22/22] ADFA-4931: gate every variant that bundles the distribution, not just release The gate keyed on buildType == "release", but the instrumentation build type also bundles assets/release, so copyV8InstrumentationQuickBuildDaemonZip packaged with no distribution checked and the build stayed green. The gate now uses hasBundledAssets, the same test AndroidModuleConf uses to add assets/release to a variant, made public for this. The gate is renamed quickBuildBundledDistGate to match. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N1o4KqT54Vp2zpakJ54sbU --- app/build.gradle.kts | 17 +++++++++-------- .../plugins/conf/AndroidModuleConf.kt | 2 +- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 007bf402f7..1977240482 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,6 +5,7 @@ import com.aayushatharva.brotli4j.decoder.BrotliInputStream import com.itsaky.androidide.build.config.BuildConfig import com.itsaky.androidide.desugaring.utils.JavaIOReplacements.applyJavaIOReplacements import com.itsaky.androidide.plugins.AndroidIDEAssetsPlugin +import com.itsaky.androidide.plugins.conf.hasBundledAssets import com.itsaky.androidide.plugins.tasks.AddFileToAssetsTask import org.adfa.constants.GRADLE_API_NAME_JAR_BR import org.adfa.constants.GRADLE_API_NAME_JAR_ZIP @@ -627,17 +628,17 @@ val quickBuildDistJarCheck = } } -// Release variants bundle the distribution, so they must not package the daemon zip until the -// check above has read it. Wired per variant below rather than inferred from task names. -val quickBuildReleaseDistGate = - tasks.register("quickBuildReleaseDistGate") { +// A variant that bundles the distribution must not package the daemon zip until the check +// above has read it. Wired per variant below rather than inferred from task names. +val quickBuildBundledDistGate = + tasks.register("quickBuildBundledDistGate") { dependsOn(quickBuildDistJarCheck) val releaseArchive = quickBuildReleaseDistribution doLast { if (!releaseArchive.isFile) { throw GradleException( "${releaseArchive.absolutePath} is absent, so quickBuildDistJarCheck cannot read " + - "the Gradle distribution this release build packages. Run " + + "the Gradle distribution this variant packages. Run " + ":app:assetsDownloadRelease first.", ) } @@ -706,13 +707,13 @@ androidComponents.onVariants { variant -> AddFileToAssetsTask::outputDirectory, ) - // The build type AndroidModuleConf.hasBundledAssets packages assets/release into. - val bundlesDistribution = variant.buildType == "release" + // The same test that adds assets/release, and the distribution in it, to this variant. + val bundlesDistribution = hasBundledAssets(variant) val copyDaemonZip = tasks.register("copy${variantName}QuickBuildDaemonZip") { dependsOn(quickBuildDaemonZip) if (bundlesDistribution) { - dependsOn(quickBuildReleaseDistGate) + dependsOn(quickBuildBundledDistGate) } inputFile.set(quickBuildDaemonZip.flatMap { it.archiveFile }) baseAssetsPath.set("data/common") diff --git a/composite-builds/build-logic/plugins/src/main/java/com/itsaky/androidide/plugins/conf/AndroidModuleConf.kt b/composite-builds/build-logic/plugins/src/main/java/com/itsaky/androidide/plugins/conf/AndroidModuleConf.kt index 396d9f2de3..99e8efd862 100644 --- a/composite-builds/build-logic/plugins/src/main/java/com/itsaky/androidide/plugins/conf/AndroidModuleConf.kt +++ b/composite-builds/build-logic/plugins/src/main/java/com/itsaky/androidide/plugins/conf/AndroidModuleConf.kt @@ -65,7 +65,7 @@ private const val PROFILEABLE_BUILD_TYPE = "profileable" * This is `true` for non-debug builds and for [INSTRUMENTATION_BUILD_TYPE] builds. When updating this * value, please update the corresponding value in `AssetsInstaller.kt` in `:app` module. */ -internal fun hasBundledAssets(variant: Variant): Boolean = +fun hasBundledAssets(variant: Variant): Boolean = when (variant.buildType) { INSTRUMENTATION_BUILD_TYPE -> true PROFILEABLE_BUILD_TYPE -> false