From 13d72e0c15790b50c2556d92c76def0444e4e66f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 14 Oct 2026 23:03:52 -0700 Subject: [PATCH 01/43] ADFA-4930: plan for moving Gradle build output off FUSE Benchmarks on a Galaxy Note20 Ultra (Android 13) and a Pixel 6 Pro (Android 17) show warm-daemon builds 24-35% faster with build output in app-private storage, and redirecting only build/ and .gradle/ recovers 91% of that. The plan covers the redirect mechanism, the two consumers that hard-code build/, output lifecycle, a 4-PR stack, validation and draft Steps to QA. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- ...6-10-14-adfa-4930-build-output-off-fuse.md | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md diff --git a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md new file mode 100644 index 0000000000..8c72711bbd --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md @@ -0,0 +1,139 @@ +# ADFA-4930: Move Gradle build output off FUSE - implementation plan + +2026-10-14. Branch `perf/ADFA-4930-fuse-storage`. + +## Decision + +Move each project's Gradle build output - every project's `build/` directory and the root `.gradle/` project cache - from the project folder on `/storage/emulated` (FUSE) to app-private storage. Sources stay where they are, visible to the user and over USB. + +This is option 2 of the ticket's original analysis. Option 1, Quick Build's scratch tree, already moved (`QuickBuildScratch`, `EnvironmentQuickBuildPaths.kt:59`). This plan reuses its project key and storage location. + +## Evidence + +Measured 2026-10-14 with the IDE's Gradle arguments, same template project on both legs, ABBA order. Details and raw numbers are in the ticket comments. + +| Device | Build | FUSE | App storage | Saving | +|---|---|---|---|---| +| Galaxy Note20 Ultra, Android 13 | warm edit (n=5) | 6.23 s | 4.07 s | 35% | +| Galaxy Note20 Ultra, Android 13 | warm clean | 17.2 s | 11.5 s | 33% | +| Pixel 6 Pro, Android 17 | warm edit (n=5) | 2.92 s | 2.22 s | 24% | +| Pixel 6 Pro, Android 17 | warm clean | 8.95 s | 6.37 s | 29% | +| Both | cold build after reboot, `--no-daemon` | | | none measurable | + +- **Moving only the build output is enough.** Sources on FUSE with `build/` and `.gradle/` redirected to app storage recovered 91% of the FUSE cost for both edit and clean builds (Pixel, 6 and 3 rounds). No build output reached `/sdcard`. +- **FUSE costs waiting, not CPU.** Kernel CPU time is the same on both filesystems. strace puts the cost in file creation under `build/intermediates` (7.3 ms median on FUSE vs 56 us on f2fs). +- **Faster storage would add little.** Kernel time is 8-10% of build CPU, so making every remaining file operation instant would save at most 8-10% more. Android exposes no RAM filesystem to apps (`/dev/shm` does not exist; the tmpfs mounts belong to the system or the shell). +- **Not yet measured:** a large project, and the low tiers named in the original analysis (C107, itel A667L). See Validation. + +## Design + +### Where the output goes + +`/gradle-build//`, next to Quick Build's `quickbuild-scratch/`. `noBackupFilesDir` is app-private f2fs and excluded from Auto Backup, which suits regenerable output. + +- `` is `QuickBuildScratch.projectKey`: `-` of the normalized absolute project path. Extract it to a shared helper so both trees key a project the same way. +- Inside the tree: + - `main//` - each project's build directory in the main build (`main/_root/` for the root project, `main/app/` for `:app`). + - `-//` - nested builds (`buildSrc`, `includeBuild`), keyed by their own root directory. + - `project-cache/` - Gradle's `.gradle/`, via `--project-cache-dir`. + - `kotlin/` - Kotlin's `.kotlin/`, via `kotlin.project.persistent.dir`. Confirm the property name and the minimum Kotlin Gradle plugin version before relying on it; skip it for older versions. + - `project-path` - a one-line marker holding the project's absolute path, for the orphan sweep. + +### How it is wired + +1. **IDE side.** `GradleBuildService.getGradleExtraArgs` (`GradleBuildService.kt:697`) adds, when the feature is on: + - `-Pandroidide.build.outputDir=` + - `--project-cache-dir /project-cache` + - `-Pkotlin.project.persistent.dir=/kotlin` + + Sync (`RootModelBuilder.kt:207`) and builds (`ToolingApiServerImpl.kt:313`) both take their arguments from `prepareBuild`, so this one change covers both. `prepareBuild` already reads the project path (`GradleBuildService.kt:552`). +2. **Gradle side.** `AndroidIDEInitScriptPlugin.apply` (`gradle-plugin`) reads the property from `gradle.startParameter.projectProperties` and, in `beforeProject`, sets `project.layout.buildDirectory`. This runs before any build script, so AGP, Kotlin and Quick Build's plugin all see the new location. The prototype init script did exactly this (`gradle.allprojects { p -> p.layout.buildDirectory.set(...) }`), with 33 builds and no output under the project. +3. **Flag.** A `BuildPreferences` switch, default off, until validation passes. + +### What reads build output + +| Where | What it does | Change | +|---|---|---| +| `QuickBuildTaskPaths.kt:44`, `GradleQuickBuildProvisioner.kt:463` | Reads `setup.json` from `/build/quickbuild//`, while `QuickBuildPlugin.kt:334` writes it under `layout.buildDirectory` | **Must change**: resolve from the module's build directory in the project model. Without this, Quick Build fails with "setup.json not found". | +| `BuildViewModel.kt:327` | Finds the plugin `.cgp` under `/build/plugin` | **Must change**: use the root project's build directory from the model. `PluginBuilder.kt:207-217` already writes under `layout.buildDirectory`. | +| `AndroidModule.kt:191-243`, `JavaModule.kt:57-126` | Classpath, `R.jar` and dex files for the editor and run | None - built from `buildDirPath` in Gradle's model (`tooling-api-impl/.../GradleProjectExts.kt:20`). Verify. | +| `BuildViewModel.kt:164`, `:279` | Finds the APK | None - uses AGP's `assembleTaskOutputListingFile`. Verify. | +| `ApkInstaller.kt:203`, `ide_file_provider_paths.xml` | Installs the APK | None - streams into a PackageInstaller session; the FileProvider already covers `files-path` and `root-path`. | +| `QuickBuildPlugin.kt:205-334`, `PluginBuilder.kt:207-217` | Write Quick Build and plugin outputs | None - already relative to `layout.buildDirectory`. | +| `ProjectSearchOptions.kt:18`, `GetProjectDetails.kt:25`, `ProjectSyncHelper.kt:115`, `QuickBuildProjectLayout.kt:148`, `WatchFilter.kt:78` | Skip `build/` and `.gradle/` when walking a project | None. | + +### Lifecycle + +- **Project deleted in the IDE** (`RecentProjectsAdapter.kt:213`, `DeleteProjectFragment`): delete its output tree too. +- **Orphans** (project moved, renamed or deleted outside the IDE): at startup, off the main thread, remove trees whose `project-path` marker no longer exists - the same shape as `QuickBuildScratch.sweep`. +- **Existing `build/` folders in projects**: leave them. They go stale but do no harm. A later "free space" action can offer to remove them. +- **Storage**: output now counts as app data in Settings, and "Clear storage" removes it (the next build is a full one). Uninstalling removes it as well; sources are unaffected. + +### What users will notice + +These are the tradeoffs from the original analysis, with how this plan handles each: + +- **The APK is no longer in the project folder**, file manager or MTP. Add an **Export APK** action that copies the built APK to `Download/` or opens the share sheet. The survey found no existing export or share action. +- **Build output no longer travels with a copied project folder.** That's acceptable: it can be rebuilt, and copying `build/` is rarely wanted. +- **Terminal builds** (`./gradlew` in the IDE's terminal) don't get the redirect, so they still build into the project folder, separately from the IDE's tree. Document this. Changing it is a follow-up if users trip over it. + +## PR stack + +Each PR compiles and passes tests on its own; the flag stays off until PR 4. + +1. **Mechanism, behind the flag.** Shared project-key helper extracted from `QuickBuildScratch`; output-tree resolver; the extra arguments; the init-script plugin redirect. Tests: key and argument unit tests, and a `:gradle-plugin` TestKit test that the build directory and project cache land under the given tree. About 6 files. +2. **Consumers.** Quick Build `setup.json` lookup and plugin `.cgp` lookup from the model. Tests for both. About 4 files. +3. **Lifecycle and UX.** Delete-with-project, the orphan sweep, Export APK, strings. About 6 files. +4. **Default on.** Flip the flag after validation. Update `ARCHITECTURE.md` (storage layout) and any user docs. + +## Validation before default-on + +- Re-run the three-way benchmark (`sdcard` / `split` / `home`) on a large project such as `sora-editor-full`, and on one low-tier phone (C107 or itel A667L). +- Full regression on a device: build and run, Quick Build, a plugin project, clean, sync, a project with `buildSrc`, a Kotlin project. + +## Steps to QA (draft) + +```gherkin +Feature: Build output in app storage + + Background: + Given build output relocation is enabled in Build preferences + + Scenario: A project builds and runs with output in app storage + Given a new Empty Activity project in CodeOnTheGoProjects + When I build and run it + Then the app installs and launches + And the project folder contains no "build" or ".gradle" folder + + Scenario: Quick Build still works + Given an open project that has been built once + When I edit MainActivity and run Quick Build + Then the change appears in the running app + + Scenario: A plugin project still produces its plugin + Given a new Plugin project + When I build it + Then the IDE offers to install the built plugin + + Scenario: Export APK + Given a built project + When I choose Export APK + Then the APK appears in Download + + Scenario: Deleting a project frees its build output + Given a built project + When I delete it from the main screen + Then the app's storage in Android Settings drops by about the size of its build output + + Scenario: An old project with a build folder + Given a project that already has a "build" folder from an earlier version + When I build it + Then the build succeeds + And the old "build" folder is left unchanged +``` + +## Out of scope + +- Gradle file-system watching on Android - ADFA-6545. +- The IDE's own per-project cache, `/.cg/`. It stays on FUSE: few files, written on sync only. +- Samsung Freecess freezing a backgrounded app also stalled Gradle's localhost client-daemon connection in the benchmark (seen with `run-as` builds; whether IDE builds are affected is unverified). From 084a52212ab6c115985ecd8f1da53fe34de8d247 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 15 Oct 2026 13:36:35 -0700 Subject: [PATCH 02/43] ADFA-4930: share Quick Build's project storage key Moves QuickBuildScratch.projectKey into ProjectStorageKey.of, unchanged, so relocated Gradle build output can key a project's tree the same way Quick Build's scratch does. QuickBuildScratch.projectKey delegates to it. No behavior change; the existing QuickBuildScratch key tests cover it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../cotg/quickbuild/data/ProjectStorageKey.kt | 39 +++++++++++++++++++ .../cotg/quickbuild/data/QuickBuildScratch.kt | 28 ++----------- quickbuild/docs/debugging.md | 4 +- 3 files changed, 45 insertions(+), 26 deletions(-) create mode 100644 quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProjectStorageKey.kt diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProjectStorageKey.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProjectStorageKey.kt new file mode 100644 index 0000000000..3a0614a9a9 --- /dev/null +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProjectStorageKey.kt @@ -0,0 +1,39 @@ +package org.appdevforall.cotg.quickbuild.data + +import java.io.File +import java.security.MessageDigest + +/** + * The directory key for a project's per-project trees in app-private storage: Quick Build's + * scratch ([QuickBuildScratch]) and relocated Gradle build output (ADFA-4930). Both use this one + * function so a project maps to the same key in each. + */ +object ProjectStorageKey { + private const val HASH_CHARS = 16 + private const val MAX_BASENAME_CHARS = 40 + + /** + * Derives a project's stable directory key: `-`. + * + * The basename is only for human debuggability; uniqueness comes from the hash of the + * normalized absolute path, so `a/MyApp` and `b/MyApp` cannot collide and a project maps + * to the same tree across sessions. + * + * @param projectRoot the project's root directory; only its path is read, so a moved or + * renamed project keys to a different tree by design. + * @return a filesystem-safe single path segment - every character outside + * `[A-Za-z0-9._-]` is replaced, and the basename is truncated before the hash is joined. + */ + fun of(projectRoot: File): String { + val normalized = projectRoot.absoluteFile.normalize().path + val digest = MessageDigest.getInstance("SHA-256").digest(normalized.toByteArray(Charsets.UTF_8)) + val hash = digest.joinToString("") { "%02x".format(it) }.take(HASH_CHARS) + val base = + projectRoot.name + .map { if (it.isLetterOrDigit() || it == '.' || it == '_' || it == '-') it else '_' } + .joinToString("") + .take(MAX_BASENAME_CHARS) + .ifEmpty { "project" } + return "$base-$hash" + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt index b34aca2580..aa956ed17a 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.withContext import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage import org.slf4j.LoggerFactory import java.io.File -import java.security.MessageDigest /** * Owns the per-project Quick Build scratch trees, `//{work,out}`. @@ -55,29 +54,12 @@ class QuickBuildScratch( } /** - * Derives a project's stable directory key: `-`. + * The project's directory key; see [ProjectStorageKey.of]. * - * The basename is only for human debuggability; uniqueness comes from the hash of the - * normalized absolute path, so `a/MyApp` and `b/MyApp` cannot collide and a project maps - * to the same tree across sessions. - * - * @param projectRoot the project's root directory; only its path is read, so a moved or - * renamed project keys to a different tree by design. - * @return a filesystem-safe single path segment - every character outside - * `[A-Za-z0-9._-]` is replaced, and the basename is truncated before the hash is joined. + * @param projectRoot the project's root directory. + * @return a filesystem-safe single path segment. */ - fun projectKey(projectRoot: File): String { - val normalized = projectRoot.absoluteFile.normalize().path - val digest = MessageDigest.getInstance("SHA-256").digest(normalized.toByteArray(Charsets.UTF_8)) - val hash = digest.joinToString("") { "%02x".format(it) }.take(HASH_CHARS) - val base = - projectRoot.name - .map { if (it.isLetterOrDigit() || it == '.' || it == '_' || it == '-') it else '_' } - .joinToString("") - .take(MAX_BASENAME_CHARS) - .ifEmpty { "project" } - return "$base-$hash" - } + fun projectKey(projectRoot: File): String = ProjectStorageKey.of(projectRoot) /** * The project's scratch tree; parent of its `work/` and `out/` dirs. @@ -204,7 +186,5 @@ class QuickBuildScratch( const val DEFAULT_MIN_FREE_BYTES: Long = 100L * 1024 * 1024 private const val MB = 1024L * 1024 - private const val HASH_CHARS = 16 - private const val MAX_BASENAME_CHARS = 40 } } diff --git a/quickbuild/docs/debugging.md b/quickbuild/docs/debugging.md index e04f432353..4a9ed95389 100644 --- a/quickbuild/docs/debugging.md +++ b/quickbuild/docs/debugging.md @@ -194,8 +194,8 @@ Four traps in that table: - **The scratch tree is deleted on session teardown**, so inspect it while the session is live. Its directory name is `-` - ([`QuickBuildScratch.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt)), - so `ls` the parent rather than trying to compute it. + ([`ProjectStorageKey.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProjectStorageKey.kt), + shared with relocated Gradle build output), so `ls` the parent rather than trying to compute it. - **The generation counter's directory is `.androidide`, not `.cg`.** CoGo's project cache dir was renamed to `.cg`; this one path is hardcoded to the old name in [`FileGenerationStore.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt). From 44376027c613a3c2399467a1bf0631ad32561c06 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 15 Oct 2026 13:36:35 -0700 Subject: [PATCH 03/43] ADFA-4930: relocate Gradle build output behind a preference With Build & Run > "Relocate build output" on, IDE syncs and builds write every project's build directory, the project cache (.gradle/) and Kotlin's .kotlin/ to noBackupFilesDir/gradle-build// instead of the FUSE-backed project folder. Warm-daemon builds measured 24-35% faster that way; see the plan in docs/superpowers/plans. - GradleBuildService passes -Pcotg.build.outputDir, --project-cache-dir and -Pkotlin.project.persistent.dir; sync and builds share these. - AndroidIDEInitScriptPlugin sets layout.buildDirectory in beforeProject, before any build script runs; nested builds get their own subdirectory. - The tree records its project's path for the orphan sweep that comes later in the stack. - The arguments end with --no-watch-fs: Gradle refuses --project-cache-dir with file-system watching turned on, which the tuner's --watch-fs does under experiments. Watching cannot run on Android anyway (ADFA-6545). Off by default: Quick Build's setup.json lookup and the plugin .cgp lookup still read build/ from the project folder until the next PR. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../preferences/buildAndRunPrefExts.kt | 11 +++ .../services/builder/BuildOutputLocation.kt | 66 ++++++++++++++++ .../services/builder/GradleBuildService.kt | 19 +++++ .../builder/BuildOutputLocationTest.kt | 75 +++++++++++++++++++ .../tooling/api/GradlePluginConfig.java | 5 ++ .../gradle/AndroidIDEInitScriptPlugin.kt | 21 ++++++ .../androidide/gradle/BuildOutputRedirect.kt | 48 ++++++++++++ .../gradle/AndroidIDEInitScriptPluginTest.kt | 26 +++++++ .../gradle/BuildOutputRedirectTest.kt | 37 +++++++++ .../preferences/internal/BuildPreferences.kt | 11 +++ resources/src/main/res/values/strings.xml | 2 + 11 files changed, 321 insertions(+) create mode 100644 app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt create mode 100644 app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt create mode 100644 gradle-plugin/src/main/java/com/itsaky/androidide/gradle/BuildOutputRedirect.kt create mode 100644 gradle-plugin/src/test/java/com/itsaky/androidide/gradle/BuildOutputRedirectTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt b/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt index 47c7614ee1..891766fd45 100644 --- a/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt +++ b/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt @@ -30,6 +30,7 @@ import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILDRUN_FLAGS_WARNING import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILD_RUN import com.itsaky.androidide.preferences.internal.BuildPreferences.GRADLE_COMMANDS import com.itsaky.androidide.preferences.internal.BuildPreferences.LAUNCH_APP_AFTER_INSTALL +import com.itsaky.androidide.preferences.internal.BuildPreferences.RELOCATE_BUILD_OUTPUT import com.itsaky.androidide.preferences.internal.BuildPreferences.isBuildCacheEnabled import com.itsaky.androidide.preferences.internal.BuildPreferences.isDebugEnabled import com.itsaky.androidide.preferences.internal.BuildPreferences.isInfoEnabled @@ -38,6 +39,7 @@ import com.itsaky.androidide.preferences.internal.BuildPreferences.isScanEnabled import com.itsaky.androidide.preferences.internal.BuildPreferences.isStacktraceEnabled import com.itsaky.androidide.preferences.internal.BuildPreferences.isWarningModeAllEnabled import com.itsaky.androidide.preferences.internal.BuildPreferences.launchAppAfterInstall +import com.itsaky.androidide.preferences.internal.BuildPreferences.relocateBuildOutput import com.itsaky.androidide.resources.R.drawable import com.itsaky.androidide.resources.R.string import kotlinx.parcelize.Parcelize @@ -66,6 +68,7 @@ private class GradleOptions( init { addPreference(GradleCommands()) + addPreference(RelocateBuildOutput()) } } @@ -92,6 +95,14 @@ private class GradleCommands( } +@Parcelize +private class RelocateBuildOutput( + override val key: String = RELOCATE_BUILD_OUTPUT, + override val title: Int = string.idepref_relocateBuildOutput_title, + override val summary: Int? = string.idepref_relocateBuildOutput_summary, + override val icon: Int? = drawable.ic_folder, +) : SwitchPreference(setValue = ::relocateBuildOutput::set, getValue = ::relocateBuildOutput::get) + @Parcelize private class RunOptions( override val key: String = "ide.build.runOptions", diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt new file mode 100644 index 0000000000..d6cf042f51 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -0,0 +1,66 @@ +package com.itsaky.androidide.services.builder + +import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR +import org.appdevforall.cotg.quickbuild.data.ProjectStorageKey +import java.io.File + +/** + * Where a project's Gradle build output goes when it is moved off the FUSE-backed project folder + * (ADFA-4930): `/gradle-build//`, beside Quick Build's scratch and + * keyed the same way. App-private f2fs makes warm builds 24-35% faster on the devices measured. + */ +object BuildOutputLocation { + /** Parent of every project's tree, under `noBackupFilesDir`. */ + const val DIR_NAME = "gradle-build" + + /** Holds the project's path, so a sweep can find trees whose project is gone. */ + const val PROJECT_PATH_FILE = "project-path" + + private const val KOTLIN_PERSISTENT_DIR = "kotlin.project.persistent.dir" + + /** + * @param noBackupDir the app's `noBackupFilesDir`. + * @param projectDir the project's root directory. + * @return the project's tree; computed, not created. + */ + fun treeFor( + noBackupDir: File, + projectDir: File, + ): File = File(File(noBackupDir, DIR_NAME), ProjectStorageKey.of(projectDir)) + + /** + * The Gradle arguments that send a project's build output into [tree]: every project's build + * directory (set by the init script plugin), the project cache that is otherwise `.gradle/`, + * and Kotlin's `.kotlin/`. Kotlin Gradle plugins that predate that property ignore it. + * + * Gradle refuses `--project-cache-dir` together with file-system watching enabled by + * `--watch-fs` (which [GradleBuildTuner] adds) or by `org.gradle.vfs.watch=true` in a + * project's `gradle.properties`, so these arguments end with `--no-watch-fs`. The last of the + * two flags wins and these arguments come after the tuner's. Nothing is lost: watching cannot + * run on Android, where Gradle's native file-events library does not load (ADFA-6545). + */ + fun gradleArgs(tree: File): List = + listOf( + "-P$PROPERTY_BUILD_OUTPUT_DIR=${tree.absolutePath}", + "--project-cache-dir", + File(tree, "project-cache").absolutePath, + "-P$KOTLIN_PERSISTENT_DIR=${File(tree, "kotlin").absolutePath}", + "--no-watch-fs", + ) + + /** + * Creates [tree] and records [projectDir] in it. + * + * @return false when [tree] cannot be created; the build then keeps its output in the project. + */ + fun prepare( + tree: File, + projectDir: File, + ): Boolean { + if (!tree.isDirectory && !tree.mkdirs()) return false + val marker = File(tree, PROJECT_PATH_FILE) + val path = projectDir.absolutePath + if (!marker.isFile || marker.readText() != path) marker.writeText(path) + return true + } +} diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index 8145f6248c..7f0973154c 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -708,6 +708,10 @@ class GradleBuildService : extraArgs.add("-P${PROPERTY_LOG_SENDER_ENABLED}=$enableLogSender") extraArgs.add("-P${PROPERTY_LOG_SENDER_AAR}=${Environment.LOGSENDER_AAR.absolutePath}") + if (BuildPreferences.relocateBuildOutput) { + extraArgs.addAll(relocatedBuildOutputArgs()) + } + if (BuildPreferences.isStacktraceEnabled) { extraArgs.add("--stacktrace") } @@ -738,6 +742,21 @@ class GradleBuildService : return extraArgs } + /** + * The arguments that move the open project's build output to app-private storage (ADFA-4930), + * or none when there is no open project or its directory cannot be created - the build then + * writes to the project folder as before. Runs on the [prepareBuild] worker, so disk I/O is fine. + */ + private fun relocatedBuildOutputArgs(): List { + val projectDir = ProjectManagerImpl.getInstance().projectDirPath?.let(::File) ?: return emptyList() + val tree = BuildOutputLocation.treeFor(noBackupFilesDir, projectDir) + if (!BuildOutputLocation.prepare(tree, projectDir)) { + log.warn("Cannot create build output directory {}; building into the project folder", tree) + return emptyList() + } + return BuildOutputLocation.gradleArgs(tree) + } + override fun checkGradleWrapperAvailability(): CompletableFuture = if (isGradleWrapperAvailable) { CompletableFuture.completedFuture( diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt new file mode 100644 index 0000000000..56ab29845c --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -0,0 +1,75 @@ +package com.itsaky.androidide.services.builder + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR +import org.appdevforall.cotg.quickbuild.data.ProjectStorageKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class BuildOutputLocationTest { + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun `tree is keyed like Quick Build's scratch, under gradle-build`() { + val noBackup = tmp.newFolder("no_backup") + val project = File(tmp.root, "projects/My App") + + val tree = BuildOutputLocation.treeFor(noBackup, project) + + assertThat(tree).isEqualTo(File(File(noBackup, "gradle-build"), ProjectStorageKey.of(project))) + } + + @Test + fun `args relocate build dirs, the project cache and kotlin state into the tree`() { + val tree = File(tmp.root, "tree") + + assertThat(BuildOutputLocation.gradleArgs(tree)) + .containsExactly( + "-P$PROPERTY_BUILD_OUTPUT_DIR=${tree.absolutePath}", + "--project-cache-dir", + File(tree, "project-cache").absolutePath, + "-Pkotlin.project.persistent.dir=${File(tree, "kotlin").absolutePath}", + "--no-watch-fs", + ).inOrder() + } + + @Test + fun `relocation overrides an earlier --watch-fs, which gradle refuses with a project cache dir`() { + // GradleBuildService puts the tuner's arguments first and these after them. + val args = listOf("--parallel", "--watch-fs") + BuildOutputLocation.gradleArgs(File(tmp.root, "tree")) + + assertThat(args.last { it == "--watch-fs" || it == "--no-watch-fs" }).isEqualTo("--no-watch-fs") + } + + @Test + fun `prepare creates the tree and records the project path`() { + val tree = File(tmp.root, "gradle-build/app-1234") + val project = File(tmp.root, "projects/app") + + assertThat(BuildOutputLocation.prepare(tree, project)).isTrue() + + assertThat(tree.isDirectory).isTrue() + assertThat(File(tree, BuildOutputLocation.PROJECT_PATH_FILE).readText()).isEqualTo(project.absolutePath) + } + + @Test + fun `prepare rewrites a stale project path`() { + val tree = tmp.newFolder("tree") + File(tree, BuildOutputLocation.PROJECT_PATH_FILE).writeText("/old/place") + val project = File(tmp.root, "projects/app") + + assertThat(BuildOutputLocation.prepare(tree, project)).isTrue() + + assertThat(File(tree, BuildOutputLocation.PROJECT_PATH_FILE).readText()).isEqualTo(project.absolutePath) + } + + @Test + fun `prepare reports a tree it cannot create`() { + val blocker = tmp.newFile("blocker") + + assertThat(BuildOutputLocation.prepare(File(blocker, "tree"), File(tmp.root, "app"))).isFalse() + } +} diff --git a/gradle-plugin-config/src/main/java/com/itsaky/androidide/tooling/api/GradlePluginConfig.java b/gradle-plugin-config/src/main/java/com/itsaky/androidide/tooling/api/GradlePluginConfig.java index 5ef74fc090..4bd6180f1e 100644 --- a/gradle-plugin-config/src/main/java/com/itsaky/androidide/tooling/api/GradlePluginConfig.java +++ b/gradle-plugin-config/src/main/java/com/itsaky/androidide/tooling/api/GradlePluginConfig.java @@ -49,6 +49,11 @@ public final class GradlePluginConfig { */ public static final String PROPERTY_QUICK_BUILD_BASELINE_GENERATION = "cotg.quickbuild.baselineGeneration"; + /** + * Absolute path of the directory that receives the build's output instead of each project's {@code build/} (ADFA-4930). When set, the init script plugin points every project's build directory into it, so build output stays off the FUSE-backed project folder. Unset keeps Gradle's default. + */ + public static final String PROPERTY_BUILD_OUTPUT_DIR = "cotg.build.outputDir"; + /** * Property to enable or disable LogSender in the project. Value can be true or false. */ diff --git a/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPlugin.kt b/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPlugin.kt index 45596df26a..cea40ca955 100644 --- a/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPlugin.kt +++ b/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPlugin.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.gradle import com.itsaky.androidide.buildinfo.BuildInfo +import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR import org.adfa.constants.COGO_GRADLE_PLUGIN_JAR_NAME import org.adfa.constants.COGO_GRADLE_PLUGIN_PATH import org.gradle.api.GradleException @@ -67,6 +68,7 @@ class AndroidIDEInitScriptPlugin : Plugin { override fun apply(target: Gradle) { removeDaemonLogs(target) + redirectBuildOutput(target) target.beforeSettings { settings -> settings.addLocalMavenRepoToBuildscript(logger) @@ -113,6 +115,25 @@ class AndroidIDEInitScriptPlugin : Plugin { return loader.urLs.mapNotNull { url -> runCatching { File(url.toURI()) }.getOrNull() } } + /** + * Points every project's build directory into the directory the IDE passed as + * [PROPERTY_BUILD_OUTPUT_DIR], off the FUSE-backed project folder (ADFA-4930). `beforeProject` + * runs before any build script, so AGP, Kotlin and the IDE's own plugins all see the new + * location. Without the property, Gradle's default stays. + */ + private fun redirectBuildOutput(gradle: Gradle) { + val outputDir = gradle.startParameter.projectProperties[PROPERTY_BUILD_OUTPUT_DIR] + if (outputDir.isNullOrBlank()) return + + val root = File(outputDir) + val isNestedBuild = gradle.parent != null + gradle.beforeProject { project -> + val buildDir = BuildOutputRedirect.buildDirFor(root, isNestedBuild, project.rootDir, project.path) + project.layout.buildDirectory.set(buildDir) + logger.info("Build directory of project '{}' relocated to {}", project.path, buildDir) + } + } + private fun removeDaemonLogs(gradle: Gradle) { val gradleUserHomeDir = gradle.gradleUserHomeDir val currentGradleVersion = gradle.gradleVersion diff --git a/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/BuildOutputRedirect.kt b/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/BuildOutputRedirect.kt new file mode 100644 index 0000000000..9b98008f3e --- /dev/null +++ b/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/BuildOutputRedirect.kt @@ -0,0 +1,48 @@ +package com.itsaky.androidide.gradle + +import java.io.File +import java.security.MessageDigest + +/** + * Where a project's build directory goes when the IDE relocates build output off the project + * folder (ADFA-4930): `//`. + * + * `` is `main` for the build the IDE runs. A nested build - `buildSrc` or an included + * build - has its own projects with the same paths (`:`, `:app`), so it gets a directory of its + * own, keyed by its root directory. + */ +internal object BuildOutputRedirect { + private const val MAIN_BUILD = "main" + private const val ROOT_PROJECT = "_root" + private const val HASH_CHARS = 12 + + /** + * @param root the relocated output root the IDE passed in. + * @param isNestedBuild whether the project belongs to `buildSrc` or an included build. + * @param buildRootDir the root directory of the project's build. + * @param projectPath the project's Gradle path, `:` for the root project. + * @return the project's build directory. + */ + fun buildDirFor( + root: File, + isNestedBuild: Boolean, + buildRootDir: File, + projectPath: String, + ): File { + val build = if (isNestedBuild) nestedBuildKey(buildRootDir) else MAIN_BUILD + val project = if (projectPath == ":") ROOT_PROJECT else projectPath.removePrefix(":").replace(':', '/') + return File(File(root, build), project) + } + + private fun nestedBuildKey(buildRootDir: File): String { + val path = buildRootDir.absoluteFile.normalize().path + val hash = + MessageDigest + .getInstance("SHA-256") + .digest(path.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + .take(HASH_CHARS) + val name = buildRootDir.name.map { if (it.isLetterOrDigit() || it in "._-") it else '_' }.joinToString("") + return "${name.ifEmpty { "build" }}-$hash" + } +} diff --git a/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPluginTest.kt b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPluginTest.kt index a4dfac000c..e535478044 100644 --- a/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPluginTest.kt +++ b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPluginTest.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.gradle import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.buildinfo.BuildInfo +import com.itsaky.androidide.tooling.api.GradlePluginConfig import org.gradle.testkit.runner.BuildResult import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test @@ -87,6 +88,31 @@ class AndroidIDEInitScriptPluginTest { assertThat(result.output).doesNotContain("Adding LogSender dependency") } + @Test + fun `test build output is relocated when the IDE asks for it`( + @TempDir dir: File, + ) { + val result = + buildProject(configureArgs = { + it += "-P${GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR}=${dir.absolutePath}" + it += "--info" + }) + + assertIdePluginApplied(result) + assertThat(result.output) + .contains("Build directory of project ':' relocated to ${File(dir, "main/_root")}") + assertThat(result.output) + .contains("Build directory of project ':app' relocated to ${File(dir, "main/app")}") + } + + @Test + fun `test build output stays put by default`() { + val result = buildProject(configureArgs = { it += "--info" }) + + assertIdePluginApplied(result) + assertThat(result.output).doesNotContain("relocated to") + } + @Disabled( "AGP 7.3.0 on Gradle 7.5.1 fails to configure the fixture with 'Protocol message " + "contained an invalid tag (zero)'. Predates - and is unrelated to - the Gradle 9 work; " + diff --git a/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/BuildOutputRedirectTest.kt b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/BuildOutputRedirectTest.kt new file mode 100644 index 0000000000..3f0a5191c7 --- /dev/null +++ b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/BuildOutputRedirectTest.kt @@ -0,0 +1,37 @@ +package com.itsaky.androidide.gradle + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.io.File + +class BuildOutputRedirectTest { + private val root = File("/out") + private val projectDir = File("/projects/MyApp") + + @Test + fun `main build projects go under main`() { + assertThat(BuildOutputRedirect.buildDirFor(root, false, projectDir, ":")).isEqualTo(File("/out/main/_root")) + assertThat(BuildOutputRedirect.buildDirFor(root, false, projectDir, ":app")).isEqualTo(File("/out/main/app")) + assertThat(BuildOutputRedirect.buildDirFor(root, false, projectDir, ":libs:core")) + .isEqualTo(File("/out/main/libs/core")) + } + + @Test + fun `a nested build gets its own directory keyed by its root`() { + val buildSrc = BuildOutputRedirect.buildDirFor(root, true, File(projectDir, "buildSrc"), ":") + + assertThat(buildSrc.parentFile.parentFile).isEqualTo(root) + assertThat(buildSrc.parentFile.name).startsWith("buildSrc-") + assertThat(buildSrc.name).isEqualTo("_root") + } + + @Test + fun `nested builds with the same name in different places do not collide`() { + val a = BuildOutputRedirect.buildDirFor(root, true, File("/a/plugins"), ":app") + val b = BuildOutputRedirect.buildDirFor(root, true, File("/b/plugins"), ":app") + val again = BuildOutputRedirect.buildDirFor(root, true, File("/a/plugins"), ":app") + + assertThat(a).isNotEqualTo(b) + assertThat(again).isEqualTo(a) + } +} diff --git a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt index 34155df4eb..b33e2526ca 100644 --- a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt +++ b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt @@ -36,6 +36,7 @@ object BuildPreferences { const val GRADLE_COMMANDS = "idepref_build_gradleCommands" const val CUSTOM_GRADLE_INSTALLATION = "idepref_build_customGradleInstallation" const val LAUNCH_APP_AFTER_INSTALL = "ide.build.run.launchAppAfterInstall" + const val RELOCATE_BUILD_OUTPUT = "ide.build.relocateBuildOutput" /** Switch for Gradle `--debug` option. */ var isDebugEnabled: Boolean @@ -105,4 +106,14 @@ object BuildPreferences { set(value) { prefManager.putBoolean(LAUNCH_APP_AFTER_INSTALL, value) } + + /** + * Whether builds write their output to app-private storage instead of the project folder + * (ADFA-4930). Off until the rest of the IDE reads build output from the new location. + */ + var relocateBuildOutput: Boolean + get() = prefManager.getBoolean(RELOCATE_BUILD_OUTPUT, false) + set(value) { + prefManager.putBoolean(RELOCATE_BUILD_OUTPUT, value) + } } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 86a4fc3877..86311f92b1 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -872,6 +872,8 @@ Launch app after installation If enabled, the IDE will (without confirmation) launch the application right after it is installed. Run options + Relocate build output + Experimental. Faster repeat builds: build output goes to Code on the Go\'s private storage, not the project folder. Sync to apply. Cannot run application. Unable to determine package name. Cannot run application. No application modules found in project. Launch app From 707320f4dab99bc3a47e1bd006e3448ddccf5340 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 15 Oct 2026 14:13:10 -0700 Subject: [PATCH 04/43] ADFA-4930: move ProjectStorageKey to :shared Relocated Gradle build output is not a Quick Build concern, so the shared project key moves from quickbuild:core to the cross-cutting :shared module; quickbuild:core now depends on :shared. Imports and the Quick Build debugging guide's link follow. No behavior change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../itsaky/androidide/services/builder/BuildOutputLocation.kt | 2 +- .../androidide/services/builder/BuildOutputLocationTest.kt | 2 +- quickbuild/core/build.gradle.kts | 1 + .../appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt | 1 + quickbuild/docs/debugging.md | 2 +- .../java/com/itsaky/androidide/utils}/ProjectStorageKey.kt | 4 ++-- 6 files changed, 7 insertions(+), 5 deletions(-) rename {quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data => shared/src/main/java/com/itsaky/androidide/utils}/ProjectStorageKey.kt (93%) diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index d6cf042f51..ec2695c183 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -1,7 +1,7 @@ package com.itsaky.androidide.services.builder import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR -import org.appdevforall.cotg.quickbuild.data.ProjectStorageKey +import com.itsaky.androidide.utils.ProjectStorageKey import java.io.File /** diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index 56ab29845c..95095b6c57 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -2,7 +2,7 @@ package com.itsaky.androidide.services.builder import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR -import org.appdevforall.cotg.quickbuild.data.ProjectStorageKey +import com.itsaky.androidide.utils.ProjectStorageKey import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder diff --git a/quickbuild/core/build.gradle.kts b/quickbuild/core/build.gradle.kts index 0999389124..8ceb7ddce6 100644 --- a/quickbuild/core/build.gradle.kts +++ b/quickbuild/core/build.gradle.kts @@ -62,6 +62,7 @@ tasks.register("jacocoTestReport") { dependencies { implementation(projects.logger) implementation(projects.eventbusEvents) + implementation(projects.shared) // Wire DTOs/constants shared with the daemon (single protocol definition). api, not // implementation: CompileOutput.stats, DexOutput.stats and DaemonReply.BuildFailed.stats // put its types on this module's public surface. diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt index aa956ed17a..f3d9dd510e 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt @@ -1,5 +1,6 @@ package org.appdevforall.cotg.quickbuild.data +import com.itsaky.androidide.utils.ProjectStorageKey import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/quickbuild/docs/debugging.md b/quickbuild/docs/debugging.md index 4a9ed95389..95c13dcbb2 100644 --- a/quickbuild/docs/debugging.md +++ b/quickbuild/docs/debugging.md @@ -194,7 +194,7 @@ Four traps in that table: - **The scratch tree is deleted on session teardown**, so inspect it while the session is live. Its directory name is `-` - ([`ProjectStorageKey.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProjectStorageKey.kt), + ([`ProjectStorageKey.kt`](../../shared/src/main/java/com/itsaky/androidide/utils/ProjectStorageKey.kt), shared with relocated Gradle build output), so `ls` the parent rather than trying to compute it. - **The generation counter's directory is `.androidide`, not `.cg`.** CoGo's project cache dir was renamed to `.cg`; this one path is hardcoded to the old name in diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProjectStorageKey.kt b/shared/src/main/java/com/itsaky/androidide/utils/ProjectStorageKey.kt similarity index 93% rename from quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProjectStorageKey.kt rename to shared/src/main/java/com/itsaky/androidide/utils/ProjectStorageKey.kt index 3a0614a9a9..4618ae28b2 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProjectStorageKey.kt +++ b/shared/src/main/java/com/itsaky/androidide/utils/ProjectStorageKey.kt @@ -1,11 +1,11 @@ -package org.appdevforall.cotg.quickbuild.data +package com.itsaky.androidide.utils import java.io.File import java.security.MessageDigest /** * The directory key for a project's per-project trees in app-private storage: Quick Build's - * scratch ([QuickBuildScratch]) and relocated Gradle build output (ADFA-4930). Both use this one + * scratch (`QuickBuildScratch`) and relocated Gradle build output (ADFA-4930). Both use this one * function so a project maps to the same key in each. */ object ProjectStorageKey { From 1343378671006f0cae809d895b2cab45cbf47a24 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 15 Oct 2026 14:13:10 -0700 Subject: [PATCH 05/43] ADFA-4930: gate build-output relocation behind experiments The switch is shown, and honored, only while experiments are enabled (REVIEW.md section 12): until the rest of the stack lands, Quick Build and plugin builds still look for output in the project folder. The switch also gets its long-press help tag (section 9); the tooltip text is still to be written in documentation.db. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../itsaky/androidide/preferences/buildAndRunPrefExts.kt | 9 ++++++++- .../androidide/services/builder/GradleBuildService.kt | 3 ++- .../java/com/itsaky/androidide/idetooltips/TooltipTag.kt | 1 + .../androidide/preferences/internal/BuildPreferences.kt | 3 ++- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt b/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt index 891766fd45..bee33b03f0 100644 --- a/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt +++ b/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt @@ -27,6 +27,7 @@ import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILDRUN_FLAGS_OFFLINE import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILDRUN_FLAGS_SCAN import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILDRUN_FLAGS_STACKTRACE import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILDRUN_FLAGS_WARNINGMODEALL +import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILDRUN_RELOCATE_OUTPUT import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILD_RUN import com.itsaky.androidide.preferences.internal.BuildPreferences.GRADLE_COMMANDS import com.itsaky.androidide.preferences.internal.BuildPreferences.LAUNCH_APP_AFTER_INSTALL @@ -42,6 +43,7 @@ import com.itsaky.androidide.preferences.internal.BuildPreferences.launchAppAfte import com.itsaky.androidide.preferences.internal.BuildPreferences.relocateBuildOutput import com.itsaky.androidide.resources.R.drawable import com.itsaky.androidide.resources.R.string +import com.itsaky.androidide.utils.FeatureFlags import kotlinx.parcelize.Parcelize @Parcelize @@ -68,7 +70,11 @@ private class GradleOptions( init { addPreference(GradleCommands()) - addPreference(RelocateBuildOutput()) + // Early access only (REVIEW.md section 12): until the rest of the ADFA-4930 stack lands, + // Quick Build and plugin builds still look for output in the project folder. + if (FeatureFlags.isExperimentsEnabled) { + addPreference(RelocateBuildOutput()) + } } } @@ -101,6 +107,7 @@ private class RelocateBuildOutput( override val title: Int = string.idepref_relocateBuildOutput_title, override val summary: Int? = string.idepref_relocateBuildOutput_summary, override val icon: Int? = drawable.ic_folder, + override val tooltipTag: String = PREFS_BUILDRUN_RELOCATE_OUTPUT, ) : SwitchPreference(setValue = ::relocateBuildOutput::set, getValue = ::relocateBuildOutput::get) @Parcelize diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index 7f0973154c..31be8707dd 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -708,7 +708,8 @@ class GradleBuildService : extraArgs.add("-P${PROPERTY_LOG_SENDER_ENABLED}=$enableLogSender") extraArgs.add("-P${PROPERTY_LOG_SENDER_AAR}=${Environment.LOGSENDER_AAR.absolutePath}") - if (BuildPreferences.relocateBuildOutput) { + // Early access only, like the switch itself (REVIEW.md section 12). + if (FeatureFlags.isExperimentsEnabled && BuildPreferences.relocateBuildOutput) { extraArgs.addAll(relocatedBuildOutputArgs()) } diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index 9b304ed645..30268ad325 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -98,6 +98,7 @@ object TooltipTag { // Preferences - Build & Run screen const val PREFS_BUILDRUN_AUTOLAUNCH = "prefs.buildrun.autolaunch" + const val PREFS_BUILDRUN_RELOCATE_OUTPUT = "prefs.buildrun.relocateoutput" const val PREFS_BUILDRUN_FLAGS = "prefs.buildrun.flags" const val PREFS_BUILDRUN_FLAGS_STACKTRACE = "prefs.buildrun.flags.stacktrace" const val PREFS_BUILDRUN_FLAGS_INFO = "prefs.buildrun.flags.info" diff --git a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt index b33e2526ca..9d2b4f8599 100644 --- a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt +++ b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt @@ -109,7 +109,8 @@ object BuildPreferences { /** * Whether builds write their output to app-private storage instead of the project folder - * (ADFA-4930). Off until the rest of the IDE reads build output from the new location. + * (ADFA-4930). Off until the rest of the IDE reads build output from the new location, and + * honored only while experiments are enabled. */ var relocateBuildOutput: Boolean get() = prefManager.getBoolean(RELOCATE_BUILD_OUTPUT, false) From 419142cb013df78b52876d1e23be752914d49a11 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 15 Oct 2026 15:57:59 -0700 Subject: [PATCH 06/43] ADFA-4930: one flat, hashed build directory per project Relocated build directories mirrored the Gradle path, so `:core:ui` landed inside `:core`'s build directory (where `:core:clean` deletes it), a subproject named `generated` or `intermediates` landed in its parent's task outputs, and `:_root` collided with the root project. Each project now gets `/-`: its last path segment (`root` for `:`) plus a SHA-256 prefix of its full path, flat under `main/` or under its nested build's key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../androidide/gradle/BuildOutputRedirect.kt | 45 ++++++++++++++----- .../gradle/AndroidIDEInitScriptPluginTest.kt | 9 ++-- .../gradle/BuildOutputRedirectTest.kt | 38 +++++++++++++--- 3 files changed, 71 insertions(+), 21 deletions(-) diff --git a/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/BuildOutputRedirect.kt b/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/BuildOutputRedirect.kt index 9b98008f3e..2c7b6e6ade 100644 --- a/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/BuildOutputRedirect.kt +++ b/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/BuildOutputRedirect.kt @@ -5,16 +5,26 @@ import java.security.MessageDigest /** * Where a project's build directory goes when the IDE relocates build output off the project - * folder (ADFA-4930): `//`. + * folder (ADFA-4930): `//`, one flat directory per project. * * `` is `main` for the build the IDE runs. A nested build - `buildSrc` or an included * build - has its own projects with the same paths (`:`, `:app`), so it gets a directory of its * own, keyed by its root directory. + * + * `` is the project's name plus a hash of its full Gradle path. Mapping `:core:ui` to + * `core/ui` instead would put it inside `:core`'s build directory, where `:core:clean` deletes it + * and a subproject called `generated` or `intermediates` lands in its parent's task outputs. The + * name never keeps `:`, which is the classpath separator. */ internal object BuildOutputRedirect { private const val MAIN_BUILD = "main" - private const val ROOT_PROJECT = "_root" - private const val HASH_CHARS = 12 + private const val ROOT_PROJECT = "root" + + // The same lengths as the app's ProjectStorageKey, which keys the tree this lives in. The + // plugin keeps its own copy because its jar is injected into every user build and stays off + // the IDE's modules. + private const val HASH_CHARS = 16 + private const val MAX_NAME_CHARS = 40 /** * @param root the relocated output root the IDE passed in. @@ -29,20 +39,33 @@ internal object BuildOutputRedirect { buildRootDir: File, projectPath: String, ): File { - val build = if (isNestedBuild) nestedBuildKey(buildRootDir) else MAIN_BUILD - val project = if (projectPath == ":") ROOT_PROJECT else projectPath.removePrefix(":").replace(':', '/') - return File(File(root, build), project) + val build = + if (isNestedBuild) { + key(buildRootDir.name, buildRootDir.absoluteFile.normalize().path) + } else { + MAIN_BUILD + } + val name = if (projectPath == ":") ROOT_PROJECT else projectPath.substringAfterLast(':') + return File(File(root, build), key(name, projectPath)) } - private fun nestedBuildKey(buildRootDir: File): String { - val path = buildRootDir.absoluteFile.normalize().path + /** `-`: readable, and unique per identity. */ + private fun key( + name: String, + identity: String, + ): String { val hash = MessageDigest .getInstance("SHA-256") - .digest(path.toByteArray(Charsets.UTF_8)) + .digest(identity.toByteArray(Charsets.UTF_8)) .joinToString("") { "%02x".format(it) } .take(HASH_CHARS) - val name = buildRootDir.name.map { if (it.isLetterOrDigit() || it in "._-") it else '_' }.joinToString("") - return "${name.ifEmpty { "build" }}-$hash" + val safeName = + name + .map { if (it.isLetterOrDigit() || it in "._-") it else '_' } + .joinToString("") + .take(MAX_NAME_CHARS) + .ifEmpty { "build" } + return "$safeName-$hash" } } diff --git a/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPluginTest.kt b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPluginTest.kt index e535478044..6f716e50fb 100644 --- a/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPluginTest.kt +++ b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPluginTest.kt @@ -99,10 +99,11 @@ class AndroidIDEInitScriptPluginTest { }) assertIdePluginApplied(result) - assertThat(result.output) - .contains("Build directory of project ':' relocated to ${File(dir, "main/_root")}") - assertThat(result.output) - .contains("Build directory of project ':app' relocated to ${File(dir, "main/app")}") + for (path in listOf(":", ":app")) { + // The main build's directory does not depend on its root, so any File will do. + val expected = BuildOutputRedirect.buildDirFor(dir, false, dir, path) + assertThat(result.output).contains("Build directory of project '$path' relocated to $expected") + } } @Test diff --git a/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/BuildOutputRedirectTest.kt b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/BuildOutputRedirectTest.kt index 3f0a5191c7..a5491728e0 100644 --- a/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/BuildOutputRedirectTest.kt +++ b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/BuildOutputRedirectTest.kt @@ -8,12 +8,38 @@ class BuildOutputRedirectTest { private val root = File("/out") private val projectDir = File("/projects/MyApp") + private fun main(path: String) = BuildOutputRedirect.buildDirFor(root, false, projectDir, path) + + @Test + fun `main build projects get one flat directory each under main`() { + for (path in listOf(":", ":app", ":libs:core")) { + assertThat(main(path).parentFile).isEqualTo(File(root, "main")) + } + assertThat(main(":").name).startsWith("root-") + assertThat(main(":app").name).startsWith("app-") + assertThat(main(":libs:core").name).startsWith("core-") + } + + @Test + fun `a child project's build directory is not inside its parent's`() { + val parent = main(":core") + val child = main(":core:ui") + + assertThat(child.parentFile).isEqualTo(parent.parentFile) + assertThat(child.path).doesNotContain(parent.path + File.separator) + } + + @Test + fun `names that used to collide do not`() { + val dirs = listOf(":", ":_root", ":root", ":core", ":core:generated", ":generated", ":a:b", ":a_b").map(::main) + + assertThat(dirs.toSet()).hasSize(dirs.size) + } + @Test - fun `main build projects go under main`() { - assertThat(BuildOutputRedirect.buildDirFor(root, false, projectDir, ":")).isEqualTo(File("/out/main/_root")) - assertThat(BuildOutputRedirect.buildDirFor(root, false, projectDir, ":app")).isEqualTo(File("/out/main/app")) - assertThat(BuildOutputRedirect.buildDirFor(root, false, projectDir, ":libs:core")) - .isEqualTo(File("/out/main/libs/core")) + fun `the mapping is stable and never contains a colon`() { + assertThat(main(":core:ui")).isEqualTo(main(":core:ui")) + assertThat(main(":core:ui").name).doesNotContain(":") } @Test @@ -22,7 +48,7 @@ class BuildOutputRedirectTest { assertThat(buildSrc.parentFile.parentFile).isEqualTo(root) assertThat(buildSrc.parentFile.name).startsWith("buildSrc-") - assertThat(buildSrc.name).isEqualTo("_root") + assertThat(buildSrc.name).startsWith("root-") } @Test From 07906783078f911fc33a34a22912445c8f3d4997 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 15 Oct 2026 15:57:59 -0700 Subject: [PATCH 07/43] ADFA-4930: apply build-output relocation at sync Builds read the live switch, while the project model (APK listing file, classpath) came from the last sync. Toggling the switch without a sync sent the next build's output somewhere the IDE did not look. A sync now applies the switch and records the result in `/.cg/build-output-relocated`; builds follow that record. When the switch and the record differ, the editor shows the sync-needed banner on resume, once per change of the switch, and opening the project syncs it. A sync is recognized by its `BuildRunType.ProjectSync` build ID. Also from the PR 1 review: - `--watch-fs` is removed wherever it sits once the project cache is relocated, instead of relying on `--no-watch-fs` coming last. - `prepare` returns false when the tree's marker cannot be written, instead of throwing out of `prepareBuild`. - An unset project path (`""`) no longer resolves to the working directory. - One `isAvailable()` gate for the switch and the build. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../editor/ProjectHandlerActivity.kt | 45 +++++++++- .../preferences/buildAndRunPrefExts.kt | 4 +- .../services/builder/BuildOutputLocation.kt | 88 ++++++++++++++++--- .../services/builder/GradleBuildService.kt | 42 ++++++--- .../builder/BuildOutputLocationTest.kt | 61 ++++++++++++- ...6-10-14-adfa-4930-build-output-off-fuse.md | 17 +++- 6 files changed, 220 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index c426c94b65..e53f86ea44 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -97,6 +97,7 @@ import com.itsaky.androidide.search.replace.ReplaceCoordinator import com.itsaky.androidide.search.replace.ReplaceHost import com.itsaky.androidide.search.replace.ReplaceSession import com.itsaky.androidide.search.replace.SkipReason +import com.itsaky.androidide.services.builder.BuildOutputLocation import com.itsaky.androidide.services.builder.GradleBuildService import com.itsaky.androidide.services.builder.GradleBuildServiceConnnection import com.itsaky.androidide.services.builder.gradleDistributionParams @@ -122,6 +123,7 @@ import com.itsaky.androidide.utils.DURATION_INDEFINITE import com.itsaky.androidide.utils.DURATION_LONG import com.itsaky.androidide.utils.DialogUtils.newMaterialDialogBuilder import com.itsaky.androidide.utils.DialogUtils.showRestartPrompt +import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FeatureFlags import com.itsaky.androidide.utils.ProjectSearchOptions import com.itsaky.androidide.utils.RecursiveFileSearcher @@ -182,6 +184,9 @@ abstract class ProjectHandlerActivity : protected var mReplaceInProjectDialog: AlertDialog? = null private val replaceCoordinator by lazy { ReplaceCoordinator(editorFor = { file -> getEditorForFile(file)?.editor }) } protected var syncNotificationFlashbar: Flashbar? = null + + /** The relocation request the sync banner last offered, so Ignore holds until it changes. */ + private var buildOutputSyncOffered: Boolean? = null private var replaceReportFlashbar: Flashbar? = null private val buildViewModel by viewModels() @@ -1203,9 +1208,41 @@ abstract class ProjectHandlerActivity : // parked awaiting that retry (auto-retries are bounded by the reducer). quickBuildSessionManager()?.onHostForegrounded() + // ADFA-4930: a changed "Relocate build output" (or experiments flag) only moves the build + // output at the next sync, so offer one, once per change. notifySyncNeeded stays quiet + // while the project is still initializing, which syncs on its own when the change is + // pending. + activityScope.launch { + val path = ProjectManagerImpl.getInstance().projectDirPath.takeIf { it.isNotBlank() } ?: return@launch + val requested = BuildOutputLocation.isRequested() + val pending = isBuildOutputSyncPending(File(path), requested) + withContext(Dispatchers.Main) { + when { + !pending -> { + buildOutputSyncOffered = null + } + + buildOutputSyncOffered != requested -> { + if (notifySyncNeeded { initializeProject(forceSync = true) }) { + buildOutputSyncOffered = requested + } + } + } + } + } + invalidateOptionsMenu() } + /** Reads the project's cache directory, so it runs on [Dispatchers.IO]. */ + private suspend fun isBuildOutputSyncPending( + projectDir: File, + requested: Boolean = BuildOutputLocation.isRequested(), + ): Boolean = + withContext(Dispatchers.IO) { + BuildOutputLocation.syncPending(Environment.getProjectCacheDir(projectDir), requested) + } + override fun preDestroy() { syncNotificationFlashbar?.dismiss() syncNotificationFlashbar = null @@ -1287,9 +1324,10 @@ abstract class ProjectHandlerActivity : notifySyncNeeded { initializeProject(forceSync = true) } } - private fun notifySyncNeeded(onConfirm: () -> Unit) { + /** @return false when the banner was not shown: no build service, or initializing or building. */ + private fun notifySyncNeeded(onConfirm: () -> Unit): Boolean { val buildService = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) - if (buildService == null || editorViewModel.isInitializing || buildService.isBuildInProgress) return + if (buildService == null || editorViewModel.isInitializing || buildService.isBuildInProgress) return false activityScope.launch(Dispatchers.Main.immediate) { syncNotificationFlashbar?.dismiss() @@ -1312,6 +1350,7 @@ abstract class ProjectHandlerActivity : syncNotificationFlashbar?.showOnUiThread() } + return true } fun startServices() { @@ -1421,7 +1460,7 @@ abstract class ProjectHandlerActivity : val needsSync = try { - forceSync || manager.isGradleSyncNeeded(projectDir) + forceSync || manager.isGradleSyncNeeded(projectDir) || isBuildOutputSyncPending(projectDir) } catch (e: Exception) { when (e) { is FileNotFoundException -> { diff --git a/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt b/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt index bee33b03f0..aced7eb4e6 100644 --- a/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt +++ b/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt @@ -43,7 +43,7 @@ import com.itsaky.androidide.preferences.internal.BuildPreferences.launchAppAfte import com.itsaky.androidide.preferences.internal.BuildPreferences.relocateBuildOutput import com.itsaky.androidide.resources.R.drawable import com.itsaky.androidide.resources.R.string -import com.itsaky.androidide.utils.FeatureFlags +import com.itsaky.androidide.services.builder.BuildOutputLocation import kotlinx.parcelize.Parcelize @Parcelize @@ -72,7 +72,7 @@ private class GradleOptions( addPreference(GradleCommands()) // Early access only (REVIEW.md section 12): until the rest of the ADFA-4930 stack lands, // Quick Build and plugin builds still look for output in the project folder. - if (FeatureFlags.isExperimentsEnabled) { + if (BuildOutputLocation.isAvailable()) { addPreference(RelocateBuildOutput()) } } diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index ec2695c183..6bde569d4d 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -1,13 +1,20 @@ package com.itsaky.androidide.services.builder +import com.itsaky.androidide.preferences.internal.BuildPreferences import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR +import com.itsaky.androidide.utils.FeatureFlags import com.itsaky.androidide.utils.ProjectStorageKey import java.io.File +import java.io.IOException /** * Where a project's Gradle build output goes when it is moved off the FUSE-backed project folder * (ADFA-4930): `/gradle-build//`, beside Quick Build's scratch and * keyed the same way. App-private f2fs makes warm builds 24-35% faster on the devices measured. + * + * A sync applies the user's choice and records it in the project's cache directory; builds follow + * that record, not the live switch, so their output is always where the synced model says it is. + * A change to the switch therefore waits for the next sync ([syncPending]). */ object BuildOutputLocation { /** Parent of every project's tree, under `noBackupFilesDir`. */ @@ -16,7 +23,19 @@ object BuildOutputLocation { /** Holds the project's path, so a sweep can find trees whose project is gone. */ const val PROJECT_PATH_FILE = "project-path" + /** Present in a project's cache directory when its last sync relocated build output. */ + const val SYNCED_STATE_FILE = "build-output-relocated" + private const val KOTLIN_PERSISTENT_DIR = "kotlin.project.persistent.dir" + private const val WATCH_FS = "--watch-fs" + private const val NO_WATCH_FS = "--no-watch-fs" + private const val PROJECT_CACHE_DIR = "--project-cache-dir" + + /** Whether the switch exists at all: early access only (REVIEW.md section 12). */ + fun isAvailable(): Boolean = FeatureFlags.isExperimentsEnabled + + /** Whether the user currently asks for relocation; applied at the next sync. */ + fun isRequested(): Boolean = isAvailable() && BuildPreferences.relocateBuildOutput /** * @param noBackupDir the app's `noBackupFilesDir`. @@ -33,25 +52,32 @@ object BuildOutputLocation { * directory (set by the init script plugin), the project cache that is otherwise `.gradle/`, * and Kotlin's `.kotlin/`. Kotlin Gradle plugins that predate that property ignore it. * - * Gradle refuses `--project-cache-dir` together with file-system watching enabled by - * `--watch-fs` (which [GradleBuildTuner] adds) or by `org.gradle.vfs.watch=true` in a - * project's `gradle.properties`, so these arguments end with `--no-watch-fs`. The last of the - * two flags wins and these arguments come after the tuner's. Nothing is lost: watching cannot - * run on Android, where Gradle's native file-events library does not load (ADFA-6545). + * They end with `--no-watch-fs`, which overrides `org.gradle.vfs.watch=true` in a project's + * `gradle.properties`: Gradle refuses `--project-cache-dir` with file-system watching turned on. + * [withoutWatchConflict] removes the tuner's `--watch-fs` wherever it sits. Nothing is lost: + * watching cannot run on Android, where Gradle's native file-events library does not load + * (ADFA-6545). */ fun gradleArgs(tree: File): List = listOf( "-P$PROPERTY_BUILD_OUTPUT_DIR=${tree.absolutePath}", - "--project-cache-dir", + PROJECT_CACHE_DIR, File(tree, "project-cache").absolutePath, "-P$KOTLIN_PERSISTENT_DIR=${File(tree, "kotlin").absolutePath}", - "--no-watch-fs", + NO_WATCH_FS, ) + /** + * Drops `--watch-fs` from a build's complete argument list when it also relocates the project + * cache, so the build does not fail whatever order the tuner's and these arguments are in. + */ + fun withoutWatchConflict(args: List): List = if (PROJECT_CACHE_DIR in args) args.filterNot { it == WATCH_FS } else args + /** * Creates [tree] and records [projectDir] in it. * - * @return false when [tree] cannot be created; the build then keeps its output in the project. + * @return false when [tree] or its marker cannot be written; the build then keeps its output + * in the project folder. */ fun prepare( tree: File, @@ -60,7 +86,49 @@ object BuildOutputLocation { if (!tree.isDirectory && !tree.mkdirs()) return false val marker = File(tree, PROJECT_PATH_FILE) val path = projectDir.absolutePath - if (!marker.isFile || marker.readText() != path) marker.writeText(path) - return true + return try { + if (!marker.isFile || marker.readText() != path) marker.writeText(path) + true + } catch (e: IOException) { + false + } } + + /** + * Whether the project's last sync relocated its build output. False when nothing was recorded: + * every sync before this feature built into the project folder. + * + * @param projectCacheDir the project's cache directory (`/.cg`). + */ + fun syncedState(projectCacheDir: File): Boolean = File(projectCacheDir, SYNCED_STATE_FILE).isFile + + /** + * Records what a sync of the project applied. + * + * @return false when the record cannot be written. + */ + fun recordSyncedState( + projectCacheDir: File, + relocated: Boolean, + ): Boolean { + val file = File(projectCacheDir, SYNCED_STATE_FILE) + return try { + if (relocated) { + (projectCacheDir.isDirectory || projectCacheDir.mkdirs()) && (file.isFile || file.createNewFile()) + } else { + !file.exists() || file.delete() + } + } catch (e: IOException) { + false + } + } + + /** + * Whether the switch (or the experiments flag) has changed since the project's last sync, so the + * next sync would move the build output. + */ + fun syncPending( + projectCacheDir: File, + requested: Boolean = isRequested(), + ): Boolean = syncedState(projectCacheDir) != requested } diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index 31be8707dd..eefedbe86a 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -558,7 +558,10 @@ class GradleBuildService : @Suppress("SimplifyBooleanWithConstants") val extraArgs = - getGradleExtraArgs(enableJdwp = JdwpOptions.JDWP_ENABLED && isDebugBuild) + getGradleExtraArgs( + enableJdwp = JdwpOptions.JDWP_ENABLED && isDebugBuild, + isSync = buildInfo.buildId.runType == BuildRunType.ProjectSync, + ) var buildParams = if (FeatureFlags.isExperimentsEnabled) { @@ -576,7 +579,7 @@ class GradleBuildService : GradleBuildTuner .toGradleBuildParams(tuningConfig = newTuningConfig) .run { - copy(gradleArgs = gradleArgs + extraArgs) + copy(gradleArgs = BuildOutputLocation.withoutWatchConflict(gradleArgs + extraArgs)) } }.onFailure { err -> log.error("Failed to auto-tune Gradle build", err) @@ -697,6 +700,7 @@ class GradleBuildService : private fun getGradleExtraArgs( enableJdwp: Boolean = JdwpOptions.JDWP_ENABLED, enableLogSender: Boolean = DevOpsPreferences.logsenderEnabled, + isSync: Boolean, ): List { val extraArgs = ArrayList() extraArgs.add("--init-script") @@ -708,10 +712,7 @@ class GradleBuildService : extraArgs.add("-P${PROPERTY_LOG_SENDER_ENABLED}=$enableLogSender") extraArgs.add("-P${PROPERTY_LOG_SENDER_AAR}=${Environment.LOGSENDER_AAR.absolutePath}") - // Early access only, like the switch itself (REVIEW.md section 12). - if (FeatureFlags.isExperimentsEnabled && BuildPreferences.relocateBuildOutput) { - extraArgs.addAll(relocatedBuildOutputArgs()) - } + extraArgs.addAll(relocatedBuildOutputArgs(isSync)) if (BuildPreferences.isStacktraceEnabled) { extraArgs.add("--stacktrace") @@ -745,17 +746,30 @@ class GradleBuildService : /** * The arguments that move the open project's build output to app-private storage (ADFA-4930), - * or none when there is no open project or its directory cannot be created - the build then - * writes to the project folder as before. Runs on the [prepareBuild] worker, so disk I/O is fine. + * or none. A sync applies the user's current choice and records it; a build follows the last + * sync's record, so it writes where the synced model (APK listing file, classpath) looks. With no + * open project, or a tree that cannot be written, the output stays in the project folder. Runs on + * the [prepareBuild] worker, so disk I/O is fine. */ - private fun relocatedBuildOutputArgs(): List { - val projectDir = ProjectManagerImpl.getInstance().projectDirPath?.let(::File) ?: return emptyList() + private fun relocatedBuildOutputArgs(isSync: Boolean): List { + val projectDir = + ProjectManagerImpl + .getInstance() + .projectDirPath + .takeIf { it.isNotBlank() } + ?.let(::File) ?: return emptyList() + val cacheDir = Environment.getProjectCacheDir(projectDir) val tree = BuildOutputLocation.treeFor(noBackupFilesDir, projectDir) - if (!BuildOutputLocation.prepare(tree, projectDir)) { - log.warn("Cannot create build output directory {}; building into the project folder", tree) - return emptyList() + + var relocate = if (isSync) BuildOutputLocation.isRequested() else BuildOutputLocation.syncedState(cacheDir) + if (relocate && !BuildOutputLocation.prepare(tree, projectDir)) { + log.warn("Cannot write build output directory {}; building into the project folder", tree) + relocate = false + } + if (isSync && !BuildOutputLocation.recordSyncedState(cacheDir, relocate)) { + log.warn("Cannot record the build output location in {}", cacheDir) } - return BuildOutputLocation.gradleArgs(tree) + return if (relocate) BuildOutputLocation.gradleArgs(tree) else emptyList() } override fun checkGradleWrapperAvailability(): CompletableFuture = diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index 95095b6c57..b1d4743013 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -37,11 +37,20 @@ class BuildOutputLocationTest { } @Test - fun `relocation overrides an earlier --watch-fs, which gradle refuses with a project cache dir`() { - // GradleBuildService puts the tuner's arguments first and these after them. - val args = listOf("--parallel", "--watch-fs") + BuildOutputLocation.gradleArgs(File(tmp.root, "tree")) + fun `watch-fs is dropped wherever it sits once the project cache is relocated`() { + val relocation = BuildOutputLocation.gradleArgs(File(tmp.root, "tree")) - assertThat(args.last { it == "--watch-fs" || it == "--no-watch-fs" }).isEqualTo("--no-watch-fs") + for (args in listOf(listOf("--parallel", "--watch-fs") + relocation, relocation + "--watch-fs")) { + assertThat(BuildOutputLocation.withoutWatchConflict(args)).doesNotContain("--watch-fs") + assertThat(BuildOutputLocation.withoutWatchConflict(args)).containsAtLeastElementsIn(relocation) + } + } + + @Test + fun `watch-fs is kept when nothing is relocated`() { + val args = listOf("--parallel", "--watch-fs") + + assertThat(BuildOutputLocation.withoutWatchConflict(args)).isEqualTo(args) } @Test @@ -72,4 +81,48 @@ class BuildOutputLocationTest { assertThat(BuildOutputLocation.prepare(File(blocker, "tree"), File(tmp.root, "app"))).isFalse() } + + @Test + fun `prepare reports a marker it cannot write instead of throwing`() { + val tree = tmp.newFolder("tree") + File(tree, BuildOutputLocation.PROJECT_PATH_FILE).mkdir() + + assertThat(BuildOutputLocation.prepare(tree, File(tmp.root, "app"))).isFalse() + } + + @Test + fun `a project never synced with relocation reads as not relocated`() { + assertThat(BuildOutputLocation.syncedState(File(tmp.root, "project/.cg"))).isFalse() + } + + @Test + fun `the synced state round-trips, creating the cache directory`() { + val cacheDir = File(tmp.root, "project/.cg") + + assertThat(BuildOutputLocation.recordSyncedState(cacheDir, true)).isTrue() + assertThat(BuildOutputLocation.syncedState(cacheDir)).isTrue() + + assertThat(BuildOutputLocation.recordSyncedState(cacheDir, false)).isTrue() + assertThat(BuildOutputLocation.syncedState(cacheDir)).isFalse() + } + + @Test + fun `recording reports a cache directory it cannot create`() { + val blocker = tmp.newFile("blocker") + + assertThat(BuildOutputLocation.recordSyncedState(File(blocker, ".cg"), true)).isFalse() + } + + @Test + fun `a sync is pending exactly when the request differs from the last sync`() { + val cacheDir = tmp.newFolder("project", ".cg") + + assertThat(BuildOutputLocation.syncPending(cacheDir, requested = false)).isFalse() + assertThat(BuildOutputLocation.syncPending(cacheDir, requested = true)).isTrue() + + BuildOutputLocation.recordSyncedState(cacheDir, true) + + assertThat(BuildOutputLocation.syncPending(cacheDir, requested = true)).isFalse() + assertThat(BuildOutputLocation.syncPending(cacheDir, requested = false)).isTrue() + } } diff --git a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md index 8c72711bbd..55e6072441 100644 --- a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md +++ b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md @@ -33,8 +33,8 @@ Measured 2026-10-14 with the IDE's Gradle arguments, same template project on bo - `` is `QuickBuildScratch.projectKey`: `-` of the normalized absolute project path. Extract it to a shared helper so both trees key a project the same way. - Inside the tree: - - `main//` - each project's build directory in the main build (`main/_root/` for the root project, `main/app/` for `:app`). - - `-//` - nested builds (`buildSrc`, `includeBuild`), keyed by their own root directory. + - `main/-/` - one flat directory per project in the main build: `` is the project's last path segment (`root` for `:`), `` a SHA-256 prefix of its full Gradle path. `main/root-/` for the root project, `main/app-/` for `:app`. Nesting `:core:ui` under `:core` would put it inside `:core`'s build directory, where `:core:clean` deletes it. + - `-/-/` - nested builds (`buildSrc`, `includeBuild`), keyed by their own root directory, with projects keyed as above. - `project-cache/` - Gradle's `.gradle/`, via `--project-cache-dir`. - `kotlin/` - Kotlin's `.kotlin/`, via `kotlin.project.persistent.dir`. Confirm the property name and the minimum Kotlin Gradle plugin version before relying on it; skip it for older versions. - `project-path` - a one-line marker holding the project's absolute path, for the orphan sweep. @@ -42,13 +42,15 @@ Measured 2026-10-14 with the IDE's Gradle arguments, same template project on bo ### How it is wired 1. **IDE side.** `GradleBuildService.getGradleExtraArgs` (`GradleBuildService.kt:697`) adds, when the feature is on: - - `-Pandroidide.build.outputDir=` + - `-Pcotg.build.outputDir=` - `--project-cache-dir /project-cache` - `-Pkotlin.project.persistent.dir=/kotlin` + - `--no-watch-fs`, and any `--watch-fs` removed: Gradle refuses `--project-cache-dir` with file-system watching on. Watching does not run on Android anyway (ADFA-6545). Sync (`RootModelBuilder.kt:207`) and builds (`ToolingApiServerImpl.kt:313`) both take their arguments from `prepareBuild`, so this one change covers both. `prepareBuild` already reads the project path (`GradleBuildService.kt:552`). 2. **Gradle side.** `AndroidIDEInitScriptPlugin.apply` (`gradle-plugin`) reads the property from `gradle.startParameter.projectProperties` and, in `beforeProject`, sets `project.layout.buildDirectory`. This runs before any build script, so AGP, Kotlin and Quick Build's plugin all see the new location. The prototype init script did exactly this (`gradle.allprojects { p -> p.layout.buildDirectory.set(...) }`), with 33 builds and no output under the project. -3. **Flag.** A `BuildPreferences` switch, default off, until validation passes. +3. **Flag.** A `BuildPreferences` switch, default off, shown only with experiments enabled (`FeatureFlags.isExperimentsEnabled`, REVIEW.md section 12) until validation passes. +4. **Sync applies it.** A sync reads the switch and records what it applied in `/.cg/build-output-relocated`; builds follow that record, not the live switch, so their output is always where the synced model says it is. When the switch and the record differ, the editor shows the sync-needed banner on resume, and opening the project syncs. ### What reads build output @@ -105,6 +107,13 @@ Feature: Build output in app storage Then the app installs and launches And the project folder contains no "build" or ".gradle" folder + Scenario: Changing the switch waits for a sync + Given an open project that was synced with relocation off + When I turn relocation on in Build preferences and return to the editor + Then the editor asks for a sync + And a build before the sync still writes to the project's "build" folder + And after the sync, builds write to app storage + Scenario: Quick Build still works Given an open project that has been built once When I edit MainActivity and run Quick Build From ebe334eca4411b7debf1a0e63f30fe428c09b2d6 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 15 Oct 2026 15:58:49 -0700 Subject: [PATCH 08/43] ADFA-4930: point ProjectStorageKey at the plugin's copy Comment only. BuildOutputRedirect already names ProjectStorageKey as the derivation it copies; this adds the pointer back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../main/java/com/itsaky/androidide/utils/ProjectStorageKey.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shared/src/main/java/com/itsaky/androidide/utils/ProjectStorageKey.kt b/shared/src/main/java/com/itsaky/androidide/utils/ProjectStorageKey.kt index 4618ae28b2..5ddf00f19b 100644 --- a/shared/src/main/java/com/itsaky/androidide/utils/ProjectStorageKey.kt +++ b/shared/src/main/java/com/itsaky/androidide/utils/ProjectStorageKey.kt @@ -9,6 +9,8 @@ import java.security.MessageDigest * function so a project maps to the same key in each. */ object ProjectStorageKey { + // gradle-plugin's BuildOutputRedirect keys build directories with its own copy of this + // derivation, because its jar stays off the IDE's modules; change the two together. private const val HASH_CHARS = 16 private const val MAX_BASENAME_CHARS = 40 From f3d750bb67381a0349d5516c7ea00eb9ab0aec53 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 15 Oct 2026 16:41:36 -0700 Subject: [PATCH 09/43] ADFA-4930: no sync banner before the project initializes Opening a project whose switch differs from its last sync showed the sync-needed banner on the first resume, before initialization had started, so the open's own sync applied the change and the banner stayed up over the toolbar, swallowing taps on Run and Quick Build. The editor now offers the sync only after the project has initialized, and a successful initialization dismisses an offer it has made stale, whichever way the sync was started. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../editor/ProjectHandlerActivity.kt | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index e53f86ea44..ae1ffa5815 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -1209,22 +1209,23 @@ abstract class ProjectHandlerActivity : quickBuildSessionManager()?.onHostForegrounded() // ADFA-4930: a changed "Relocate build output" (or experiments flag) only moves the build - // output at the next sync, so offer one, once per change. notifySyncNeeded stays quiet - // while the project is still initializing, which syncs on its own when the change is - // pending. - activityScope.launch { - val path = ProjectManagerImpl.getInstance().projectDirPath.takeIf { it.isNotBlank() } ?: return@launch - val requested = BuildOutputLocation.isRequested() - val pending = isBuildOutputSyncPending(File(path), requested) - withContext(Dispatchers.Main) { - when { - !pending -> { - buildOutputSyncOffered = null - } + // output at the next sync, so offer one, once per change. Not before the project has + // initialized: opening it syncs on its own when the change is pending. + if (initializingFuture?.isDone == true) { + activityScope.launch { + val path = ProjectManagerImpl.getInstance().projectDirPath.takeIf { it.isNotBlank() } ?: return@launch + val requested = BuildOutputLocation.isRequested() + val pending = isBuildOutputSyncPending(File(path), requested) + withContext(Dispatchers.Main) { + when { + !pending -> { + buildOutputSyncOffered = null + } - buildOutputSyncOffered != requested -> { - if (notifySyncNeeded { initializeProject(forceSync = true) }) { - buildOutputSyncOffered = requested + buildOutputSyncOffered != requested -> { + if (notifySyncNeeded { initializeProject(forceSync = true) }) { + buildOutputSyncOffered = requested + } } } } @@ -1762,6 +1763,13 @@ abstract class ProjectHandlerActivity : editorViewModel.isInitializing = false invalidateOptionsMenu() + // ADFA-4930: this initialization applied the build output switch, so a banner offering a + // sync for it is stale, whichever way the user started the sync. + if (buildOutputSyncOffered != null) { + buildOutputSyncOffered = null + syncNotificationFlashbar?.dismiss() + } + // ADFA-4128 benchmark: if the bench trampoline armed an autostart for THIS project, // claim it now - the adb-driven stand-in for the human's tap. Claimed BEFORE prebuild // so a standard-mode bench build runs alone on the daemon instead of racing the eager From d984d16a6b3cbb4705e92b285221c12a114744df Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 15 Oct 2026 17:40:54 -0700 Subject: [PATCH 10/43] ADFA-4930: builds follow the synced model, not a record file The sync wrote `.cg/build-output-relocated` in prepareBuild, before it knew whether it would succeed. A sync that failed left the old model loaded while builds followed the new record, so a build could write to one place while the IDE installed an older APK from the other, and no banner offered the retry. A failed record write gave the reverse mismatch. The synced model is now the record: a build relocates exactly when the model's root build directory is in the project's tree, so a sync that fails changes neither. The record file is gone. Also: - A build never falls back to the project folder when the tree cannot be prepared; that would leave the model pointing at older output, so Gradle reports the failure instead. - The sync offer is made after the project initializes and on resume, and withdrawn when the switch agrees with the model again, including when the user switches back without syncing. Tapping the banner's Sync forgets the offer, so a sync that fails is offered again. - Opening a project no longer forces a sync for a pending change; the model is not loaded before initialization, so it offers one after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../editor/ProjectHandlerActivity.kt | 79 +++++++++++-------- .../services/builder/BuildOutputLocation.kt | 66 ++++++++-------- .../services/builder/GradleBuildService.kt | 29 ++++--- .../builder/BuildOutputLocationTest.kt | 58 +++++++++----- ...6-10-14-adfa-4930-build-output-off-fuse.md | 2 +- 5 files changed, 134 insertions(+), 100 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index ae1ffa5815..54c76aa257 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -123,7 +123,6 @@ import com.itsaky.androidide.utils.DURATION_INDEFINITE import com.itsaky.androidide.utils.DURATION_LONG import com.itsaky.androidide.utils.DialogUtils.newMaterialDialogBuilder import com.itsaky.androidide.utils.DialogUtils.showRestartPrompt -import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FeatureFlags import com.itsaky.androidide.utils.ProjectSearchOptions import com.itsaky.androidide.utils.RecursiveFileSearcher @@ -187,6 +186,9 @@ abstract class ProjectHandlerActivity : /** The relocation request the sync banner last offered, so Ignore holds until it changes. */ private var buildOutputSyncOffered: Boolean? = null + + /** The banner that offer showed, so withdrawing it never dismisses another one. */ + private var buildOutputSyncBanner: Flashbar? = null private var replaceReportFlashbar: Flashbar? = null private val buildViewModel by viewModels() @@ -1208,41 +1210,53 @@ abstract class ProjectHandlerActivity : // parked awaiting that retry (auto-retries are bounded by the reducer). quickBuildSessionManager()?.onHostForegrounded() - // ADFA-4930: a changed "Relocate build output" (or experiments flag) only moves the build - // output at the next sync, so offer one, once per change. Not before the project has - // initialized: opening it syncs on its own when the change is pending. + // Before the first initialization there is no synced model to compare the switch with. if (initializingFuture?.isDone == true) { - activityScope.launch { - val path = ProjectManagerImpl.getInstance().projectDirPath.takeIf { it.isNotBlank() } ?: return@launch - val requested = BuildOutputLocation.isRequested() - val pending = isBuildOutputSyncPending(File(path), requested) - withContext(Dispatchers.Main) { - when { - !pending -> { - buildOutputSyncOffered = null - } + reconcileBuildOutputSyncOffer(afterInit = false) + } - buildOutputSyncOffered != requested -> { - if (notifySyncNeeded { initializeProject(forceSync = true) }) { - buildOutputSyncOffered = requested - } - } + invalidateOptionsMenu() + } + + /** + * ADFA-4930: a changed "Relocate build output" (or experiments flag) only moves the build output + * at the next sync, so offer one, once per change, and withdraw the offer once the synced model + * agrees with the switch again. + * + * @param afterInit whether a project initialization just finished. It applied any Gradle file + * changes too, so the banner can go even when it also stands for those. + */ + private fun reconcileBuildOutputSyncOffer(afterInit: Boolean) { + activityScope.launch { + val path = ProjectManagerImpl.getInstance().projectDirPath.takeIf { it.isNotBlank() } ?: return@launch + val requested = BuildOutputLocation.isRequested() + val pending = + BuildOutputLocation.syncPending( + BuildOutputLocation.modelRelocated(noBackupFilesDir, File(path)), + requested, + ) + withContext(Dispatchers.Main) { + if (!pending) { + val banner = buildOutputSyncBanner + if (banner != null && banner === syncNotificationFlashbar && (afterInit || !editorViewModel.isSyncNeeded)) { + banner.dismiss() } + buildOutputSyncBanner = null + buildOutputSyncOffered = null + } else if (buildOutputSyncOffered != requested && notifySyncNeeded(::syncForBuildOutput)) { + buildOutputSyncOffered = requested + buildOutputSyncBanner = syncNotificationFlashbar } } } - - invalidateOptionsMenu() } - /** Reads the project's cache directory, so it runs on [Dispatchers.IO]. */ - private suspend fun isBuildOutputSyncPending( - projectDir: File, - requested: Boolean = BuildOutputLocation.isRequested(), - ): Boolean = - withContext(Dispatchers.IO) { - BuildOutputLocation.syncPending(Environment.getProjectCacheDir(projectDir), requested) - } + /** Forgets the offer, so a sync that fails leaves it to be made again on the next resume. */ + private fun syncForBuildOutput() { + buildOutputSyncOffered = null + buildOutputSyncBanner = null + initializeProject(forceSync = true) + } override fun preDestroy() { syncNotificationFlashbar?.dismiss() @@ -1461,7 +1475,7 @@ abstract class ProjectHandlerActivity : val needsSync = try { - forceSync || manager.isGradleSyncNeeded(projectDir) || isBuildOutputSyncPending(projectDir) + forceSync || manager.isGradleSyncNeeded(projectDir) } catch (e: Exception) { when (e) { is FileNotFoundException -> { @@ -1763,12 +1777,7 @@ abstract class ProjectHandlerActivity : editorViewModel.isInitializing = false invalidateOptionsMenu() - // ADFA-4930: this initialization applied the build output switch, so a banner offering a - // sync for it is stale, whichever way the user started the sync. - if (buildOutputSyncOffered != null) { - buildOutputSyncOffered = null - syncNotificationFlashbar?.dismiss() - } + reconcileBuildOutputSyncOffer(afterInit = true) // ADFA-4128 benchmark: if the bench trampoline armed an autostart for THIS project, // claim it now - the adb-driven stand-in for the human's tap. Claimed BEFORE prebuild diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index 6bde569d4d..6070fb3d1e 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -1,6 +1,9 @@ package com.itsaky.androidide.services.builder import com.itsaky.androidide.preferences.internal.BuildPreferences +import com.itsaky.androidide.projects.ProjectManagerImpl +import com.itsaky.androidide.projects.api.Workspace +import com.itsaky.androidide.projects.models.buildDir import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR import com.itsaky.androidide.utils.FeatureFlags import com.itsaky.androidide.utils.ProjectStorageKey @@ -12,9 +15,11 @@ import java.io.IOException * (ADFA-4930): `/gradle-build//`, beside Quick Build's scratch and * keyed the same way. App-private f2fs makes warm builds 24-35% faster on the devices measured. * - * A sync applies the user's choice and records it in the project's cache directory; builds follow - * that record, not the live switch, so their output is always where the synced model says it is. - * A change to the switch therefore waits for the next sync ([syncPending]). + * The synced project model is the record of where output goes. A sync applies the user's choice; + * a build follows the model's root build directory ([modelRelocated]), not the live switch, so it + * writes where the model (APK listing file, classpath) looks, and a sync that fails leaves both + * as they were. A change to the switch therefore waits for the next successful sync + * ([syncPending]). */ object BuildOutputLocation { /** Parent of every project's tree, under `noBackupFilesDir`. */ @@ -23,9 +28,6 @@ object BuildOutputLocation { /** Holds the project's path, so a sweep can find trees whose project is gone. */ const val PROJECT_PATH_FILE = "project-path" - /** Present in a project's cache directory when its last sync relocated build output. */ - const val SYNCED_STATE_FILE = "build-output-relocated" - private const val KOTLIN_PERSISTENT_DIR = "kotlin.project.persistent.dir" private const val WATCH_FS = "--watch-fs" private const val NO_WATCH_FS = "--no-watch-fs" @@ -76,8 +78,8 @@ object BuildOutputLocation { /** * Creates [tree] and records [projectDir] in it. * - * @return false when [tree] or its marker cannot be written; the build then keeps its output - * in the project folder. + * @return false when [tree] or its marker cannot be written; a sync then keeps output in the + * project folder. */ fun prepare( tree: File, @@ -95,40 +97,38 @@ object BuildOutputLocation { } /** - * Whether the project's last sync relocated its build output. False when nothing was recorded: - * every sync before this feature built into the project folder. + * Whether a synced model's build directory lies in [tree], meaning the sync that produced the + * model relocated build output. * - * @param projectCacheDir the project's cache directory (`/.cg`). + * @param modelBuildDir the root project's build directory in the synced model. + * @param tree the project's tree, from [treeFor]. */ - fun syncedState(projectCacheDir: File): Boolean = File(projectCacheDir, SYNCED_STATE_FILE).isFile + fun isRelocated( + modelBuildDir: File, + tree: File, + ): Boolean = modelBuildDir.absoluteFile.normalize().startsWith(tree.absoluteFile.normalize()) /** - * Records what a sync of the project applied. + * [isRelocated] for the synced model of the open project, or null when no model is loaded. * - * @return false when the record cannot be written. + * @param noBackupDir the app's `noBackupFilesDir`. + * @param projectDir the open project's root directory. + * @param workspace the synced model of the open project. */ - fun recordSyncedState( - projectCacheDir: File, - relocated: Boolean, - ): Boolean { - val file = File(projectCacheDir, SYNCED_STATE_FILE) - return try { - if (relocated) { - (projectCacheDir.isDirectory || projectCacheDir.mkdirs()) && (file.isFile || file.createNewFile()) - } else { - !file.exists() || file.delete() - } - } catch (e: IOException) { - false - } - } + fun modelRelocated( + noBackupDir: File, + projectDir: File, + workspace: Workspace? = ProjectManagerImpl.getInstance().workspace, + ): Boolean? = workspace?.let { isRelocated(it.rootProject.buildDir, treeFor(noBackupDir, projectDir)) } /** - * Whether the switch (or the experiments flag) has changed since the project's last sync, so the - * next sync would move the build output. + * Whether the switch (or the experiments flag) disagrees with the synced model, so the next + * sync would move the build output. False without a model: there is nothing to disagree with. + * + * @param modelRelocated [modelRelocated] for the open project. */ fun syncPending( - projectCacheDir: File, + modelRelocated: Boolean?, requested: Boolean = isRequested(), - ): Boolean = syncedState(projectCacheDir) != requested + ): Boolean = modelRelocated != null && modelRelocated != requested } diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index eefedbe86a..9188294e05 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -746,10 +746,11 @@ class GradleBuildService : /** * The arguments that move the open project's build output to app-private storage (ADFA-4930), - * or none. A sync applies the user's current choice and records it; a build follows the last - * sync's record, so it writes where the synced model (APK listing file, classpath) looks. With no - * open project, or a tree that cannot be written, the output stays in the project folder. Runs on - * the [prepareBuild] worker, so disk I/O is fine. + * or none. A sync applies the user's current choice, keeping output in the project folder when + * the tree cannot be written; the model it produces records the outcome. A build follows that + * model, so it writes where the model (APK listing file, classpath) looks, and never falls back + * to the project folder, which would leave the model pointing at older output. Runs on the + * [prepareBuild] worker, so disk I/O is fine. */ private fun relocatedBuildOutputArgs(isSync: Boolean): List { val projectDir = @@ -758,18 +759,22 @@ class GradleBuildService : .projectDirPath .takeIf { it.isNotBlank() } ?.let(::File) ?: return emptyList() - val cacheDir = Environment.getProjectCacheDir(projectDir) val tree = BuildOutputLocation.treeFor(noBackupFilesDir, projectDir) - var relocate = if (isSync) BuildOutputLocation.isRequested() else BuildOutputLocation.syncedState(cacheDir) - if (relocate && !BuildOutputLocation.prepare(tree, projectDir)) { - log.warn("Cannot write build output directory {}; building into the project folder", tree) - relocate = false + if (!isSync) { + if (BuildOutputLocation.modelRelocated(noBackupFilesDir, projectDir) != true) return emptyList() + if (!BuildOutputLocation.prepare(tree, projectDir)) { + log.warn("Cannot write build output directory {}; Gradle will report it", tree) + } + return BuildOutputLocation.gradleArgs(tree) } - if (isSync && !BuildOutputLocation.recordSyncedState(cacheDir, relocate)) { - log.warn("Cannot record the build output location in {}", cacheDir) + + if (!BuildOutputLocation.isRequested()) return emptyList() + if (!BuildOutputLocation.prepare(tree, projectDir)) { + log.warn("Cannot write build output directory {}; syncing with output in the project folder", tree) + return emptyList() } - return if (relocate) BuildOutputLocation.gradleArgs(tree) else emptyList() + return BuildOutputLocation.gradleArgs(tree) } override fun checkGradleWrapperAvailability(): CompletableFuture = diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index b1d4743013..372942c0d5 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -1,8 +1,12 @@ package com.itsaky.androidide.services.builder import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.projects.api.GradleProject +import com.itsaky.androidide.projects.api.Workspace import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR import com.itsaky.androidide.utils.ProjectStorageKey +import io.mockk.every +import io.mockk.mockk import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -91,38 +95,54 @@ class BuildOutputLocationTest { } @Test - fun `a project never synced with relocation reads as not relocated`() { - assertThat(BuildOutputLocation.syncedState(File(tmp.root, "project/.cg"))).isFalse() + fun `a model whose build directory is in the tree is relocated`() { + val tree = File(tmp.root, "no_backup/gradle-build/app-1234") + + assertThat(BuildOutputLocation.isRelocated(File(tree, "main/root-5678"), tree)).isTrue() } @Test - fun `the synced state round-trips, creating the cache directory`() { - val cacheDir = File(tmp.root, "project/.cg") - - assertThat(BuildOutputLocation.recordSyncedState(cacheDir, true)).isTrue() - assertThat(BuildOutputLocation.syncedState(cacheDir)).isTrue() + fun `a model building into the project folder is not relocated`() { + val tree = File(tmp.root, "no_backup/gradle-build/app-1234") - assertThat(BuildOutputLocation.recordSyncedState(cacheDir, false)).isTrue() - assertThat(BuildOutputLocation.syncedState(cacheDir)).isFalse() + assertThat(BuildOutputLocation.isRelocated(File(tmp.root, "projects/app/build"), tree)).isFalse() } @Test - fun `recording reports a cache directory it cannot create`() { - val blocker = tmp.newFile("blocker") + fun `a sibling tree that shares the name as a prefix is not this project's`() { + val tree = File(tmp.root, "no_backup/gradle-build/app-1234") + val sibling = File(tmp.root, "no_backup/gradle-build/app-12345/main/root-5678") - assertThat(BuildOutputLocation.recordSyncedState(File(blocker, ".cg"), true)).isFalse() + assertThat(BuildOutputLocation.isRelocated(sibling, tree)).isFalse() } @Test - fun `a sync is pending exactly when the request differs from the last sync`() { - val cacheDir = tmp.newFolder("project", ".cg") + fun `the open project's model is read through its root project's build directory`() { + val noBackup = tmp.newFolder("no_backup") + val project = File(tmp.root, "projects/app") + val tree = BuildOutputLocation.treeFor(noBackup, project) - assertThat(BuildOutputLocation.syncPending(cacheDir, requested = false)).isFalse() - assertThat(BuildOutputLocation.syncPending(cacheDir, requested = true)).isTrue() + assertThat(BuildOutputLocation.modelRelocated(noBackup, project, workspace(File(tree, "main/root-5678")))).isTrue() + assertThat(BuildOutputLocation.modelRelocated(noBackup, project, workspace(File(project, "build")))).isFalse() + assertThat(BuildOutputLocation.modelRelocated(noBackup, project, workspace = null)).isNull() + } + + @Test + fun `a sync is pending exactly when the request differs from the synced model`() { + assertThat(BuildOutputLocation.syncPending(modelRelocated = false, requested = false)).isFalse() + assertThat(BuildOutputLocation.syncPending(modelRelocated = false, requested = true)).isTrue() + assertThat(BuildOutputLocation.syncPending(modelRelocated = true, requested = true)).isFalse() + assertThat(BuildOutputLocation.syncPending(modelRelocated = true, requested = false)).isTrue() + } - BuildOutputLocation.recordSyncedState(cacheDir, true) + @Test + fun `nothing is pending before a model is loaded`() { + assertThat(BuildOutputLocation.syncPending(modelRelocated = null, requested = true)).isFalse() + } - assertThat(BuildOutputLocation.syncPending(cacheDir, requested = true)).isFalse() - assertThat(BuildOutputLocation.syncPending(cacheDir, requested = false)).isTrue() + private fun workspace(rootBuildDir: File): Workspace { + val root = mockk() + every { root.buildDirPath } returns rootBuildDir.path + return mockk().also { every { it.rootProject } returns root } } } diff --git a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md index 55e6072441..f966a1091d 100644 --- a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md +++ b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md @@ -50,7 +50,7 @@ Measured 2026-10-14 with the IDE's Gradle arguments, same template project on bo Sync (`RootModelBuilder.kt:207`) and builds (`ToolingApiServerImpl.kt:313`) both take their arguments from `prepareBuild`, so this one change covers both. `prepareBuild` already reads the project path (`GradleBuildService.kt:552`). 2. **Gradle side.** `AndroidIDEInitScriptPlugin.apply` (`gradle-plugin`) reads the property from `gradle.startParameter.projectProperties` and, in `beforeProject`, sets `project.layout.buildDirectory`. This runs before any build script, so AGP, Kotlin and Quick Build's plugin all see the new location. The prototype init script did exactly this (`gradle.allprojects { p -> p.layout.buildDirectory.set(...) }`), with 33 builds and no output under the project. 3. **Flag.** A `BuildPreferences` switch, default off, shown only with experiments enabled (`FeatureFlags.isExperimentsEnabled`, REVIEW.md section 12) until validation passes. -4. **Sync applies it.** A sync reads the switch and records what it applied in `/.cg/build-output-relocated`; builds follow that record, not the live switch, so their output is always where the synced model says it is. When the switch and the record differ, the editor shows the sync-needed banner on resume, and opening the project syncs. +4. **Sync applies it.** A sync reads the switch; the model it produces is the record. A build relocates exactly when the model's root build directory is in the tree, so its output is always where the model (APK listing file, classpath) looks, and a failed sync changes neither. When the switch and the model disagree, the editor offers a sync after the project initializes and on resume, once per change, and withdraws the offer when they agree again. ### What reads build output From e703393b77bf9c992b88c7bf7bc54cb152fef7ed Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 15 Oct 2026 16:41:36 -0700 Subject: [PATCH 11/43] ADFA-4930: find Quick Build and plugin output via the project model Quick Build's setup.json and a plugin project's .cgp were looked up under `/build`. With relocated build output that path holds nothing, or a stale report from before the relocation, which Quick Build would then read. Both now resolve against the build directory the synced model gives: the module's for setup.json, the root project's for the .cgp. The model and the output agree because builds follow the last sync (PR 1). `QuickBuildTaskPaths.setupJson` is now relative to the build directory, and the debugging doc lists the relocated location. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../quickbuild/GradleQuickBuildProvisioner.kt | 36 +++++------ .../quickbuild/QuickBuildTaskPaths.kt | 11 ++-- .../androidide/viewmodel/BuildViewModel.kt | 20 ++++-- ...GradleQuickBuildProvisionerBuildDirTest.kt | 61 +++++++++++++++++++ .../GradleQuickBuildProvisionerFakes.kt | 15 +++-- .../quickbuild/QuickBuildTaskPathsTest.kt | 10 +-- quickbuild/docs/debugging.md | 3 +- 7 files changed, 113 insertions(+), 43 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerBuildDirTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt index 11fc7691fe..2218c574bf 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt @@ -7,6 +7,7 @@ import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.api.AndroidModule import com.itsaky.androidide.projects.builder.BuildService import com.itsaky.androidide.projects.isPluginProject +import com.itsaky.androidide.projects.models.buildDir import com.itsaky.androidide.resources.R import com.itsaky.androidide.services.builder.GradleBuildService import com.itsaky.androidide.tooling.api.GradlePluginConfig @@ -459,28 +460,19 @@ class GradleQuickBuildProvisioner( // Variant-scoped, matching where the Gradle plugin writes it: one report per // debuggable variant, so a flavored project has several and only this variant's is - // the built app. - val reportPath = QuickBuildTaskPaths.setupJson(variantName) - val reportFile = - sequenceOf( - File(moduleDir, reportPath), - File(projectRoot, reportPath), - ).firstOrNull { it.isFile } - ?: run { - log.error( - "{} not found under {} or {} after the proxy app build", - reportPath, - moduleDir, - projectRoot, - ) - // The build succeeded but wrote no Quick Build setup, which all but - // names the cause: the plugin only configures DEBUGGABLE variants, and - // the release-name check above only catches AGP's own release build - // type. Say so instead of the generic "setup failed". - return ProxyAppBuildResult.Failed( - context.getString(R.string.quick_build_variant_setup_missing, variantName), - ) - } + // the built app. Under the build directory the synced model gives the module, which + // relocated build output (ADFA-4930) moves out of the project folder. + val reportFile = File(module.buildDir, QuickBuildTaskPaths.setupJson(variantName)) + if (!reportFile.isFile) { + log.error("{} not found after the proxy app build", reportFile) + // The build succeeded but wrote no Quick Build setup, which all but names the + // cause: the plugin only configures DEBUGGABLE variants, and the release-name + // check above only catches AGP's own release build type. Say so instead of the + // generic "setup failed". + return ProxyAppBuildResult.Failed( + context.getString(R.string.quick_build_variant_setup_missing, variantName), + ) + } val proxyApp = ProxyAppInfo.parse(reportFile.readText(), projectRoot) diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt index e281c677da..0a629f8fab 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt @@ -36,10 +36,11 @@ object QuickBuildTaskPaths { } /** - * Where the Gradle plugin writes that variant's proxy app report, relative to the - * directory owning the `build/` dir - the other half of the same contract, kept next to - * the task name so the two cannot drift apart. Variant-scoped like every other Quick - * Build output: a flavored project has one report per debuggable variant. + * Where the Gradle plugin writes that variant's proxy app report, relative to the module's + * build directory - the other half of the same contract, kept next to the task name so the + * two cannot drift apart. Variant-scoped like every other Quick Build output: a flavored + * project has one report per debuggable variant. Resolve it against the build directory in + * the project model: relocated build output (ADFA-4930) is not under `/build`. */ - fun setupJson(variantName: String = DEFAULT_VARIANT): String = "build/quickbuild/${variantName.ifBlank { DEFAULT_VARIANT }}/setup.json" + fun setupJson(variantName: String = DEFAULT_VARIANT): String = "quickbuild/${variantName.ifBlank { DEFAULT_VARIANT }}/setup.json" } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt index 87487e4420..de3770ea0d 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt @@ -14,6 +14,7 @@ import com.itsaky.androidide.projects.api.AndroidModule import com.itsaky.androidide.projects.builder.BuildService import com.itsaky.androidide.projects.isPluginProject import com.itsaky.androidide.projects.models.assembleTaskOutputListingFile +import com.itsaky.androidide.projects.models.buildDir import com.itsaky.androidide.resources.R import com.itsaky.androidide.tooling.api.messages.BuildRunType import com.itsaky.androidide.tooling.api.messages.GradleBuildParams @@ -147,15 +148,22 @@ class BuildViewModel( } if (isPluginProject) { - val projectRoot = IProjectManager.getInstance().projectDirPath + // The root project's build directory as the synced model has it, which + // relocated build output (ADFA-4930) moves out of the project folder. + val rootBuildDir = + IProjectManager + .getInstance() + .workspace + ?.rootProject + ?.buildDir val cgpFile = - withContext(Dispatchers.IO) { findPluginCgpFile(projectRoot, variant) } + rootBuildDir?.let { withContext(Dispatchers.IO) { findPluginCgpFile(it, variant) } } if (cgpFile != null) { reporter.finish(BuildState.AwaitingPluginInstall(cgpFile)) } else { - log.warn("Plugin built successfully but .cgp file not found") + log.warn("Plugin built successfully but no .cgp file under {}/plugin", rootBuildDir) reporter.finish( - BuildState.Error("Plugin built but output file (.cgp) not found in build/plugin"), + BuildState.Error("Plugin built but output file (.cgp) not found"), ) } return@launch @@ -321,10 +329,10 @@ class BuildViewModel( } private fun findPluginCgpFile( - projectRoot: String, + rootBuildDir: File, variant: AndroidModels.AndroidVariant, ): File? { - val pluginDir = File(projectRoot, "build/plugin") + val pluginDir = File(rootBuildDir, "plugin") if (!pluginDir.exists()) return null val isDebug = variant.name.contains("debug", ignoreCase = true) diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerBuildDirTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerBuildDirTest.kt new file mode 100644 index 0000000000..f1d328c60a --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerBuildDirTest.kt @@ -0,0 +1,61 @@ +package com.itsaky.androidide.quickbuild + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.service.provision.ProvisionOutcome +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +/** + * The proxy app report is read from the module's build directory as the synced project model + * gives it. With build output relocated off the project folder (ADFA-4930) that directory is + * not `/build`, which may still hold a report from before the relocation. + */ +@RunWith(RobolectricTestRunner::class) +class GradleQuickBuildProvisionerBuildDirTest { + @get:Rule + val temp = TemporaryFolder() + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + private val projectRoot: File get() = File(temp.root, "project") + + private val moduleDir: File get() = File(projectRoot, "app").apply { mkdirs() } + + private val relocatedBuildDir: File get() = File(temp.root, "no_backup/gradle-build/project-1234/main/app-5678") + + @Test + fun `a report under a relocated build directory is found`() = + runTest { + writeSetupJson(moduleDir, buildDir = relocatedBuildDir) + + val outcome = provisioner().provision() + + assertThat(outcome).isInstanceOf(ProvisionOutcome.Success::class.java) + } + + @Test + fun `a stale report left in the project folder is not read`() = + runTest { + writeSetupJson(moduleDir, entryActivity = "com.example.demo.StaleActivity") + writeSetupJson(moduleDir, buildDir = relocatedBuildDir) + + val outcome = provisioner().provision() + + assertThat((outcome as ProvisionOutcome.Success).proxyApp.entryActivity) + .isEqualTo("com.example.demo.MainActivity") + } + + private fun provisioner(): GradleQuickBuildProvisioner = + testProvisioner( + context, + projectRoot, + projectManager = FakeProjectManager(projectRoot, appModules = listOf(androidModule(buildDir = relocatedBuildDir))), + ) +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFakes.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFakes.kt index d38c83182f..4c1bfc770a 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFakes.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFakes.kt @@ -143,13 +143,16 @@ internal fun unsyncedProjectManager(projectRoot: File): FakeProjectManager = Fak * @param path the module's Gradle path. * @param variantName the selected variant, or null for the mid-sync read the provisioner * falls back to the default variant on. + * @param buildDir the module's build directory as the synced model reports it. */ internal fun androidModule( path: String = ":app", variantName: String? = "debug", + buildDir: File = File("app", "build"), ): AndroidModule = mockk().also { module -> every { module.path } returns path + every { module.buildDirPath } returns buildDir.path every { module.getSelectedVariant() } returns variantName?.let { AndroidModels.AndroidVariant @@ -208,17 +211,20 @@ internal const val REAL_APPLICATION_ID = "com.example.demo" /** * Writes the report the Gradle plugin would have written for a successful proxy app build. * - * @param moduleDir the module whose `build/` dir owns the report. + * @param moduleDir the module whose `build/` dir owns the report, as [testProvisioner]'s default + * module reports it. * @param variantName the variant the report belongs to; reports are variant-scoped. * @param entryActivity the launcher activity, or null for the No-Activity template, which is a * successful build with nothing to launch. + * @param buildDir the build directory the report goes under. */ internal fun writeSetupJson( moduleDir: File, variantName: String = "debug", entryActivity: String? = "com.example.demo.MainActivity", + buildDir: File = File(moduleDir, "build"), ) { - val report = File(moduleDir, QuickBuildTaskPaths.setupJson(variantName)) + val report = File(buildDir, QuickBuildTaskPaths.setupJson(variantName)) report.parentFile.mkdirs() val apkPath = "app/build/outputs/apk/$variantName/app-$variantName.apk" report.writeText( @@ -235,7 +241,7 @@ internal fun writeUnparseableSetupJson( moduleDir: File, variantName: String = "debug", ) { - val report = File(moduleDir, QuickBuildTaskPaths.setupJson(variantName)) + val report = File(moduleDir, "build/" + QuickBuildTaskPaths.setupJson(variantName)) report.parentFile.mkdirs() // A well-formed JSON object with no proxyAppId: parseable as JSON, unusable as a report, // which is the shape a schema drift actually produces. @@ -250,7 +256,8 @@ internal fun testProvisioner( context: Context, projectRoot: File, buildService: BuildService? = FakeBuildService(), - projectManager: IProjectManager = FakeProjectManager(projectRoot, appModules = listOf(androidModule())), + projectManager: IProjectManager = + FakeProjectManager(projectRoot, appModules = listOf(androidModule(buildDir = File(projectRoot, "app/build")))), installer: ProxyAppInstaller = recordingInstaller(), packages: InstalledPackages = FakePackages(), apkCertSha256: (File) -> String? = { null }, diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt index 3408e4af87..418d2d89d2 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt @@ -73,18 +73,18 @@ class QuickBuildTaskPathsTest { @Test fun `the report path is variant-scoped, matching where the Gradle plugin writes it`() { - // Both halves of the plugin contract: `build/quickbuild//setup.json`. A + // Both halves of the plugin contract: `/quickbuild//setup.json`. A // flavor-agnostic path here would read another flavor's report - the wrong APK and // the wrong applicationId. assertThat(QuickBuildTaskPaths.setupJson("debug")) - .isEqualTo("build/quickbuild/debug/setup.json") + .isEqualTo("quickbuild/debug/setup.json") assertThat(QuickBuildTaskPaths.setupJson("demoDebug")) - .isEqualTo("build/quickbuild/demoDebug/setup.json") + .isEqualTo("quickbuild/demoDebug/setup.json") } @Test fun `a blank variant reads the default variant's report`() { - assertThat(QuickBuildTaskPaths.setupJson("")).isEqualTo("build/quickbuild/debug/setup.json") - assertThat(QuickBuildTaskPaths.setupJson()).isEqualTo("build/quickbuild/debug/setup.json") + assertThat(QuickBuildTaskPaths.setupJson("")).isEqualTo("quickbuild/debug/setup.json") + assertThat(QuickBuildTaskPaths.setupJson()).isEqualTo("quickbuild/debug/setup.json") } } diff --git a/quickbuild/docs/debugging.md b/quickbuild/docs/debugging.md index 95c13dcbb2..4c13460a1f 100644 --- a/quickbuild/docs/debugging.md +++ b/quickbuild/docs/debugging.md @@ -184,7 +184,8 @@ to CoGo. | `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 | | `/.androidide/quickbuild/generation` | the generation counter | plain `adb shell cat` | -| `//build/quickbuild/setup.json` | the proxy app build's handshake with CoGo | plain `adb shell cat` | +| `//build/quickbuild//setup.json` | the proxy app build's handshake with CoGo | plain `adb shell cat` | +| `no_backup/gradle-build/-<16 hex>/main/-<16 hex>/quickbuild//setup.json` | the same, when "Relocate build output" is on (ADFA-4930) | `run-as com.itsaky.androidide` | | `/data/data//files/quickbuild/payload/` | persisted payload: `payload.dex`, `resources.arsc`, `assets.zip`, `meta.json` | `run-as ` | Projects live under `/storage/emulated/0/CodeOnTheGoProjects/`. From b0648b8a503234e0b651c07fb6095c63bba9231a Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 15 Oct 2026 17:55:18 -0700 Subject: [PATCH 12/43] ADFA-4930: pin the .cgp lookup and pass setupJson its build directory From the #2150 review: - The plugin .cgp lookup moves into `findPluginCgpFile(workspace, variantName)`, so a test can pin it to the model's root build directory. `PluginCgpLookupTest` fails against the old `/build/plugin` lookup. - `QuickBuildTaskPaths.setupJson` takes the build directory and returns a File, so a caller cannot resolve it against the module folder by mistake. - The fake module's default build directory is absolute, so it never resolves against the test JVM's working directory. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../quickbuild/GradleQuickBuildProvisioner.kt | 2 +- .../quickbuild/QuickBuildTaskPaths.kt | 19 ++++-- .../androidide/viewmodel/BuildViewModel.kt | 48 +++++++-------- .../GradleQuickBuildProvisionerFakes.kt | 6 +- .../quickbuild/QuickBuildTaskPathsTest.kt | 15 +++-- .../viewmodel/PluginCgpLookupTest.kt | 60 +++++++++++++++++++ 6 files changed, 110 insertions(+), 40 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/viewmodel/PluginCgpLookupTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt index 2218c574bf..5822ff1265 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt @@ -462,7 +462,7 @@ class GradleQuickBuildProvisioner( // debuggable variant, so a flavored project has several and only this variant's is // the built app. Under the build directory the synced model gives the module, which // relocated build output (ADFA-4930) moves out of the project folder. - val reportFile = File(module.buildDir, QuickBuildTaskPaths.setupJson(variantName)) + val reportFile = QuickBuildTaskPaths.setupJson(module.buildDir, variantName) if (!reportFile.isFile) { log.error("{} not found after the proxy app build", reportFile) // The build succeeded but wrote no Quick Build setup, which all but names the diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt index 0a629f8fab..2b0bc3d9a7 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt @@ -1,5 +1,7 @@ package com.itsaky.androidide.quickbuild +import java.io.File + /** * Composes the Gradle task path for the quick-build proxy app build's `assemble` task from * a module's Gradle project path and the variant CoGo has selected. @@ -36,11 +38,16 @@ object QuickBuildTaskPaths { } /** - * Where the Gradle plugin writes that variant's proxy app report, relative to the module's - * build directory - the other half of the same contract, kept next to the task name so the - * two cannot drift apart. Variant-scoped like every other Quick Build output: a flavored - * project has one report per debuggable variant. Resolve it against the build directory in - * the project model: relocated build output (ADFA-4930) is not under `/build`. + * Where the Gradle plugin writes that variant's proxy app report - the other half of the same + * contract, kept next to the task name so the two cannot drift apart. Variant-scoped like + * every other Quick Build output: a flavored project has one report per debuggable variant. + * + * @param buildDir the module's build directory as the project model has it; relocated build + * output (ADFA-4930) is not under `/build`. + * @param variantName the variant; blank falls back to [DEFAULT_VARIANT]. */ - fun setupJson(variantName: String = DEFAULT_VARIANT): String = "quickbuild/${variantName.ifBlank { DEFAULT_VARIANT }}/setup.json" + fun setupJson( + buildDir: File, + variantName: String = DEFAULT_VARIANT, + ): File = File(buildDir, "quickbuild/${variantName.ifBlank { DEFAULT_VARIANT }}/setup.json") } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt index de3770ea0d..99b3209b4f 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt @@ -11,6 +11,7 @@ import com.itsaky.androidide.models.installTaskRequestsIn import com.itsaky.androidide.project.AndroidModels import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.api.AndroidModule +import com.itsaky.androidide.projects.api.Workspace import com.itsaky.androidide.projects.builder.BuildService import com.itsaky.androidide.projects.isPluginProject import com.itsaky.androidide.projects.models.assembleTaskOutputListingFile @@ -148,20 +149,12 @@ class BuildViewModel( } if (isPluginProject) { - // The root project's build directory as the synced model has it, which - // relocated build output (ADFA-4930) moves out of the project folder. - val rootBuildDir = - IProjectManager - .getInstance() - .workspace - ?.rootProject - ?.buildDir - val cgpFile = - rootBuildDir?.let { withContext(Dispatchers.IO) { findPluginCgpFile(it, variant) } } + val workspace = IProjectManager.getInstance().workspace + val cgpFile = withContext(Dispatchers.IO) { findPluginCgpFile(workspace, variant.name) } if (cgpFile != null) { reporter.finish(BuildState.AwaitingPluginInstall(cgpFile)) } else { - log.warn("Plugin built successfully but no .cgp file under {}/plugin", rootBuildDir) + log.warn("Plugin built successfully but no .cgp file under {}/plugin", workspace?.rootProject?.buildDir) reporter.finish( BuildState.Error("Plugin built but output file (.cgp) not found"), ) @@ -327,18 +320,25 @@ class BuildViewModel( _buildState.value = BuildState.Idle } } +} - private fun findPluginCgpFile( - rootBuildDir: File, - variant: AndroidModels.AndroidVariant, - ): File? { - val pluginDir = File(rootBuildDir, "plugin") - if (!pluginDir.exists()) return null - - val isDebug = variant.name.contains("debug", ignoreCase = true) - return pluginDir - .listFiles { file -> file.extension.equals(PLUGIN_ARCHIVE_EXTENSION, ignoreCase = true) } - ?.filter { it.name.contains("-debug") == isDebug } - ?.maxByOrNull { it.lastModified() } - } +/** + * The newest `.cgp` a plugin build produced for [variantName], under the root project's build + * directory as the synced model has it: relocated build output (ADFA-4930) is not under + * `/build`, which may still hold a plugin from before the relocation. + * + * @return null when there is no model or no matching `.cgp`. + */ +internal fun findPluginCgpFile( + workspace: Workspace?, + variantName: String, +): File? { + val pluginDir = File(workspace?.rootProject?.buildDir ?: return null, "plugin") + if (!pluginDir.exists()) return null + + val isDebug = variantName.contains("debug", ignoreCase = true) + return pluginDir + .listFiles { file -> file.extension.equals(PLUGIN_ARCHIVE_EXTENSION, ignoreCase = true) } + ?.filter { it.name.contains("-debug") == isDebug } + ?.maxByOrNull { it.lastModified() } } diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFakes.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFakes.kt index 4c1bfc770a..b46b121d99 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFakes.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFakes.kt @@ -148,7 +148,7 @@ internal fun unsyncedProjectManager(projectRoot: File): FakeProjectManager = Fak internal fun androidModule( path: String = ":app", variantName: String? = "debug", - buildDir: File = File("app", "build"), + buildDir: File = File("/fake-project/app/build"), ): AndroidModule = mockk().also { module -> every { module.path } returns path @@ -224,7 +224,7 @@ internal fun writeSetupJson( entryActivity: String? = "com.example.demo.MainActivity", buildDir: File = File(moduleDir, "build"), ) { - val report = File(buildDir, QuickBuildTaskPaths.setupJson(variantName)) + val report = QuickBuildTaskPaths.setupJson(buildDir, variantName) report.parentFile.mkdirs() val apkPath = "app/build/outputs/apk/$variantName/app-$variantName.apk" report.writeText( @@ -241,7 +241,7 @@ internal fun writeUnparseableSetupJson( moduleDir: File, variantName: String = "debug", ) { - val report = File(moduleDir, "build/" + QuickBuildTaskPaths.setupJson(variantName)) + val report = QuickBuildTaskPaths.setupJson(File(moduleDir, "build"), variantName) report.parentFile.mkdirs() // A well-formed JSON object with no proxyAppId: parseable as JSON, unusable as a report, // which is the shape a schema drift actually produces. diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt index 418d2d89d2..d06f049d2b 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.quickbuild import com.google.common.truth.Truth.assertThat import org.junit.Test +import java.io.File /** * Two regressions are pinned here. @@ -76,15 +77,17 @@ class QuickBuildTaskPathsTest { // Both halves of the plugin contract: `/quickbuild//setup.json`. A // flavor-agnostic path here would read another flavor's report - the wrong APK and // the wrong applicationId. - assertThat(QuickBuildTaskPaths.setupJson("debug")) - .isEqualTo("quickbuild/debug/setup.json") - assertThat(QuickBuildTaskPaths.setupJson("demoDebug")) - .isEqualTo("quickbuild/demoDebug/setup.json") + val buildDir = File("/projects/app/build") + assertThat(QuickBuildTaskPaths.setupJson(buildDir, "debug")) + .isEqualTo(File(buildDir, "quickbuild/debug/setup.json")) + assertThat(QuickBuildTaskPaths.setupJson(buildDir, "demoDebug")) + .isEqualTo(File(buildDir, "quickbuild/demoDebug/setup.json")) } @Test fun `a blank variant reads the default variant's report`() { - assertThat(QuickBuildTaskPaths.setupJson("")).isEqualTo("quickbuild/debug/setup.json") - assertThat(QuickBuildTaskPaths.setupJson()).isEqualTo("quickbuild/debug/setup.json") + val buildDir = File("/projects/app/build") + assertThat(QuickBuildTaskPaths.setupJson(buildDir, "")).isEqualTo(File(buildDir, "quickbuild/debug/setup.json")) + assertThat(QuickBuildTaskPaths.setupJson(buildDir)).isEqualTo(File(buildDir, "quickbuild/debug/setup.json")) } } diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/PluginCgpLookupTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/PluginCgpLookupTest.kt new file mode 100644 index 0000000000..6895f9a6db --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/PluginCgpLookupTest.kt @@ -0,0 +1,60 @@ +package com.itsaky.androidide.viewmodel + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.projects.api.GradleProject +import com.itsaky.androidide.projects.api.Workspace +import io.mockk.every +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * A plugin build's `.cgp` is found under the root project's build directory as the synced model + * has it. With relocated build output (ADFA-4930) that is in app storage, and `/build` + * may still hold a plugin from before the relocation. + */ +class PluginCgpLookupTest { + @get:Rule + val tmp = TemporaryFolder() + + private val project: File get() = File(tmp.root, "project") + + private val relocated: File get() = File(tmp.root, "no_backup/gradle-build/project-1234/main/root-5678") + + @Test + fun `the plugin is found under a relocated root build directory, not the project folder`() { + val stale = cgp(File(project, "build/plugin"), "old-debug.cgp") + val built = cgp(File(relocated, "plugin"), "myplugin-debug.cgp") + + assertThat(findPluginCgpFile(workspace(relocated), "debug")).isEqualTo(built) + assertThat(findPluginCgpFile(workspace(relocated), "debug")).isNotEqualTo(stale) + } + + @Test + fun `a release variant takes the plugin without the debug suffix`() { + cgp(File(relocated, "plugin"), "myplugin-debug.cgp") + val release = cgp(File(relocated, "plugin"), "myplugin.cgp") + + assertThat(findPluginCgpFile(workspace(relocated), "release")).isEqualTo(release) + } + + @Test + fun `nothing is found without a model or a plugin`() { + assertThat(findPluginCgpFile(null, "debug")).isNull() + assertThat(findPluginCgpFile(workspace(relocated), "debug")).isNull() + } + + private fun cgp( + dir: File, + name: String, + ): File = File(dir.apply { mkdirs() }, name).apply { writeText("cgp") } + + private fun workspace(rootBuildDir: File): Workspace { + val root = mockk() + every { root.buildDirPath } returns rootBuildDir.path + every { root.projectDirPath } returns project.path + return mockk().also { every { it.rootProject } returns root } + } +} From badb9d30282f8051a4d1a34bc86dcbb3bb98f923 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 15 Oct 2026 18:00:17 -0700 Subject: [PATCH 13/43] ADFA-4930: delete relocated build output with its project, sweep orphans Relocated build output outlived its project: deleting a project in the IDE left its tree in app storage, and so did deleting, moving or renaming it outside the IDE. - Deleting a project (Recents' delete and the Delete projects screen, both through RecentProjectsViewModel) deletes its trees. - At startup, off the main thread, a sweep deletes the trees of projects that no longer exist. A project on a volume that is not mounted is not gone, and a tree without a marker is left alone. Trees are matched by their project-path marker rather than by key, because deletion uses the canonical path and builds use projectDirPath; a symlinked spelling of the same project would key differently. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../CredentialProtectedApplicationLoader.kt | 8 +++ .../services/builder/BuildOutputLocation.kt | 49 +++++++++++++++ .../viewmodel/RecentProjectsViewModel.kt | 9 +++ .../builder/BuildOutputLocationTest.kt | 61 +++++++++++++++++++ 4 files changed, 127 insertions(+) diff --git a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt index eeac14fa2e..82f0d511ce 100644 --- a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt @@ -27,6 +27,7 @@ import com.itsaky.androidide.plugins.manager.services.IdeTerminalServiceImpl import com.itsaky.androidide.preferences.internal.DevOpsPreferences import com.itsaky.androidide.preferences.internal.GeneralPreferences import com.itsaky.androidide.resources.localization.LocaleProvider +import com.itsaky.androidide.services.builder.BuildOutputLocation import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult import com.itsaky.androidide.ui.themes.IDETheme import com.itsaky.androidide.ui.themes.IThemeManager @@ -142,6 +143,13 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { IDEColorSchemeProvider.init() } + app.coroutineScope.launch(Dispatchers.IO) { + // ADFA-4930: relocated build output of projects deleted, moved or renamed outside + // the IDE. + val removed = BuildOutputLocation.sweep(app.noBackupFilesDir) + if (removed > 0) logger.info("Swept {} orphaned build output tree(s)", removed) + } + if (!VMUtils.isJvm || VMUtils.isInstrumentedTest) { ToolsManager.init(app, null) } diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index 6070fb3d1e..585dc9fdf0 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -7,6 +7,8 @@ import com.itsaky.androidide.projects.models.buildDir import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR import com.itsaky.androidide.utils.FeatureFlags import com.itsaky.androidide.utils.ProjectStorageKey +import com.itsaky.androidide.utils.canonicalProjectLocation +import com.itsaky.androidide.utils.isDeletedProjectDirectory import java.io.File import java.io.IOException @@ -96,6 +98,53 @@ object BuildOutputLocation { } } + /** + * Deletes the trees of [projectDir], matched by their [PROJECT_PATH_FILE] marker rather than by + * key, so a path spelled differently (`/sdcard` against `/storage/emulated/0`) still matches. + * Call it when the user deletes the project. + * + * @return how many trees were deleted. + */ + fun removeTreesOf( + noBackupDir: File, + projectDir: File, + ): Int { + val location = projectDir.canonicalProjectLocation() + return deleteTreesWhere(noBackupDir) { it.canonicalProjectLocation() == location } + } + + /** + * Deletes the trees of projects that no longer exist, such as a project deleted, moved or renamed + * outside the IDE. A project on a volume that is not mounted is not gone, and a tree without a + * marker is left alone. Runs on a background thread at startup. + * + * @return how many trees were deleted. + */ + fun sweep(noBackupDir: File): Int = deleteTreesWhere(noBackupDir) { it.isDeletedProjectDirectory() } + + private fun deleteTreesWhere( + noBackupDir: File, + isOrphan: (File) -> Boolean, + ): Int { + val trees = File(noBackupDir, DIR_NAME).listFiles { file -> file.isDirectory } ?: return 0 + return trees.count { tree -> + val project = projectOf(tree) + project != null && isOrphan(project) && tree.deleteRecursively() + } + } + + /** The project [tree] belongs to, from its marker, or null when the marker is missing or unreadable. */ + private fun projectOf(tree: File): File? = + try { + File(tree, PROJECT_PATH_FILE) + .takeIf { it.isFile } + ?.readText() + ?.takeIf { it.isNotBlank() } + ?.let(::File) + } catch (e: IOException) { + null + } + /** * Whether a synced model's build directory lies in [tree], meaning the sync that produced the * model relocated build output. diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt index 1436c3079a..84f398345f 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt @@ -15,6 +15,7 @@ import com.itsaky.androidide.roomData.recentproject.RecentProject import com.itsaky.androidide.roomData.recentproject.RecentProjectDao import com.itsaky.androidide.roomData.recentproject.RecentProjectMaintenance import com.itsaky.androidide.roomData.recentproject.RecentProjectRoomDatabase +import com.itsaky.androidide.services.builder.BuildOutputLocation import com.itsaky.androidide.templates.Language import com.itsaky.androidide.utils.canonicalProjectLocation import com.itsaky.androidide.utils.getCreatedTime @@ -284,6 +285,12 @@ class RecentProjectsViewModel( } } + /** ADFA-4930: the project's relocated build output goes with it. Call on [Dispatchers.IO]. */ + private fun removeBuildOutput(location: String) { + val removed = BuildOutputLocation.removeTreesOf(getApplication().noBackupFilesDir, File(location)) + if (removed > 0) logger.info("Deleted {} build output tree(s) of {}", removed, location) + } + fun deleteProjectByLocation(location: String) = viewModelScope.launch { try { @@ -297,6 +304,7 @@ class RecentProjectsViewModel( if (isDeleted) { recentProjectDao.deleteByLocation(projectToDelete.location) + removeBuildOutput(projectToDelete.location) } projectToDelete.takeIf { isDeleted } } @@ -394,6 +402,7 @@ class RecentProjectsViewModel( if (isDeletedFromStorage) { successfullyDeletedLocations.add(project.location) + removeBuildOutput(project.location) } else { logger.warn("Failed to delete project files from storage: ${project.location}") allDeletionsSucceeded = false diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index 372942c0d5..b31ffa2b35 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -11,6 +11,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +import java.nio.file.Files class BuildOutputLocationTest { @get:Rule @@ -140,6 +141,66 @@ class BuildOutputLocationTest { assertThat(BuildOutputLocation.syncPending(modelRelocated = null, requested = true)).isFalse() } + @Test + fun `deleting a project deletes its tree and no other`() { + val noBackup = tmp.newFolder("no_backup") + val app = tmp.newFolder("projects", "app") + val other = tmp.newFolder("projects", "other") + val appTree = treeOf(noBackup, app) + val otherTree = treeOf(noBackup, other) + + assertThat(BuildOutputLocation.removeTreesOf(noBackup, app)).isEqualTo(1) + + assertThat(appTree.exists()).isFalse() + assertThat(otherTree.isDirectory).isTrue() + } + + @Test + fun `a tree recorded under another spelling of the project path is still found`() { + val noBackup = tmp.newFolder("no_backup") + val app = tmp.newFolder("storage", "app") + val link = File(tmp.root, "sdcard").toPath() + Files.createSymbolicLink(link, app.parentFile.toPath()) + val tree = treeOf(noBackup, File(link.toFile(), "app")) + + assertThat(BuildOutputLocation.removeTreesOf(noBackup, app)).isEqualTo(1) + assertThat(tree.exists()).isFalse() + } + + @Test + fun `the sweep deletes the trees of deleted projects only`() { + val noBackup = tmp.newFolder("no_backup") + val kept = tmp.newFolder("projects", "kept") + val deleted = tmp.newFolder("projects", "deleted") + val keptTree = treeOf(noBackup, kept) + val deletedTree = treeOf(noBackup, deleted) + val unmountedTree = treeOf(noBackup, File(tmp.root, "unmounted-volume/app")) + val markerless = File(noBackup, "${BuildOutputLocation.DIR_NAME}/no-marker").apply { mkdirs() } + deleted.deleteRecursively() + + assertThat(BuildOutputLocation.sweep(noBackup)).isEqualTo(1) + + assertThat(deletedTree.exists()).isFalse() + assertThat(keptTree.isDirectory).isTrue() + // A missing parent reads as a volume that is not mounted, not as a deleted project. + assertThat(unmountedTree.isDirectory).isTrue() + assertThat(markerless.isDirectory).isTrue() + } + + @Test + fun `the sweep copes with no trees at all`() { + assertThat(BuildOutputLocation.sweep(tmp.newFolder("no_backup"))).isEqualTo(0) + } + + private fun treeOf( + noBackup: File, + project: File, + ): File = + BuildOutputLocation.treeFor(noBackup, project).also { + assertThat(BuildOutputLocation.prepare(it, project)).isTrue() + File(it, "main/app-1234/outputs").mkdirs() + } + private fun workspace(rootBuildDir: File): Workspace { val root = mockk() every { root.buildDirPath } returns rootBuildDir.path From aa9c463505b54f614174942beba6f3f31d8e6880 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 15 Oct 2026 18:18:33 -0700 Subject: [PATCH 14/43] ADFA-4930: Export APK from the editor toolbar With relocated build output the built APK is in app-private storage, where a file manager cannot reach it. A toolbar action, after Launch app and shown only while relocation is available (experiments), resolves the selected variant's APK from the synced model and offers: - Share: the share sheet, through the existing FileProvider. - Copy to Download: `-.apk`, numbered when taken, so an earlier export is never overwritten. Long-press help uses TooltipTag.EDITOR_TOOLBAR_EXPORT_APK ("project.export.apk"); its documentation.db text is a separate SQL script for OfflineDocumentationTools. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../androidide/actions/etc/ExportApkAction.kt | 127 ++++++++++++++++++ .../com/itsaky/androidide/utils/ApkExport.kt | 35 +++++ .../androidide/utils/EditorActivityActions.kt | 6 + .../itsaky/androidide/utils/ApkExportTest.kt | 62 +++++++++ .../androidide/idetooltips/TooltipTag.kt | 1 + resources/src/main/res/values/strings.xml | 6 + 6 files changed, 237 insertions(+) create mode 100644 app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt b/app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt new file mode 100644 index 0000000000..684129c3fb --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt @@ -0,0 +1,127 @@ +package com.itsaky.androidide.actions.etc + +import android.content.Context +import android.os.Environment +import android.view.MenuItem +import androidx.core.content.ContextCompat +import com.itsaky.androidide.R +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.EditorActivityAction +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.actions.openApplicationModuleChooser +import com.itsaky.androidide.activities.editor.EditorHandlerActivity +import com.itsaky.androidide.idetooltips.TooltipTag.EDITOR_TOOLBAR_EXPORT_APK +import com.itsaky.androidide.models.ApkMetadata +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.projects.models.assembleTaskOutputListingFile +import com.itsaky.androidide.utils.ApkExport +import com.itsaky.androidide.utils.DialogUtils +import com.itsaky.androidide.utils.IntentUtils +import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashSuccess +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException + +/** + * Hands over the selected variant's built APK (ADFA-4930): with relocated build output it is in + * app-private storage, where a file manager cannot reach it. Offers the share sheet, or a copy in + * the Download folder. + */ +class ExportApkAction( + context: Context, + override val order: Int, +) : EditorActivityAction() { + override val id: String = "ide.editor.exportApk" + override var requiresUIThread: Boolean = true + + override fun retrieveTooltipTag(isReadOnlyContext: Boolean) = EDITOR_TOOLBAR_EXPORT_APK + + init { + label = context.getString(R.string.title_export_apk) + icon = ContextCompat.getDrawable(context, R.drawable.ic_share) + } + + override fun prepare(data: ActionData) { + super.prepare(data) + data.getActivity() ?: run { + markInvisible() + return + } + + visible = true + enabled = IProjectManager.getInstance().getAndroidAppModules().isNotEmpty() + } + + override suspend fun execAction(data: ActionData) { + openApplicationModuleChooser(data) { app -> + val variant = app.getSelectedVariant() + if (variant == null) { + flashError(R.string.err_selected_variant_not_found) + return@openApplicationModuleChooser + } + + val activity = data.requireActivity() + val name = "${File(IProjectManager.getInstance().projectDirPath).name}-${variant.name}" + activity.activityScope.launch { + val apk = + withContext(Dispatchers.IO) { + ApkMetadata.findApkFile(variant.mainArtifact.assembleTaskOutputListingFile)?.takeIf { it.isFile } + } + if (apk == null) { + flashError(R.string.msg_export_apk_not_built) + return@launch + } + withContext(Dispatchers.Main) { offerExport(activity, apk, name) } + } + } + } + + private fun offerExport( + activity: EditorHandlerActivity, + apk: File, + name: String, + ) { + DialogUtils + .newMaterialDialogBuilder(activity) + .setTitle(R.string.title_export_apk) + .setItems( + arrayOf( + activity.getString(R.string.export_apk_share), + activity.getString(R.string.export_apk_copy_to_download), + ), + ) { _, which -> + if (which == 0) { + IntentUtils.shareFile(activity, apk, ApkExport.MIME_TYPE) + } else { + activity.activityScope.launch(Dispatchers.IO) { copyToDownload(activity, apk, name) } + } + }.setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun copyToDownload( + activity: EditorHandlerActivity, + apk: File, + name: String, + ) { + @Suppress("DEPRECATION") + val download = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + try { + val copy = ApkExport.copyTo(apk, download, name) + flashSuccess(activity.getString(R.string.msg_export_apk_saved, copy.name)) + } catch (e: IOException) { + log.error("Cannot copy {} to {}", apk, download, e) + flashError(R.string.msg_export_apk_failed) + } + } + + override fun getShowAsActionFlags(data: ActionData): Int = MenuItem.SHOW_AS_ACTION_IF_ROOM + + companion object { + private val log = LoggerFactory.getLogger(ExportApkAction::class.java) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt b/app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt new file mode 100644 index 0000000000..414949d045 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt @@ -0,0 +1,35 @@ +package com.itsaky.androidide.utils + +import java.io.File +import java.io.IOException + +/** Copies a built APK out of app storage under a readable name (ADFA-4930). */ +internal object ApkExport { + const val MIME_TYPE = "application/vnd.android.package-archive" + + /** + * Copies [apk] into [dir] as `.apk`, or ` (2).apk` and so on when that is taken, so + * an earlier export is never overwritten. + * + * @param name the file name without `.apk`; characters a file name cannot hold are replaced. + * @return the copy. + * @throws IOException when [dir] cannot be created or the copy fails. + */ + fun copyTo( + apk: File, + dir: File, + name: String, + ): File { + if (!dir.isDirectory && !dir.mkdirs()) throw IOException("Cannot create $dir") + val target = freeTarget(dir, name.map { if (it in "/\\:*?\"<>|" || it.isISOControl()) '_' else it }.joinToString("")) + return apk.copyTo(target) + } + + private fun freeTarget( + dir: File, + name: String, + ): File = + generateSequence(1) { it + 1 } + .map { n -> File(dir, if (n == 1) "$name.apk" else "$name ($n).apk") } + .first { !it.exists() } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt index 7bee677e97..647e287844 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt @@ -38,6 +38,7 @@ import com.itsaky.androidide.actions.editor.LongSelectAction import com.itsaky.androidide.actions.editor.PasteAction import com.itsaky.androidide.actions.editor.SelectAllAction import com.itsaky.androidide.actions.etc.DisconnectLogSendersAction +import com.itsaky.androidide.actions.etc.ExportApkAction import com.itsaky.androidide.actions.etc.FindAction import com.itsaky.androidide.actions.etc.FindInFileAction import com.itsaky.androidide.actions.etc.FindInProjectAction @@ -64,6 +65,7 @@ import com.itsaky.androidide.actions.text.UndoAction import com.itsaky.androidide.plugins.extensions.UIExtension import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager import com.itsaky.androidide.plugins.manager.core.PluginManager +import com.itsaky.androidide.services.builder.BuildOutputLocation /** * Takes care of registering actions to the actions registry for the editor activity. @@ -106,6 +108,10 @@ class EditorActivityActions { registry.registerAction(FindInProjectAction(context, order++)) registry.registerAction(ReplaceInProjectAction(context, order++)) registry.registerAction(LaunchAppAction(context, order++)) + // ADFA-4930: relocated build output keeps the APK out of the project folder. + if (BuildOutputLocation.isAvailable()) { + registry.registerAction(ExportApkAction(context, order++)) + } registry.registerAction(DisconnectLogSendersAction(context, order++)) // Plugin contributions diff --git a/app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt b/app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt new file mode 100644 index 0000000000..58fa9d31d0 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt @@ -0,0 +1,62 @@ +package com.itsaky.androidide.utils + +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.io.IOException + +class ApkExportTest { + @get:Rule + val tmp = TemporaryFolder() + + private val apk: File get() = + File(tmp.root, "tree/app-debug.apk").apply { + parentFile.mkdirs() + writeText("apk") + } + + @Test + fun `the copy is named after the project and variant`() { + val download = tmp.newFolder("Download") + + val copy = ApkExport.copyTo(apk, download, "My Application-debug") + + assertThat(copy).isEqualTo(File(download, "My Application-debug.apk")) + assertThat(copy.readText()).isEqualTo("apk") + } + + @Test + fun `an earlier export is never overwritten`() { + val download = tmp.newFolder("Download") + File(download, "app-debug.apk").writeText("first") + File(download, "app-debug (2).apk").writeText("second") + + val copy = ApkExport.copyTo(apk, download, "app-debug") + + assertThat(copy.name).isEqualTo("app-debug (3).apk") + assertThat(File(download, "app-debug.apk").readText()).isEqualTo("first") + } + + @Test + fun `characters a file name cannot hold are replaced`() { + val copy = ApkExport.copyTo(apk, tmp.newFolder("Download"), "a/b:c-debug") + + assertThat(copy.name).isEqualTo("a_b_c-debug.apk") + } + + @Test + fun `a missing Download folder is created`() { + val download = File(tmp.root, "sdcard/Download") + + assertThat(ApkExport.copyTo(apk, download, "app-debug").isFile).isTrue() + } + + @Test(expected = IOException::class) + fun `a Download folder that cannot be created is reported`() { + val blocker = tmp.newFile("sdcard") + + ApkExport.copyTo(apk, File(blocker, "Download"), "app-debug") + } +} diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index 30268ad325..7b36406629 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -225,6 +225,7 @@ object TooltipTag { const val EDITOR_TOOLBAR_FIND_IN_FILE = "editor.find.in.file" const val EDITOR_TOOLBAR_SYNC = "project.sync" const val EDITOR_TOOLBAR_LAUNCH_APP = "project.launch.app" + const val EDITOR_TOOLBAR_EXPORT_APK = "project.export.apk" const val EDITOR_TOOLBAR_UNDO = "editor.undo" const val EDITOR_TOOLBAR_REDO = "editor.redo" const val EDITOR_TOOLBAR_QUICK_SAVE = "project.save" diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 86311f92b1..03ff56565e 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -877,6 +877,12 @@ Cannot run application. Unable to determine package name. Cannot run application. No application modules found in project. Launch app + Export APK + Share + Copy to Download + No APK yet. Run the app once to build it. + Saved to Download as %1$s + Could not copy the APK to Download Experimental You are using %1$s variant of Code On The Go on a %2$s device. This configuration may work, but it\'ll result in reduced performance and possible security vulnerabilities. Please avoid using this configuration whenever possible. Pin line numbers From d23ff48cd48bb25cfff04494b867e78a78539ce9 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 15 Oct 2026 18:36:38 -0700 Subject: [PATCH 15/43] ADFA-4930: plan doc matches PR 3's lifecycle and Export APK Docs only: where deletion hooks in, how trees are matched, what the sweep keeps, and that Export APK offers both the share sheet and a copy in Download. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../plans/2026-10-14-adfa-4930-build-output-off-fuse.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md index f966a1091d..24d227fc6c 100644 --- a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md +++ b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md @@ -66,8 +66,8 @@ Measured 2026-10-14 with the IDE's Gradle arguments, same template project on bo ### Lifecycle -- **Project deleted in the IDE** (`RecentProjectsAdapter.kt:213`, `DeleteProjectFragment`): delete its output tree too. -- **Orphans** (project moved, renamed or deleted outside the IDE): at startup, off the main thread, remove trees whose `project-path` marker no longer exists - the same shape as `QuickBuildScratch.sweep`. +- **Project deleted in the IDE**: both deletion paths (Recents and the Delete projects screen) go through `RecentProjectsViewModel`, which deletes the project's trees too. Trees are matched by their `project-path` marker, not by key: deletion uses the canonical path and builds use `projectDirPath`, so a symlinked spelling would key differently. +- **Orphans** (project moved, renamed or deleted outside the IDE): at startup, off the main thread, remove trees whose project no longer exists while its parent folder does, so a volume that is not mounted doesn't count. Trees without a marker are left alone. Unlike `QuickBuildScratch.sweep`, which clears every tree, this keeps the output of live projects. - **Existing `build/` folders in projects**: leave them. They go stale but do no harm. A later "free space" action can offer to remove them. - **Storage**: output now counts as app data in Settings, and "Clear storage" removes it (the next build is a full one). Uninstalling removes it as well; sources are unaffected. @@ -75,7 +75,7 @@ Measured 2026-10-14 with the IDE's Gradle arguments, same template project on bo These are the tradeoffs from the original analysis, with how this plan handles each: -- **The APK is no longer in the project folder**, file manager or MTP. Add an **Export APK** action that copies the built APK to `Download/` or opens the share sheet. The survey found no existing export or share action. +- **The APK is no longer in the project folder**, file manager or MTP. Add an **Export APK** toolbar action, shown while relocation is available, that offers the share sheet or a copy in `Download/` (`[project]-[variant].apk`, numbered when taken). The survey found no existing export action. - **Build output no longer travels with a copied project folder.** That's acceptable: it can be rebuilt, and copying `build/` is rarely wanted. - **Terminal builds** (`./gradlew` in the IDE's terminal) don't get the redirect, so they still build into the project folder, separately from the IDE's tree. Document this. Changing it is a follow-up if users trip over it. From 9a520638bd9f766f327595718300a38017048a1a Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 15 Oct 2026 18:56:57 -0700 Subject: [PATCH 16/43] ADFA-4930: harden Export APK and tree deletion From the #2151 review: - A malformed or truncated APK listing no longer crashes Export; it reads as nothing to export. - Export refuses Quick Build's proxy APK, which Quick Build's build writes to the variant's output path: the APK is the proxy when the variant's setup.json names it and is not older than it. - A copy goes to a temporary name and is renamed into place, so one that fails part-way leaves no broken APK in Download. - Export is disabled during a user-visible build and refused, with a reason, while any build (Quick Build's prebuild included) runs. - The export name includes the module when the project has more than one app module. - A tree's marker is deleted last, so a deletion that stops part-way leaves a tree the next sweep can still identify. - The unsafe file-name rule is shared with ExternalFileInstallViewModel (no behaviour change there). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../androidide/actions/etc/ExportApkAction.kt | 45 ++++++++--- .../services/builder/BuildOutputLocation.kt | 13 ++- .../com/itsaky/androidide/utils/ApkExport.kt | 80 ++++++++++++++++++- .../com/itsaky/androidide/utils/FileNames.kt | 14 ++++ .../ExternalFileInstallViewModel.kt | 4 +- .../builder/BuildOutputLocationTest.kt | 18 +++++ .../itsaky/androidide/utils/ApkExportTest.kt | 80 +++++++++++++++++++ resources/src/main/res/values/strings.xml | 2 + 8 files changed, 239 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/utils/FileNames.kt diff --git a/app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt b/app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt index 684129c3fb..2924bfa6ed 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt @@ -11,9 +11,12 @@ import com.itsaky.androidide.actions.markInvisible import com.itsaky.androidide.actions.openApplicationModuleChooser import com.itsaky.androidide.activities.editor.EditorHandlerActivity import com.itsaky.androidide.idetooltips.TooltipTag.EDITOR_TOOLBAR_EXPORT_APK -import com.itsaky.androidide.models.ApkMetadata +import com.itsaky.androidide.lookup.Lookup import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.projects.builder.BuildService import com.itsaky.androidide.projects.models.assembleTaskOutputListingFile +import com.itsaky.androidide.projects.models.buildDir +import com.itsaky.androidide.quickbuild.QuickBuildTaskPaths import com.itsaky.androidide.utils.ApkExport import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.IntentUtils @@ -53,10 +56,15 @@ class ExportApkAction( } visible = true - enabled = IProjectManager.getInstance().getAndroidAppModules().isNotEmpty() + // The user-visible flag, like the build actions: Quick Build's prebuild after every project + // open is refused at the tap instead (isBuilding), with a reason. + enabled = + IProjectManager.getInstance().getAndroidAppModules().isNotEmpty() && + buildService()?.isUserVisibleBuildInProgress != true } override suspend fun execAction(data: ActionData) { + if (isBuilding()) return openApplicationModuleChooser(data) { app -> val variant = app.getSelectedVariant() if (variant == null) { @@ -65,21 +73,38 @@ class ExportApkAction( } val activity = data.requireActivity() - val name = "${File(IProjectManager.getInstance().projectDirPath).name}-${variant.name}" + val projectManager = IProjectManager.getInstance() + val projectDir = File(projectManager.projectDirPath) + // The module only when it tells two app modules' exports apart. + val module = app.name.takeIf { projectManager.getAndroidAppModules().size > 1 } + val name = listOfNotNull(projectDir.name, module, variant.name).joinToString("-") activity.activityScope.launch { - val apk = + val found = withContext(Dispatchers.IO) { - ApkMetadata.findApkFile(variant.mainArtifact.assembleTaskOutputListingFile)?.takeIf { it.isFile } + ApkExport.resolve( + listingFile = variant.mainArtifact.assembleTaskOutputListingFile, + quickBuildReport = QuickBuildTaskPaths.setupJson(app.buildDir, variant.name), + projectDir = projectDir, + ) } - if (apk == null) { - flashError(R.string.msg_export_apk_not_built) - return@launch + when (found) { + ApkExport.Exportable.NotBuilt -> flashError(R.string.msg_export_apk_not_built) + ApkExport.Exportable.QuickBuildProxy -> flashError(R.string.msg_export_apk_quick_build) + is ApkExport.Exportable.Apk -> withContext(Dispatchers.Main) { offerExport(activity, found.file, name) } } - withContext(Dispatchers.Main) { offerExport(activity, apk, name) } } } } + private fun buildService(): BuildService? = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) + + /** A build, Quick Build's prebuild included, may be rewriting the APK; refuse with a reason. */ + private fun isBuilding(): Boolean { + if (buildService()?.isBuildInProgress != true) return false + flashError(R.string.msg_export_apk_build_running) + return true + } + private fun offerExport( activity: EditorHandlerActivity, apk: File, @@ -96,7 +121,7 @@ class ExportApkAction( ) { _, which -> if (which == 0) { IntentUtils.shareFile(activity, apk, ApkExport.MIME_TYPE) - } else { + } else if (!isBuilding()) { activity.activityScope.launch(Dispatchers.IO) { copyToDownload(activity, apk, name) } } }.setNegativeButton(android.R.string.cancel, null) diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index 585dc9fdf0..891aeb6f6a 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -129,10 +129,21 @@ object BuildOutputLocation { val trees = File(noBackupDir, DIR_NAME).listFiles { file -> file.isDirectory } ?: return 0 return trees.count { tree -> val project = projectOf(tree) - project != null && isOrphan(project) && tree.deleteRecursively() + project != null && isOrphan(project) && deleteTree(tree) } } + /** + * Deletes [tree] with its marker last: a deletion that stops part-way keeps the marker, so the + * next sweep can still tell whose tree it is and finish the job. + */ + private fun deleteTree(tree: File): Boolean { + val marker = File(tree, PROJECT_PATH_FILE) + val contents = tree.listFiles() ?: return false + val deleted = contents.filter { it != marker }.map { it.deleteRecursively() }.all { it } + return deleted && (!marker.exists() || marker.delete()) && tree.delete() + } + /** The project [tree] belongs to, from its marker, or null when the marker is missing or unreadable. */ private fun projectOf(tree: File): File? = try { diff --git a/app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt b/app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt index 414949d045..c420c8f5a1 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt @@ -1,15 +1,78 @@ package com.itsaky.androidide.utils +import com.itsaky.androidide.models.ApkMetadata +import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo +import org.slf4j.LoggerFactory import java.io.File import java.io.IOException -/** Copies a built APK out of app storage under a readable name (ADFA-4930). */ +/** Finds a built APK and copies it out of app storage under a readable name (ADFA-4930). */ internal object ApkExport { const val MIME_TYPE = "application/vnd.android.package-archive" + private val log = LoggerFactory.getLogger(ApkExport::class.java) + + /** What [resolve] found to export. */ + sealed interface Exportable { + data class Apk( + val file: File, + ) : Exportable + + /** No APK yet, or its listing cannot be read. */ + data object NotBuilt : Exportable + + /** + * The APK at the variant's output path is Quick Build's live-reload proxy app, which its + * build writes there; exporting it would hand over the wrong app. + */ + data object QuickBuildProxy : Exportable + } + + /** + * @param listingFile the variant's APK listing file, from the synced model. + * @param quickBuildReport the variant's Quick Build `setup.json`, which names the proxy APK. + * @param projectDir the project's root, which the report's relative paths resolve against. + */ + fun resolve( + listingFile: File, + quickBuildReport: File, + projectDir: File, + ): Exportable { + val apk = + try { + ApkMetadata.findApkFile(listingFile)?.takeIf { it.isFile } + } catch (e: Exception) { + // A truncated or malformed listing (JSON or redirect) means there is no APK to trust. + log.warn("Cannot read the APK listing {}", listingFile, e) + null + } ?: return Exportable.NotBuilt + return if (isQuickBuildProxy(apk, quickBuildReport, projectDir)) Exportable.QuickBuildProxy else Exportable.Apk(apk) + } + + /** + * Quick Build's report is written after the proxy APK it names, so the APK is the proxy when + * the report names it and is not older than it. A standard build rewrites the APK and leaves + * the report alone, so after one the APK is newer. + */ + private fun isQuickBuildProxy( + apk: File, + report: File, + projectDir: File, + ): Boolean { + if (!report.isFile) return false + val reported = + try { + ProxyAppInfo.parse(report.readText(), projectDir)?.apk + } catch (e: IOException) { + null + } ?: return false + return reported.absoluteFile.normalize() == apk.absoluteFile.normalize() && report.lastModified() >= apk.lastModified() + } + /** * Copies [apk] into [dir] as `.apk`, or ` (2).apk` and so on when that is taken, so - * an earlier export is never overwritten. + * an earlier export is never overwritten. The copy goes to a temporary name first, so one that + * fails part-way never leaves a broken APK under the export's name. * * @param name the file name without `.apk`; characters a file name cannot hold are replaced. * @return the copy. @@ -19,10 +82,19 @@ internal object ApkExport { apk: File, dir: File, name: String, + copy: (File, File) -> Unit = { from, to -> from.copyTo(to) }, ): File { if (!dir.isDirectory && !dir.mkdirs()) throw IOException("Cannot create $dir") - val target = freeTarget(dir, name.map { if (it in "/\\:*?\"<>|" || it.isISOControl()) '_' else it }.joinToString("")) - return apk.copyTo(target) + val target = freeTarget(dir, safeFileName(name, "app")) + val partial = File(dir, ".${target.name}.partial") + try { + partial.delete() + copy(apk, partial) + if (!partial.renameTo(target)) throw IOException("Cannot rename $partial to $target") + } finally { + partial.delete() + } + return target } private fun freeTarget( diff --git a/app/src/main/java/com/itsaky/androidide/utils/FileNames.kt b/app/src/main/java/com/itsaky/androidide/utils/FileNames.kt new file mode 100644 index 0000000000..410d9358dd --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/FileNames.kt @@ -0,0 +1,14 @@ +package com.itsaky.androidide.utils + +/** Characters a file name on shared storage cannot hold. */ +internal val UNSAFE_FILENAME_CHARS = Regex("[\\\\/:*?\"<>|]") + +/** + * [raw] with every character a file name cannot hold replaced by `_`, and trimmed. + * + * @param fallback the name to use when nothing is left. + */ +internal fun safeFileName( + raw: String, + fallback: String, +): String = raw.replace(UNSAFE_FILENAME_CHARS, "_").trim().ifBlank { fallback } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt index f2bb0c6dc3..1b238ff2d8 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt @@ -13,6 +13,7 @@ import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent import com.itsaky.androidide.utils.InstallTempFiles import com.itsaky.androidide.utils.LastValueGate import com.itsaky.androidide.utils.UriFileImporter +import com.itsaky.androidide.utils.safeFileName import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable @@ -41,7 +42,6 @@ class ExternalFileInstallViewModel( ) : ViewModel() { private companion object { private val log = LoggerFactory.getLogger(ExternalFileInstallViewModel::class.java) - private val UNSAFE_FILENAME_CHARS = Regex("[\\\\/:*?\"<>|]") // A cold OS-triggered launch of this activity can win the race against IDEApplication's // async setup (device-unlock -> CredentialProtectedApplicationLoader.load(), which itself @@ -345,7 +345,7 @@ class ExternalFileInstallViewModel( return candidate } - fun sanitizeBaseName(rawName: String): String = rawName.replace(UNSAFE_FILENAME_CHARS, "_").trim().ifBlank { "templates" } + fun sanitizeBaseName(rawName: String): String = safeFileName(rawName, "templates") private suspend fun sendErrorAndFinish( generation: Int, diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index b31ffa2b35..257537d7d2 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -187,6 +187,24 @@ class BuildOutputLocationTest { assertThat(markerless.isDirectory).isTrue() } + @Test + fun `a deletion that stops part-way keeps the marker for the next sweep`() { + val noBackup = tmp.newFolder("no_backup") + val project = tmp.newFolder("projects", "app") + val tree = treeOf(noBackup, project) + val locked = File(tree, "main/app-1234/outputs").apply { File(this, "app-debug.apk").writeText("apk") } + locked.setWritable(false) + try { + assertThat(BuildOutputLocation.removeTreesOf(noBackup, project)).isEqualTo(0) + + assertThat(File(tree, BuildOutputLocation.PROJECT_PATH_FILE).isFile).isTrue() + } finally { + locked.setWritable(true) + } + assertThat(BuildOutputLocation.removeTreesOf(noBackup, project)).isEqualTo(1) + assertThat(tree.exists()).isFalse() + } + @Test fun `the sweep copes with no trees at all`() { assertThat(BuildOutputLocation.sweep(tmp.newFolder("no_backup"))).isEqualTo(0) diff --git a/app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt b/app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt index 58fa9d31d0..b58c2be3a4 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt @@ -17,6 +17,86 @@ class ApkExportTest { writeText("apk") } + @Test + fun `no listing means nothing to export`() { + assertThat(ApkExport.resolve(File(tmp.root, "out/output-metadata.json"), report(), tmp.root)) + .isEqualTo(ApkExport.Exportable.NotBuilt) + } + + @Test + fun `a malformed listing is nothing to export, not a crash`() { + val listing = File(tmp.newFolder("out"), "output-metadata.json").apply { writeText("{\"elements\": [") } + + assertThat(ApkExport.resolve(listing, report(), tmp.root)).isEqualTo(ApkExport.Exportable.NotBuilt) + } + + @Test + fun `a standard build's APK is exported`() { + val (listing, built) = built() + + assertThat(ApkExport.resolve(listing, report(), tmp.root)).isEqualTo(ApkExport.Exportable.Apk(built)) + } + + @Test + fun `Quick Build's proxy APK at the same path is refused`() { + val (listing, built) = built() + val report = report(names = built, at = built.lastModified() + 1_000) + + assertThat(ApkExport.resolve(listing, report, tmp.root)).isEqualTo(ApkExport.Exportable.QuickBuildProxy) + } + + @Test + fun `a standard build after Quick Build's makes the APK exportable again`() { + val (listing, built) = built() + val report = report(names = built, at = built.lastModified() - 1_000) + + assertThat(ApkExport.resolve(listing, report, tmp.root)).isEqualTo(ApkExport.Exportable.Apk(built)) + } + + @Test + fun `a copy that fails part-way leaves nothing under the export's name`() { + val download = tmp.newFolder("Download") + + try { + ApkExport.copyTo(apk, download, "app-debug") { _, to -> + to.writeText("half an ap") + throw IOException("disk full") + } + } catch (e: IOException) { + // expected + } + + assertThat(download.list()).isEmpty() + } + + /** A variant's output directory holding `app-debug.apk` and its listing. */ + private fun built(): Pair { + val dir = tmp.newFolder("outputs", "apk", "debug") + val apk = File(dir, "app-debug.apk").apply { writeText("apk") } + val listing = + File(dir, "output-metadata.json").apply { + writeText( + """{"version":3,"artifactType":{"type":"APK","kind":"Directory"},"applicationId":"com.example.app",""" + + """"variantName":"debug","elements":[{"type":"SINGLE","outputFile":"app-debug.apk"}],"elementType":"File"}""", + ) + } + return listing to apk + } + + /** Quick Build's report, naming [names] as its proxy APK, written at [at]; absent without [names]. */ + private fun report( + names: File? = null, + at: Long = 0, + ): File { + val report = File(tmp.root, "quickbuild/debug/setup.json") + if (names != null) { + report.parentFile.mkdirs() + report.writeText("""{"proxyAppId":"com.example.app","entryActivity":null,"apk":"${names.absolutePath}"}""") + report.setLastModified(at) + } + return report + } + @Test fun `the copy is named after the project and variant`() { val download = tmp.newFolder("Download") diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 03ff56565e..d433589213 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -883,6 +883,8 @@ No APK yet. Run the app once to build it. Saved to Download as %1$s Could not copy the APK to Download + The latest APK is Quick Build\'s live-reload app. Run the app once to build a regular APK, then export it. + A build is running. Export the APK when it finishes. Experimental You are using %1$s variant of Code On The Go on a %2$s device. This configuration may work, but it\'ll result in reduced performance and possible security vulnerabilities. Please avoid using this configuration whenever possible. Pin line numbers From bca47b88bf2232f5692c4e91e8dfb933d2d9f4a5 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 16 Oct 2026 11:42:01 -0700 Subject: [PATCH 17/43] ADFA-4930: relocate build output by default Validation is in (ADFA-4930): warm edit builds of a 288-source project took 48% less time with output in app storage on a Pixel 6 Pro, and clean builds about 50% less on a 1.9 GB itel A667L, with no slowdowns anywhere. - "Relocate build output" defaults to on. - The experiments gate goes: the switch and Export APK show for everyone, and BuildOutputLocation.isRequested() is the switch alone. - The sync banner says what a relocation change does ("Builds are faster with build output in Code on the Go's storage..." or "...back to the project folder") instead of "Gradle files have changed", since turning this on by default makes it the first thing every existing project shows. - ARCHITECTURE.md describes where build output lives and why code must read it from the synced model. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- ARCHITECTURE.md | 7 +++++ .../editor/ProjectHandlerActivity.kt | 27 ++++++++++++++----- .../preferences/buildAndRunPrefExts.kt | 7 +---- .../services/builder/BuildOutputLocation.kt | 8 ++---- .../androidide/utils/EditorActivityActions.kt | 5 +--- ...6-10-14-adfa-4930-build-output-off-fuse.md | 1 + .../preferences/internal/BuildPreferences.kt | 5 ++-- resources/src/main/res/values/strings.xml | 4 ++- 8 files changed, 37 insertions(+), 27 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fd60cebe40..bfddc3ba13 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -63,6 +63,13 @@ The optional file path is attacker-controllable (a URL segment), so it's resolve Whether the open project *can* be named at all is `isDeepLinkTargetOfOpenProject` (`ProjectValidations.kt`): a link can only ever resolve to `/`, but a project can be opened from anywhere. Its `deepLinkTargetOfOpenProjectWithoutIo` half exists so a caller on the main thread can settle the common case — equal path strings name the same directory — without the `canonicalPath` calls the full rule needs. `CreateLinkAction` sends its residue to `Dispatchers.IO` rather than to the StrictMode whitelist (ADR 0007); the two older callers (`EditorHandlerActivity.onNewIntent`, `BaseEditorActivity.onCreate`) still fall through to `canonicalPath` on the main thread and have not been migrated, so the shortcut narrows that exposure rather than removing it. +**Gradle build output lives in app storage, not the project folder (ADFA-4930).** Projects sit on shared storage (`/sdcard`), which Android serves through FUSE, so every file a build writes pays a user-space round trip. With Build & Run > "Relocate build output" on (the default), each project's build output goes to `noBackupFilesDir/gradle-build//` (`services/builder/BuildOutputLocation`), keyed like Quick Build's scratch (`ProjectStorageKey` in `:shared`): +- `main/-/` per Gradle project (`` of its full path, so `:core:ui` is not inside `:core`'s output); nested builds get their own key. The init script plugin sets `layout.buildDirectory` from `-Pcotg.build.outputDir` (`gradle-plugin/.../BuildOutputRedirect`). +- `project-cache/` (`--project-cache-dir`, Gradle's `.gradle/`) and `kotlin/` (`kotlin.project.persistent.dir`). +- `project-path`, a marker naming the project, used to delete a project's output with it and to sweep orphans at startup. + +**The synced model is the record of where output goes.** A sync applies the switch; a build relocates exactly when the model's root build directory is in the tree, so its output is always where the model (APK listing file, classpath, Quick Build's `setup.json`, a plugin's `.cgp`) looks, and a sync that fails changes neither. Code that needs build output reads it from the model's build directories, never from `/build`. Builds started outside the IDE (`./gradlew` in the terminal) don't get the redirect and still write to the project folder. + ## Module Structure Strategy: **layer-and-subsystem based**, not feature-by-feature. The Gradle build has ~80 modules (`settings.gradle.kts`) plus three included composite builds. `app` is the integration point; the rest are libraries it composes. diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 54c76aa257..2b3b68fbe3 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -29,6 +29,7 @@ import android.widget.EditText import android.widget.Toast import androidx.activity.viewModels import androidx.annotation.GravityInt +import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import androidx.lifecycle.DefaultLifecycleObserver @@ -1219,9 +1220,9 @@ abstract class ProjectHandlerActivity : } /** - * ADFA-4930: a changed "Relocate build output" (or experiments flag) only moves the build output - * at the next sync, so offer one, once per change, and withdraw the offer once the synced model - * agrees with the switch again. + * ADFA-4930: a changed "Relocate build output" only moves the build output at the next sync, so + * offer one, once per change, and withdraw the offer once the synced model agrees with the switch + * again. Turning relocation on by default makes this the first thing an existing project shows. * * @param afterInit whether a project initialization just finished. It applied any Gradle file * changes too, so the banner can go even when it also stands for those. @@ -1243,7 +1244,13 @@ abstract class ProjectHandlerActivity : } buildOutputSyncBanner = null buildOutputSyncOffered = null - } else if (buildOutputSyncOffered != requested && notifySyncNeeded(::syncForBuildOutput)) { + } else if ( + buildOutputSyncOffered != requested && + notifySyncNeeded( + onConfirm = ::syncForBuildOutput, + message = if (requested) string.msg_sync_build_output_relocate else string.msg_sync_build_output_restore, + ) + ) { buildOutputSyncOffered = requested buildOutputSyncBanner = syncNotificationFlashbar } @@ -1339,8 +1346,14 @@ abstract class ProjectHandlerActivity : notifySyncNeeded { initializeProject(forceSync = true) } } - /** @return false when the banner was not shown: no build service, or initializing or building. */ - private fun notifySyncNeeded(onConfirm: () -> Unit): Boolean { + /** + * @param message what the banner says; the default is for changed Gradle files. + * @return false when the banner was not shown: no build service, or initializing or building. + */ + private fun notifySyncNeeded( + @StringRes message: Int = string.msg_sync_needed, + onConfirm: () -> Unit, + ): Boolean { val buildService = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) if (buildService == null || editorViewModel.isInitializing || buildService.isBuildInProgress) return false @@ -1354,7 +1367,7 @@ abstract class ProjectHandlerActivity : ).withIcon( R.drawable.ic_sync, colorFilter = resolveAttr(R.attr.colorOnSecondaryContainer), - ).message(string.msg_sync_needed) + ).message(message) .positiveActionText(string.btn_sync) .positiveActionTapListener { onConfirm() diff --git a/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt b/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt index aced7eb4e6..3f45c266db 100644 --- a/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt +++ b/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt @@ -43,7 +43,6 @@ import com.itsaky.androidide.preferences.internal.BuildPreferences.launchAppAfte import com.itsaky.androidide.preferences.internal.BuildPreferences.relocateBuildOutput import com.itsaky.androidide.resources.R.drawable import com.itsaky.androidide.resources.R.string -import com.itsaky.androidide.services.builder.BuildOutputLocation import kotlinx.parcelize.Parcelize @Parcelize @@ -70,11 +69,7 @@ private class GradleOptions( init { addPreference(GradleCommands()) - // Early access only (REVIEW.md section 12): until the rest of the ADFA-4930 stack lands, - // Quick Build and plugin builds still look for output in the project folder. - if (BuildOutputLocation.isAvailable()) { - addPreference(RelocateBuildOutput()) - } + addPreference(RelocateBuildOutput()) } } diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index 891aeb6f6a..3b51497ee9 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -5,7 +5,6 @@ import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.api.Workspace import com.itsaky.androidide.projects.models.buildDir import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR -import com.itsaky.androidide.utils.FeatureFlags import com.itsaky.androidide.utils.ProjectStorageKey import com.itsaky.androidide.utils.canonicalProjectLocation import com.itsaky.androidide.utils.isDeletedProjectDirectory @@ -35,11 +34,8 @@ object BuildOutputLocation { private const val NO_WATCH_FS = "--no-watch-fs" private const val PROJECT_CACHE_DIR = "--project-cache-dir" - /** Whether the switch exists at all: early access only (REVIEW.md section 12). */ - fun isAvailable(): Boolean = FeatureFlags.isExperimentsEnabled - /** Whether the user currently asks for relocation; applied at the next sync. */ - fun isRequested(): Boolean = isAvailable() && BuildPreferences.relocateBuildOutput + fun isRequested(): Boolean = BuildPreferences.relocateBuildOutput /** * @param noBackupDir the app's `noBackupFilesDir`. @@ -182,7 +178,7 @@ object BuildOutputLocation { ): Boolean? = workspace?.let { isRelocated(it.rootProject.buildDir, treeFor(noBackupDir, projectDir)) } /** - * Whether the switch (or the experiments flag) disagrees with the synced model, so the next + * Whether the switch disagrees with the synced model, so the next * sync would move the build output. False without a model: there is nothing to disagree with. * * @param modelRelocated [modelRelocated] for the open project. diff --git a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt index 647e287844..5c3f7511a3 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt @@ -65,7 +65,6 @@ import com.itsaky.androidide.actions.text.UndoAction import com.itsaky.androidide.plugins.extensions.UIExtension import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager import com.itsaky.androidide.plugins.manager.core.PluginManager -import com.itsaky.androidide.services.builder.BuildOutputLocation /** * Takes care of registering actions to the actions registry for the editor activity. @@ -109,9 +108,7 @@ class EditorActivityActions { registry.registerAction(ReplaceInProjectAction(context, order++)) registry.registerAction(LaunchAppAction(context, order++)) // ADFA-4930: relocated build output keeps the APK out of the project folder. - if (BuildOutputLocation.isAvailable()) { - registry.registerAction(ExportApkAction(context, order++)) - } + registry.registerAction(ExportApkAction(context, order++)) registry.registerAction(DisconnectLogSendersAction(context, order++)) // Plugin contributions diff --git a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md index 24d227fc6c..33018a27c7 100644 --- a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md +++ b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md @@ -87,6 +87,7 @@ Each PR compiles and passes tests on its own; the flag stays off until PR 4. 2. **Consumers.** Quick Build `setup.json` lookup and plugin `.cgp` lookup from the model. Tests for both. About 4 files. 3. **Lifecycle and UX.** Delete-with-project, the orphan sweep, Export APK, strings. About 6 files. 4. **Default on.** Flip the flag after validation. Update `ARCHITECTURE.md` (storage layout) and any user docs. + Validation (2026-10-15/16, posted on ADFA-4930): warm edit builds 48% faster on a 288-source project (Pixel 6 Pro); on an itel A667L, clean builds about 50% faster and small edits unchanged. PR 4 also drops the experiments gate, so the switch and Export APK show for everyone, and gives the sync banner its own wording for a relocation change. ## Validation before default-on diff --git a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt index 9d2b4f8599..730f377121 100644 --- a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt +++ b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt @@ -109,11 +109,10 @@ object BuildPreferences { /** * Whether builds write their output to app-private storage instead of the project folder - * (ADFA-4930). Off until the rest of the IDE reads build output from the new location, and - * honored only while experiments are enabled. + * (ADFA-4930). On by default: builds there are faster than on the FUSE-backed project folder. */ var relocateBuildOutput: Boolean - get() = prefManager.getBoolean(RELOCATE_BUILD_OUTPUT, false) + get() = prefManager.getBoolean(RELOCATE_BUILD_OUTPUT, true) set(value) { prefManager.putBoolean(RELOCATE_BUILD_OUTPUT, value) } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index d433589213..ec593036d2 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -121,6 +121,8 @@ Build Output Quick run Gradle files have changed. Sync the project to update dependencies and settings. + Builds are faster with build output in Code on the Go\'s storage. Sync the project to move it there. + Sync the project to move build output back to the project folder. Preparing Initializing a project may take a few moments. Gradle wrapper is not available.\nInstalling a default one. Gradle v7.4 will be downloaded if necessary. @@ -873,7 +875,7 @@ If enabled, the IDE will (without confirmation) launch the application right after it is installed. Run options Relocate build output - Experimental. Faster repeat builds: build output goes to Code on the Go\'s private storage, not the project folder. Sync to apply. + Faster builds: build output goes to Code on the Go\'s private storage, not the project folder. Sync to apply. Cannot run application. Unable to determine package name. Cannot run application. No application modules found in project. Launch app From eb1a366f51dfcc4cb303264f9e5eec5c09ddefae Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 16 Oct 2026 12:20:49 -0700 Subject: [PATCH 18/43] ADFA-4930: fit the relocation banner at 2x, defer its sync past builds From the PR 4 device test on a Note20: - At font scale 2.0 the relocation banner's Sync button was crushed to an unlabelled sliver. The banner now says "Sync to move build output to faster storage." (or "...back to the project folder.") with a "Not now" button. The shared layout is ADFA-6572. - A Sync tapped while a build held the Gradle slot (Quick Build's prebuild starts soon after a project opens) was refused, and the failed initialization dropped the loaded model. A Sync tapped then is now deferred: the relocation banner says so and comes back when the build ends. The generic sync banner says to sync once the build ends. Also: after a successful initialization whose model builds into the project folder, the project's old relocated tree is deleted, so switching relocation off doesn't leave dead output in app storage. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../editor/ProjectHandlerActivity.kt | 57 ++++++++++++++++--- resources/src/main/res/values/strings.xml | 7 ++- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 2b3b68fbe3..6bad6335f4 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -190,6 +190,9 @@ abstract class ProjectHandlerActivity : /** The banner that offer showed, so withdrawing it never dismisses another one. */ private var buildOutputSyncBanner: Flashbar? = null + + /** A relocation sync the user asked for while a build held the slot; offered again after it. */ + private var buildOutputSyncDeferred = false private var replaceReportFlashbar: Flashbar? = null private val buildViewModel by viewModels() @@ -299,6 +302,17 @@ abstract class ProjectHandlerActivity : } didCompleteLiveOnCreate = true + // ADFA-4930: offer a deferred relocation sync again once no build holds the Gradle slot. + val offerDeferredSync = + Observer { building -> + if (building == false && buildOutputSyncDeferred && !isGradleSlotBusy()) { + buildOutputSyncDeferred = false + reconcileBuildOutputSyncOffer(afterInit = false) + } + } + editorViewModel._isBuildInProgress.observe(this, offerDeferredSync) + editorViewModel._isInternalBuildInProgress.observe(this, offerDeferredSync) + editorViewModel._isSyncNeeded.observe(this) { isSyncNeeded -> if (!isSyncNeeded) { // dismiss if already showing @@ -1230,12 +1244,16 @@ abstract class ProjectHandlerActivity : private fun reconcileBuildOutputSyncOffer(afterInit: Boolean) { activityScope.launch { val path = ProjectManagerImpl.getInstance().projectDirPath.takeIf { it.isNotBlank() } ?: return@launch + val projectDir = File(path) val requested = BuildOutputLocation.isRequested() - val pending = - BuildOutputLocation.syncPending( - BuildOutputLocation.modelRelocated(noBackupFilesDir, File(path)), - requested, - ) + val modelRelocated = BuildOutputLocation.modelRelocated(noBackupFilesDir, projectDir) + if (afterInit && modelRelocated == false) { + // The model builds into the project folder, so a tree from an earlier relocated sync is + // dead weight; a later relocated sync recreates it. + val removed = withContext(Dispatchers.IO) { BuildOutputLocation.removeTreesOf(noBackupFilesDir, projectDir) } + if (removed > 0) log.info("Deleted {} unused build output tree(s) of {}", removed, projectDir) + } + val pending = BuildOutputLocation.syncPending(modelRelocated, requested) withContext(Dispatchers.Main) { if (!pending) { val banner = buildOutputSyncBanner @@ -1248,7 +1266,10 @@ abstract class ProjectHandlerActivity : buildOutputSyncOffered != requested && notifySyncNeeded( onConfirm = ::syncForBuildOutput, + // Short, with a short dismiss label: this banner greets every existing project + // once relocation is on by default, and has to keep its Sync button at 2x font scale. message = if (requested) string.msg_sync_build_output_relocate else string.msg_sync_build_output_restore, + dismissText = string.btn_not_now, ) ) { buildOutputSyncOffered = requested @@ -1258,13 +1279,24 @@ abstract class ProjectHandlerActivity : } } - /** Forgets the offer, so a sync that fails leaves it to be made again on the next resume. */ + /** + * Forgets the offer, so a sync that fails leaves it to be made again on the next resume. While a + * build holds the Gradle slot (Quick Build's prebuild starts soon after a project opens), a sync + * would be refused and drop the loaded model, so the offer is made again when the build ends. + */ private fun syncForBuildOutput() { buildOutputSyncOffered = null buildOutputSyncBanner = null + if (isGradleSlotBusy()) { + buildOutputSyncDeferred = true + flashInfoLong(getString(string.msg_sync_build_output_deferred)) + return + } initializeProject(forceSync = true) } + private fun isGradleSlotBusy(): Boolean = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE)?.isBuildInProgress == true + override fun preDestroy() { syncNotificationFlashbar?.dismiss() syncNotificationFlashbar = null @@ -1343,15 +1375,24 @@ abstract class ProjectHandlerActivity : } fun notifySyncNeeded() { - notifySyncNeeded { initializeProject(forceSync = true) } + notifySyncNeeded { + // A sync would be refused while a build holds the Gradle slot, and drop the loaded model. + if (isGradleSlotBusy()) { + flashInfo(getString(string.msg_sync_after_build)) + } else { + initializeProject(forceSync = true) + } + } } /** * @param message what the banner says; the default is for changed Gradle files. + * @param dismissText the label of the button that dismisses the banner. * @return false when the banner was not shown: no build service, or initializing or building. */ private fun notifySyncNeeded( @StringRes message: Int = string.msg_sync_needed, + @StringRes dismissText: Int = string.btn_ignore_changes, onConfirm: () -> Unit, ): Boolean { val buildService = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) @@ -1372,7 +1413,7 @@ abstract class ProjectHandlerActivity : .positiveActionTapListener { onConfirm() it.dismiss() - }.negativeActionText(string.btn_ignore_changes) + }.negativeActionText(dismissText) .negativeActionTapListener(Flashbar::dismiss) .build() diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index ec593036d2..782f464105 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -121,8 +121,10 @@ Build Output Quick run Gradle files have changed. Sync the project to update dependencies and settings. - Builds are faster with build output in Code on the Go\'s storage. Sync the project to move it there. - Sync the project to move build output back to the project folder. + Sync to move build output to faster storage. + Sync to move build output back to the project folder. + A build is running. You\'ll be asked to sync again when it finishes. + A build is running. Sync once it finishes. Preparing Initializing a project may take a few moments. Gradle wrapper is not available.\nInstalling a default one. Gradle v7.4 will be downloaded if necessary. @@ -794,6 +796,7 @@ Sync Ignore these changes + Not now Fix imports No unresolved class names found Which %1$s? From 819dc05ffcb675c5ff849086cae8971f6d665b8f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 16 Oct 2026 15:07:13 -0700 Subject: [PATCH 19/43] ADFA-4930: keep trees a live session or the model still uses From the #2155 review: - A live Quick Build session survives a plain sync and keeps absolute paths into the tree (the proxy APK, classes, payload). Deleting the unused tree now waits while a session is live; the next project open deletes it. - Trees are keyed by the project's canonical path, so every spelling of it (/sdcard against /storage/emulated/0) names the same tree. The check that decides whether builds relocate and the deletion now agree, and the deletion never removes the tree holding the loaded model's build directory. - The delete and re-offer decisions are pure functions on BuildOutputLocation, covered by BuildOutputLocationTest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../editor/ProjectHandlerActivity.kt | 23 +++++++-- .../services/builder/BuildOutputLocation.kt | 51 ++++++++++++++++--- .../builder/BuildOutputLocationTest.kt | 46 +++++++++++++++++ 3 files changed, 108 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 6bad6335f4..ac43d7b41f 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -77,6 +77,7 @@ import com.itsaky.androidide.plugins.extensions.ProjectSearchSection import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.builder.BuildService +import com.itsaky.androidide.projects.models.buildDir import com.itsaky.androidide.projects.models.projectDir import com.itsaky.androidide.quickbuild.AutostartBuild import com.itsaky.androidide.quickbuild.GenerateSourcesDeferral @@ -305,7 +306,7 @@ abstract class ProjectHandlerActivity : // ADFA-4930: offer a deferred relocation sync again once no build holds the Gradle slot. val offerDeferredSync = Observer { building -> - if (building == false && buildOutputSyncDeferred && !isGradleSlotBusy()) { + if (BuildOutputLocation.shouldOfferDeferredSync(building, buildOutputSyncDeferred, isGradleSlotBusy())) { buildOutputSyncDeferred = false reconcileBuildOutputSyncOffer(afterInit = false) } @@ -1247,10 +1248,15 @@ abstract class ProjectHandlerActivity : val projectDir = File(path) val requested = BuildOutputLocation.isRequested() val modelRelocated = BuildOutputLocation.modelRelocated(noBackupFilesDir, projectDir) - if (afterInit && modelRelocated == false) { - // The model builds into the project folder, so a tree from an earlier relocated sync is - // dead weight; a later relocated sync recreates it. - val removed = withContext(Dispatchers.IO) { BuildOutputLocation.removeTreesOf(noBackupFilesDir, projectDir) } + if (BuildOutputLocation.shouldDeleteTrees(afterInit, modelRelocated, isQuickBuildSessionLive())) { + // Never the tree the loaded model builds into, however its path is spelled. + val keep = + ProjectManagerImpl + .getInstance() + .workspace + ?.rootProject + ?.buildDir + val removed = withContext(Dispatchers.IO) { BuildOutputLocation.removeTreesOf(noBackupFilesDir, projectDir, keep) } if (removed > 0) log.info("Deleted {} unused build output tree(s) of {}", removed, projectDir) } val pending = BuildOutputLocation.syncPending(modelRelocated, requested) @@ -1295,6 +1301,13 @@ abstract class ProjectHandlerActivity : initializeProject(forceSync = true) } + /** Prebuilding writes to the current model's build directory; every later state uses the proxy's paths. */ + private fun isQuickBuildSessionLive(): Boolean = + when (quickBuildSessionManager()?.state?.value) { + null, is QuickBuildSessionState.Idle, is QuickBuildSessionState.Prebuilding -> false + else -> true + } + private fun isGradleSlotBusy(): Boolean = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE)?.isBuildInProgress == true override fun preDestroy() { diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index 3b51497ee9..481507bda1 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -14,7 +14,8 @@ import java.io.IOException /** * Where a project's Gradle build output goes when it is moved off the FUSE-backed project folder * (ADFA-4930): `/gradle-build//`, beside Quick Build's scratch and - * keyed the same way. App-private f2fs makes warm builds 24-35% faster on the devices measured. + * keyed by the same function, from the project's canonical path. App-private f2fs makes warm builds + * up to about twice as fast on the devices measured. * * The synced project model is the record of where output goes. A sync applies the user's choice; * a build follows the model's root build directory ([modelRelocated]), not the live switch, so it @@ -40,12 +41,13 @@ object BuildOutputLocation { /** * @param noBackupDir the app's `noBackupFilesDir`. * @param projectDir the project's root directory. - * @return the project's tree; computed, not created. + * @return the project's tree; computed, not created. Keyed by the canonical path, so every spelling + * of the project's path (`/sdcard` against `/storage/emulated/0`) names the same tree. */ fun treeFor( noBackupDir: File, projectDir: File, - ): File = File(File(noBackupDir, DIR_NAME), ProjectStorageKey.of(projectDir)) + ): File = File(File(noBackupDir, DIR_NAME), ProjectStorageKey.of(File(projectDir.canonicalProjectLocation()))) /** * The Gradle arguments that send a project's build output into [tree]: every project's build @@ -99,14 +101,19 @@ object BuildOutputLocation { * key, so a path spelled differently (`/sdcard` against `/storage/emulated/0`) still matches. * Call it when the user deletes the project. * + * @param keep a directory whose tree must survive, such as the loaded model's build directory. * @return how many trees were deleted. */ fun removeTreesOf( noBackupDir: File, projectDir: File, + keep: File? = null, ): Int { val location = projectDir.canonicalProjectLocation() - return deleteTreesWhere(noBackupDir) { it.canonicalProjectLocation() == location } + val kept = keep?.absoluteFile?.normalize() + return deleteTreesWhere(noBackupDir) { tree, project -> + project.canonicalProjectLocation() == location && (kept == null || !kept.startsWith(tree.absoluteFile.normalize())) + } } /** @@ -116,16 +123,16 @@ object BuildOutputLocation { * * @return how many trees were deleted. */ - fun sweep(noBackupDir: File): Int = deleteTreesWhere(noBackupDir) { it.isDeletedProjectDirectory() } + fun sweep(noBackupDir: File): Int = deleteTreesWhere(noBackupDir) { _, project -> project.isDeletedProjectDirectory() } private fun deleteTreesWhere( noBackupDir: File, - isOrphan: (File) -> Boolean, + isOrphan: (tree: File, project: File) -> Boolean, ): Int { val trees = File(noBackupDir, DIR_NAME).listFiles { file -> file.isDirectory } ?: return 0 return trees.count { tree -> val project = projectOf(tree) - project != null && isOrphan(project) && deleteTree(tree) + project != null && isOrphan(tree, project) && deleteTree(tree) } } @@ -152,6 +159,36 @@ object BuildOutputLocation { null } + /** + * Whether a successful initialization should delete the project's relocated output: its model + * builds into the project folder, so the tree is dead weight. Not while a Quick Build session is + * live, which keeps absolute paths into the tree (the proxy APK, its classes and payload) until + * it ends; the next project open deletes it instead. + * + * @param afterInit an initialization, with or without a sync, just succeeded. + * @param modelRelocated [modelRelocated] for the loaded model. + * @param quickBuildLive a Quick Build session is live. + */ + fun shouldDeleteTrees( + afterInit: Boolean, + modelRelocated: Boolean?, + quickBuildLive: Boolean, + ): Boolean = afterInit && modelRelocated == false && !quickBuildLive + + /** + * Whether to offer a relocation sync the user deferred: it was tapped while a build held the + * Gradle slot, and now no build holds it. + * + * @param building the user-visible or internal build flag that just changed. + * @param deferred a sync was deferred. + * @param slotBusy any build still holds the Gradle slot. + */ + fun shouldOfferDeferredSync( + building: Boolean?, + deferred: Boolean, + slotBusy: Boolean, + ): Boolean = building == false && deferred && !slotBusy + /** * Whether a synced model's build directory lies in [tree], meaning the sync that produced the * model relocated build output. diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index 257537d7d2..67e3843a4a 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -219,6 +219,52 @@ class BuildOutputLocationTest { File(it, "main/app-1234/outputs").mkdirs() } + @Test + fun `every spelling of a project's path names the same tree`() { + val noBackup = tmp.newFolder("no_backup") + val app = tmp.newFolder("storage", "app") + val link = File(tmp.root, "sdcard").toPath() + Files.createSymbolicLink(link, app.parentFile.toPath()) + + assertThat(BuildOutputLocation.treeFor(noBackup, File(link.toFile(), "app"))) + .isEqualTo(BuildOutputLocation.treeFor(noBackup, app)) + } + + @Test + fun `the tree the loaded model builds into is never deleted`() { + val noBackup = tmp.newFolder("no_backup") + val project = tmp.newFolder("projects", "app") + val tree = treeOf(noBackup, project) + + assertThat(BuildOutputLocation.removeTreesOf(noBackup, project, keep = File(tree, "main/root-5678"))).isEqualTo(0) + assertThat(tree.isDirectory).isTrue() + + assertThat(BuildOutputLocation.removeTreesOf(noBackup, project, keep = File(project, "build"))).isEqualTo(1) + assertThat(tree.exists()).isFalse() + } + + @Test + fun `trees are deleted only after an initialization whose model builds into the project folder`() { + assertThat(BuildOutputLocation.shouldDeleteTrees(afterInit = true, modelRelocated = false, quickBuildLive = false)).isTrue() + assertThat(BuildOutputLocation.shouldDeleteTrees(afterInit = false, modelRelocated = false, quickBuildLive = false)).isFalse() + assertThat(BuildOutputLocation.shouldDeleteTrees(afterInit = true, modelRelocated = true, quickBuildLive = false)).isFalse() + assertThat(BuildOutputLocation.shouldDeleteTrees(afterInit = true, modelRelocated = null, quickBuildLive = false)).isFalse() + } + + @Test + fun `a live Quick Build session keeps the tree it still uses`() { + assertThat(BuildOutputLocation.shouldDeleteTrees(afterInit = true, modelRelocated = false, quickBuildLive = true)).isFalse() + } + + @Test + fun `a deferred sync is offered again only once no build holds the slot`() { + assertThat(BuildOutputLocation.shouldOfferDeferredSync(building = false, deferred = true, slotBusy = false)).isTrue() + assertThat(BuildOutputLocation.shouldOfferDeferredSync(building = true, deferred = true, slotBusy = true)).isFalse() + // The user-visible flag dropped, but Quick Build's prebuild still holds the slot. + assertThat(BuildOutputLocation.shouldOfferDeferredSync(building = false, deferred = true, slotBusy = true)).isFalse() + assertThat(BuildOutputLocation.shouldOfferDeferredSync(building = false, deferred = false, slotBusy = false)).isFalse() + } + private fun workspace(rootBuildDir: File): Workspace { val root = mockk() every { root.buildDirPath } returns rootBuildDir.path From 7fc4ccd2b35b84d1c456b52d861f75b9ed334fc2 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 16 Oct 2026 15:42:40 -0700 Subject: [PATCH 20/43] ADFA-4930: record relocated builds in build_completed From the #2155 review (F04): nothing recorded whether a build's output was relocated, so build durations couldn't be split by output location once it is on for everyone. build_completed, which carries the duration, gains `relocated_output`. It goes there rather than on build_started, which already sends up to 28 parameters with some tuner configurations, over Firebase's limit of 25. The service records each relocating build (not sync) by build ID when it prepares it, and takes the flag back when the build completes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../analytics/gradle/BuildCompletedMetric.kt | 3 +++ .../services/builder/BuildOutputLocation.kt | 3 +++ .../androidide/services/builder/GradleBuildService.kt | 11 ++++++++++- .../androidide/analytics/BuildCompletedMetricTest.kt | 9 +++++++++ .../services/builder/BuildOutputLocationTest.kt | 6 ++++++ 5 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/itsaky/androidide/analytics/gradle/BuildCompletedMetric.kt b/app/src/main/java/com/itsaky/androidide/analytics/gradle/BuildCompletedMetric.kt index f216a7e0ac..5b728b8237 100644 --- a/app/src/main/java/com/itsaky/androidide/analytics/gradle/BuildCompletedMetric.kt +++ b/app/src/main/java/com/itsaky/androidide/analytics/gradle/BuildCompletedMetric.kt @@ -12,6 +12,8 @@ class BuildCompletedMetric( val buildType: String, val isSuccess: Boolean, val buildResult: BuildResult, + /** Whether the build wrote its output to app storage (ADFA-4930), so durations can be split by it. */ + val relocatedOutput: Boolean = false, ) : BuildMetric() { override val eventName = "build_completed" @@ -20,6 +22,7 @@ class BuildCompletedMetric( putString("build_type", buildType) putBoolean("success", isSuccess) putLong("duration_ms", buildResult.durationMs) + putBoolean("relocated_output", relocatedOutput) // Why it was not successful, which the metric used to drop. A build the user stopped // and a build that broke both arrive as success=false, so without this the two are // indistinguishable and every build-success rate counts deliberate cancels as diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index 481507bda1..76d0be4b7e 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -69,6 +69,9 @@ object BuildOutputLocation { NO_WATCH_FS, ) + /** Whether a sync's or build's arguments relocate its output, from [gradleArgs]. */ + fun isRelocating(args: List): Boolean = args.any { it.startsWith("-P$PROPERTY_BUILD_OUTPUT_DIR=") } + /** * Drops `--watch-fs` from a build's complete argument list when it also relocates the project * cache, so the build does not fail whatever order the tuner's and these arguments are in. diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index 9188294e05..f861096ac4 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -95,6 +95,7 @@ import java.util.Objects import java.util.UUID import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionException +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeoutException import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong @@ -258,6 +259,9 @@ class GradleBuildService : @Volatile private var tuningConfig: GradleTuningConfig? = null + /** Builds (not syncs) whose output was relocated (ADFA-4930), until their build_completed metric is sent. */ + private val relocatedBuildIds: MutableSet = ConcurrentHashMap.newKeySet() + private val buildServiceScope = CoroutineScope( Dispatchers.Default + CoroutineName("GradleBuildService"), @@ -556,12 +560,16 @@ class GradleBuildService : val currentTuningConfig = tuningConfig var newTuningConfig: GradleTuningConfig? = null + val isSync = buildInfo.buildId.runType == BuildRunType.ProjectSync + @Suppress("SimplifyBooleanWithConstants") val extraArgs = getGradleExtraArgs( enableJdwp = JdwpOptions.JDWP_ENABLED && isDebugBuild, - isSync = buildInfo.buildId.runType == BuildRunType.ProjectSync, + isSync = isSync, ) + // Syncs report no build_completed metric, so only builds are recorded. + if (!isSync && BuildOutputLocation.isRelocating(extraArgs)) relocatedBuildIds += buildInfo.buildId var buildParams = if (FeatureFlags.isExperimentsEnabled) { @@ -674,6 +682,7 @@ class GradleBuildService : isSuccess = isSuccess, buildType = buildType, buildResult = result, + relocatedOutput = relocatedBuildIds.remove(result.buildId), ), ) diff --git a/app/src/test/java/com/itsaky/androidide/analytics/BuildCompletedMetricTest.kt b/app/src/test/java/com/itsaky/androidide/analytics/BuildCompletedMetricTest.kt index 7a4671d0eb..4aa385f1da 100644 --- a/app/src/test/java/com/itsaky/androidide/analytics/BuildCompletedMetricTest.kt +++ b/app/src/test/java/com/itsaky/androidide/analytics/BuildCompletedMetricTest.kt @@ -38,6 +38,7 @@ class BuildCompletedMetricTest { private fun metric( isSuccess: Boolean, failure: TaskExecutionResult.Failure?, + relocatedOutput: Boolean = false, ) = BuildCompletedMetric( buildId = BuildId.Unknown, buildType = "assemble", @@ -49,8 +50,16 @@ class BuildCompletedMetricTest { durationMs = 1_234L, failure = failure, ), + relocatedOutput = relocatedOutput, ) + @Test + fun `the duration can be split by where the build wrote its output`() { + assertThat(metric(isSuccess = true, failure = null, relocatedOutput = true).asBundle().getBoolean("relocated_output", false)) + .isTrue() + assertThat(metric(isSuccess = true, failure = null).asBundle().getBoolean("relocated_output", true)).isFalse() + } + @Test fun `a cancelled build carries the reason that says so`() { val bundle = metric(isSuccess = false, failure = TaskExecutionResult.Failure.BUILD_CANCELLED).asBundle() diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index 67e3843a4a..cfc32d45a2 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -265,6 +265,12 @@ class BuildOutputLocationTest { assertThat(BuildOutputLocation.shouldOfferDeferredSync(building = false, deferred = false, slotBusy = false)).isFalse() } + @Test + fun `relocating arguments are recognised and others are not`() { + assertThat(BuildOutputLocation.isRelocating(BuildOutputLocation.gradleArgs(File(tmp.root, "tree")))).isTrue() + assertThat(BuildOutputLocation.isRelocating(listOf("--parallel", "--watch-fs", "-Pother=1"))).isFalse() + } + private fun workspace(rootBuildDir: File): Workspace { val root = mockk() every { root.buildDirPath } returns rootBuildDir.path From 949e076f9e88497e2d51a6df58d0fbb41d11bb4e Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 16 Oct 2026 15:48:50 -0700 Subject: [PATCH 21/43] ADFA-4930: report relocated syncs in build_completed too From the #2155 re-review (F05): 7fc4ccd recorded only builds, on the wrong premise that syncs send no build_completed. They do: the tooling server reports a sync's completion with the sync's own build ID (ToolingApiServerImpl.doInitialize), so relocated syncs were reported as relocated_output=false. The bookkeeping moves into RelocatedBuilds, which records every relocating sync or build when it is prepared and forgets it when it completes. RelocatedBuildsTest covers the round trip; its sync case fails if syncs are excluded again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../services/builder/GradleBuildService.kt | 9 +-- .../services/builder/RelocatedBuilds.kt | 25 ++++++++ .../services/builder/RelocatedBuildsTest.kt | 59 +++++++++++++++++++ 3 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/services/builder/RelocatedBuilds.kt create mode 100644 app/src/test/java/com/itsaky/androidide/services/builder/RelocatedBuildsTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index f861096ac4..a7c9dc558d 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -95,7 +95,6 @@ import java.util.Objects import java.util.UUID import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionException -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeoutException import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong @@ -259,8 +258,7 @@ class GradleBuildService : @Volatile private var tuningConfig: GradleTuningConfig? = null - /** Builds (not syncs) whose output was relocated (ADFA-4930), until their build_completed metric is sent. */ - private val relocatedBuildIds: MutableSet = ConcurrentHashMap.newKeySet() + private val relocatedBuilds = RelocatedBuilds() private val buildServiceScope = CoroutineScope( @@ -568,8 +566,7 @@ class GradleBuildService : enableJdwp = JdwpOptions.JDWP_ENABLED && isDebugBuild, isSync = isSync, ) - // Syncs report no build_completed metric, so only builds are recorded. - if (!isSync && BuildOutputLocation.isRelocating(extraArgs)) relocatedBuildIds += buildInfo.buildId + relocatedBuilds.prepared(buildInfo, extraArgs) var buildParams = if (FeatureFlags.isExperimentsEnabled) { @@ -682,7 +679,7 @@ class GradleBuildService : isSuccess = isSuccess, buildType = buildType, buildResult = result, - relocatedOutput = relocatedBuildIds.remove(result.buildId), + relocatedOutput = relocatedBuilds.completed(result.buildId), ), ) diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/RelocatedBuilds.kt b/app/src/main/java/com/itsaky/androidide/services/builder/RelocatedBuilds.kt new file mode 100644 index 0000000000..67117f66ba --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/services/builder/RelocatedBuilds.kt @@ -0,0 +1,25 @@ +package com.itsaky.androidide.services.builder + +import com.itsaky.androidide.tooling.api.messages.BuildId +import com.itsaky.androidide.tooling.api.messages.result.BuildInfo +import java.util.concurrent.ConcurrentHashMap + +/** + * Which syncs and builds relocated their output (ADFA-4930), from preparation to their + * `build_completed` metric. Syncs count too: the tooling server reports a sync's completion with + * the sync's own build ID, like a build's. + */ +internal class RelocatedBuilds { + private val ids: MutableSet = ConcurrentHashMap.newKeySet() + + /** Records [build] when its Gradle [args] relocate its output. */ + fun prepared( + build: BuildInfo, + args: List, + ) { + if (BuildOutputLocation.isRelocating(args)) ids += build.buildId + } + + /** Whether the completed build relocated its output; forgets it either way. */ + fun completed(buildId: BuildId): Boolean = ids.remove(buildId) +} diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/RelocatedBuildsTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/RelocatedBuildsTest.kt new file mode 100644 index 0000000000..94dda0cde7 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/services/builder/RelocatedBuildsTest.kt @@ -0,0 +1,59 @@ +package com.itsaky.androidide.services.builder + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.tooling.api.messages.BuildId +import com.itsaky.androidide.tooling.api.messages.BuildRunType +import com.itsaky.androidide.tooling.api.messages.result.BuildInfo +import org.junit.Test +import java.io.File + +class RelocatedBuildsTest { + private val relocating = BuildOutputLocation.gradleArgs(File("/no_backup/gradle-build/app-1234")) + + private fun info( + id: Long, + runType: BuildRunType, + ) = BuildInfo(BuildId("session", id, runType), emptyList()) + + @Test + fun `a relocated build is reported as relocated when it completes`() { + val builds = RelocatedBuilds() + val build = info(1, BuildRunType.TaskRun) + + builds.prepared(build, relocating) + + assertThat(builds.completed(build.buildId)).isTrue() + } + + @Test + fun `a relocated sync is reported as relocated when it completes`() { + // The tooling server reports a sync's completion with the sync's own ID, like a build's. + val builds = RelocatedBuilds() + val sync = info(2, BuildRunType.ProjectSync) + + builds.prepared(sync, relocating) + + assertThat(builds.completed(sync.buildId)).isTrue() + } + + @Test + fun `a build that writes to the project folder is not`() { + val builds = RelocatedBuilds() + val build = info(3, BuildRunType.TaskRun) + + builds.prepared(build, listOf("--parallel", "--watch-fs")) + + assertThat(builds.completed(build.buildId)).isFalse() + } + + @Test + fun `each build is reported once and then forgotten`() { + val builds = RelocatedBuilds() + val build = info(4, BuildRunType.TaskRun) + builds.prepared(build, relocating) + + assertThat(builds.completed(build.buildId)).isTrue() + assertThat(builds.completed(build.buildId)).isFalse() + assertThat(builds.completed(info(5, BuildRunType.TaskRun).buildId)).isFalse() + } +} From 34b6603744e78fcafa9dfbfd1d178aa9045f4d0a Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 16 Oct 2026 17:55:49 -0700 Subject: [PATCH 22/43] ADFA-4930: sweep trees of projects deleted with their folder The startup sweep counted a project as gone only while its parent folder existed, so deleting a folder of projects in a file manager left every one of their trees in app storage until Clear storage. With the parent missing too, the nearest folder that exists now decides: a readable folder on a volume under /storage means the folders were deleted, while /storage itself means the project's volume is not mounted, and its tree is kept. Recents keeps its own rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../services/builder/BuildOutputLocation.kt | 35 +++++++++++++++++-- .../builder/BuildOutputLocationTest.kt | 25 +++++++++++-- ...6-10-14-adfa-4930-build-output-off-fuse.md | 2 +- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index 891aeb6f6a..227673eaab 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -35,6 +35,9 @@ object BuildOutputLocation { private const val NO_WATCH_FS = "--no-watch-fs" private const val PROJECT_CACHE_DIR = "--project-cache-dir" + /** Where Android mounts storage volumes: internal storage, and each SD card or USB drive. */ + private val STORAGE_ROOT = File("/storage") + /** Whether the switch exists at all: early access only (REVIEW.md section 12). */ fun isAvailable(): Boolean = FeatureFlags.isExperimentsEnabled @@ -115,12 +118,38 @@ object BuildOutputLocation { /** * Deletes the trees of projects that no longer exist, such as a project deleted, moved or renamed - * outside the IDE. A project on a volume that is not mounted is not gone, and a tree without a - * marker is left alone. Runs on a background thread at startup. + * outside the IDE, or deleted with the folder holding it. A project on a volume that is not + * mounted is not gone, and a tree without a marker is left alone. Runs on a background thread at + * startup. * + * @param volumesRoot where storage volumes are mounted; tests pass their own. * @return how many trees were deleted. */ - fun sweep(noBackupDir: File): Int = deleteTreesWhere(noBackupDir) { it.isDeletedProjectDirectory() } + fun sweep( + noBackupDir: File, + volumesRoot: File = STORAGE_ROOT, + ): Int = deleteTreesWhere(noBackupDir) { isGone(it, volumesRoot) } + + /** + * Whether [project] was deleted. With its parent folder still there, Recents' rule decides. With + * the parent missing too, the nearest folder that exists decides: a readable folder on a volume + * means the folders were deleted, while [volumesRoot] itself means the volume is not mounted. + */ + private fun isGone( + project: File, + volumesRoot: File, + ): Boolean { + if (project.isDeletedProjectDirectory()) return true + if (project.exists()) return false + val nearest = generateSequence(project.parentFile) { it.parentFile }.firstOrNull { it.exists() } ?: return false + return try { + val ancestor = nearest.canonicalFile + val root = volumesRoot.canonicalFile + ancestor != root && ancestor.startsWith(root) && ancestor.isDirectory && ancestor.canRead() + } catch (e: IOException) { + false + } + } private fun deleteTreesWhere( noBackupDir: File, diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index 257537d7d2..e7e4177fea 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -174,19 +174,38 @@ class BuildOutputLocationTest { val deleted = tmp.newFolder("projects", "deleted") val keptTree = treeOf(noBackup, kept) val deletedTree = treeOf(noBackup, deleted) - val unmountedTree = treeOf(noBackup, File(tmp.root, "unmounted-volume/app")) + val volumes = tmp.newFolder("storage") + val unmountedTree = treeOf(noBackup, File(volumes, "usb-drive/Projects/app")) val markerless = File(noBackup, "${BuildOutputLocation.DIR_NAME}/no-marker").apply { mkdirs() } deleted.deleteRecursively() - assertThat(BuildOutputLocation.sweep(noBackup)).isEqualTo(1) + assertThat(BuildOutputLocation.sweep(noBackup, volumes)).isEqualTo(1) assertThat(deletedTree.exists()).isFalse() assertThat(keptTree.isDirectory).isTrue() - // A missing parent reads as a volume that is not mounted, not as a deleted project. + // Only the volumes root is left of the path: the volume is not mounted, not deleted. assertThat(unmountedTree.isDirectory).isTrue() assertThat(markerless.isDirectory).isTrue() } + @Test + fun `the sweep deletes the trees of projects deleted with their folder`() { + val noBackup = tmp.newFolder("no_backup") + val volumes = tmp.newFolder("storage") + val experiments = tmp.newFolder("storage", "emulated", "Projects", "Experiments") + val first = treeOf(noBackup, File(experiments, "first").apply { mkdirs() }) + val second = treeOf(noBackup, File(experiments, "nested/second").apply { mkdirs() }) + val outsideVolumes = treeOf(noBackup, File(tmp.root, "elsewhere/gone/app")) + experiments.deleteRecursively() + + assertThat(BuildOutputLocation.sweep(noBackup, volumes)).isEqualTo(2) + + assertThat(first.exists()).isFalse() + assertThat(second.exists()).isFalse() + // Off the volumes, a missing parent proves nothing, so the tree stays. + assertThat(outsideVolumes.isDirectory).isTrue() + } + @Test fun `a deletion that stops part-way keeps the marker for the next sweep`() { val noBackup = tmp.newFolder("no_backup") diff --git a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md index 24d227fc6c..a50ae4798c 100644 --- a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md +++ b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md @@ -67,7 +67,7 @@ Measured 2026-10-14 with the IDE's Gradle arguments, same template project on bo ### Lifecycle - **Project deleted in the IDE**: both deletion paths (Recents and the Delete projects screen) go through `RecentProjectsViewModel`, which deletes the project's trees too. Trees are matched by their `project-path` marker, not by key: deletion uses the canonical path and builds use `projectDirPath`, so a symlinked spelling would key differently. -- **Orphans** (project moved, renamed or deleted outside the IDE): at startup, off the main thread, remove trees whose project no longer exists while its parent folder does, so a volume that is not mounted doesn't count. Trees without a marker are left alone. Unlike `QuickBuildScratch.sweep`, which clears every tree, this keeps the output of live projects. +- **Orphans** (project moved, renamed or deleted outside the IDE): at startup, off the main thread, remove trees whose project no longer exists. A project counts as gone when its parent folder exists, or, with the parent deleted too, when the nearest folder that exists is a readable folder on a volume under `/storage`. A volume that is not mounted leaves only `/storage` itself, so it doesn't count. Trees without a marker are left alone. Unlike `QuickBuildScratch.sweep`, which clears every tree, this keeps the output of live projects. - **Existing `build/` folders in projects**: leave them. They go stale but do no harm. A later "free space" action can offer to remove them. - **Storage**: output now counts as app data in Settings, and "Clear storage" removes it (the next build is a full one). Uninstalling removes it as well; sources are unaffected. From 5f0afab668eaca335de83d2ff555d05969c8e3c9 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 16 Oct 2026 18:25:57 -0700 Subject: [PATCH 23/43] ADFA-4930: record the canonical project path in the marker A project opened through a symlinked root recorded the link in its tree's marker. Recents deletes the real folder, which leaves the link dangling, so the marker no longer canonicalized to the deleted project and delete-with-project missed the tree until the next startup sweep. The marker now holds the canonical path, the same spelling deletion compares against. Found by CodeRabbit on #2151. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../services/builder/BuildOutputLocation.kt | 7 ++++--- .../builder/BuildOutputLocationTest.kt | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index 227673eaab..c5ae1cf426 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -27,7 +27,7 @@ object BuildOutputLocation { /** Parent of every project's tree, under `noBackupFilesDir`. */ const val DIR_NAME = "gradle-build" - /** Holds the project's path, so a sweep can find trees whose project is gone. */ + /** Holds the project's canonical path, so a sweep can find trees whose project is gone. */ const val PROJECT_PATH_FILE = "project-path" private const val KOTLIN_PERSISTENT_DIR = "kotlin.project.persistent.dir" @@ -81,7 +81,8 @@ object BuildOutputLocation { fun withoutWatchConflict(args: List): List = if (PROJECT_CACHE_DIR in args) args.filterNot { it == WATCH_FS } else args /** - * Creates [tree] and records [projectDir] in it. + * Creates [tree] and records [projectDir] in it, canonically: a project deleted through its real + * path must still match a marker written while it was opened through a symlink. * * @return false when [tree] or its marker cannot be written; a sync then keeps output in the * project folder. @@ -92,7 +93,7 @@ object BuildOutputLocation { ): Boolean { if (!tree.isDirectory && !tree.mkdirs()) return false val marker = File(tree, PROJECT_PATH_FILE) - val path = projectDir.absolutePath + val path = projectDir.canonicalProjectLocation() return try { if (!marker.isFile || marker.readText() != path) marker.writeText(path) true diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index e7e4177fea..a76bf9b556 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -59,14 +59,14 @@ class BuildOutputLocationTest { } @Test - fun `prepare creates the tree and records the project path`() { + fun `prepare creates the tree and records the canonical project path`() { val tree = File(tmp.root, "gradle-build/app-1234") val project = File(tmp.root, "projects/app") assertThat(BuildOutputLocation.prepare(tree, project)).isTrue() assertThat(tree.isDirectory).isTrue() - assertThat(File(tree, BuildOutputLocation.PROJECT_PATH_FILE).readText()).isEqualTo(project.absolutePath) + assertThat(File(tree, BuildOutputLocation.PROJECT_PATH_FILE).readText()).isEqualTo(project.canonicalPath) } @Test @@ -77,7 +77,7 @@ class BuildOutputLocationTest { assertThat(BuildOutputLocation.prepare(tree, project)).isTrue() - assertThat(File(tree, BuildOutputLocation.PROJECT_PATH_FILE).readText()).isEqualTo(project.absolutePath) + assertThat(File(tree, BuildOutputLocation.PROJECT_PATH_FILE).readText()).isEqualTo(project.canonicalPath) } @Test @@ -167,6 +167,19 @@ class BuildOutputLocationTest { assertThat(tree.exists()).isFalse() } + @Test + fun `deleting a project opened through a symlinked root deletes its tree`() { + val noBackup = tmp.newFolder("no_backup") + val real = tmp.newFolder("real", "app") + val alias = File(tmp.root, "alias-app").also { Files.createSymbolicLink(it.toPath(), real.toPath()) } + val tree = treeOf(noBackup, alias) + // Recents deletes the real folder, which leaves the alias dangling. + real.deleteRecursively() + + assertThat(BuildOutputLocation.removeTreesOf(noBackup, real)).isEqualTo(1) + assertThat(tree.exists()).isFalse() + } + @Test fun `the sweep deletes the trees of deleted projects only`() { val noBackup = tmp.newFolder("no_backup") From d9cbebefce95649629aa2c2abd7f3b378414a9c6 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 16 Oct 2026 18:56:26 -0700 Subject: [PATCH 24/43] ADFA-4930: offer a deferred relocation sync once the build frees the slot A relocation sync tapped while a build held the Gradle slot was offered again when the build flags dropped. The build's listener clears those flags from a main-thread post, and the slot is freed on another thread once the executeTasks result arrives; the two are not ordered. When the listener ran first, the slot still looked busy, nothing checked again, and the toast's promise to ask again went unkept until the next resume. The offer now waits on the slot itself, which GradleBuildService exposes as a StateFlow. Found by Hal Eisen on #2155. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../editor/ProjectHandlerActivity.kt | 25 +++++------- .../services/builder/BuildOutputLocation.kt | 29 +++++++++----- .../services/builder/GradleBuildService.kt | 18 ++++++--- .../builder/BuildOutputLocationTest.kt | 39 +++++++++++++++---- 4 files changed, 74 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index ac43d7b41f..fd5da7de22 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -148,6 +148,7 @@ import io.github.rosemoe.sora.util.IntPair import io.sentry.Sentry import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.future.await import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -192,8 +193,8 @@ abstract class ProjectHandlerActivity : /** The banner that offer showed, so withdrawing it never dismisses another one. */ private var buildOutputSyncBanner: Flashbar? = null - /** A relocation sync the user asked for while a build held the slot; offered again after it. */ - private var buildOutputSyncDeferred = false + /** Offers again a relocation sync the user asked for while a build held the slot. */ + private var buildOutputSyncDeferral: Job? = null private var replaceReportFlashbar: Flashbar? = null private val buildViewModel by viewModels() @@ -303,17 +304,6 @@ abstract class ProjectHandlerActivity : } didCompleteLiveOnCreate = true - // ADFA-4930: offer a deferred relocation sync again once no build holds the Gradle slot. - val offerDeferredSync = - Observer { building -> - if (BuildOutputLocation.shouldOfferDeferredSync(building, buildOutputSyncDeferred, isGradleSlotBusy())) { - buildOutputSyncDeferred = false - reconcileBuildOutputSyncOffer(afterInit = false) - } - } - editorViewModel._isBuildInProgress.observe(this, offerDeferredSync) - editorViewModel._isInternalBuildInProgress.observe(this, offerDeferredSync) - editorViewModel._isSyncNeeded.observe(this) { isSyncNeeded -> if (!isSyncNeeded) { // dismiss if already showing @@ -1293,9 +1283,14 @@ abstract class ProjectHandlerActivity : private fun syncForBuildOutput() { buildOutputSyncOffered = null buildOutputSyncBanner = null - if (isGradleSlotBusy()) { - buildOutputSyncDeferred = true + val service = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) as? GradleBuildService + if (service?.isBuildInProgress == true) { flashInfoLong(getString(string.msg_sync_build_output_deferred)) + buildOutputSyncDeferral?.cancel() + buildOutputSyncDeferral = + BuildOutputLocation.offerWhenSlotFree(activityScope, service.buildSlotBusy) { + reconcileBuildOutputSyncOffer(afterInit = false) + } return } initializeProject(forceSync = true) diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index f529887e83..cbbf35c2df 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -8,6 +8,11 @@ import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPU import com.itsaky.androidide.utils.ProjectStorageKey import com.itsaky.androidide.utils.canonicalProjectLocation import com.itsaky.androidide.utils.isDeletedProjectDirectory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import java.io.File import java.io.IOException @@ -209,18 +214,22 @@ object BuildOutputLocation { ): Boolean = afterInit && modelRelocated == false && !quickBuildLive /** - * Whether to offer a relocation sync the user deferred: it was tapped while a build held the - * Gradle slot, and now no build holds it. + * Offers a relocation sync the user deferred, once no build holds the Gradle slot. Waits on the + * slot itself: a finished build's listener can run before the build frees the slot, so a check + * made when the build flags drop can still find it busy, and nothing would check again. * - * @param building the user-visible or internal build flag that just changed. - * @param deferred a sync was deferred. - * @param slotBusy any build still holds the Gradle slot. + * @param slotBusy whether a build holds the Gradle slot. + * @param offer makes the offer again. */ - fun shouldOfferDeferredSync( - building: Boolean?, - deferred: Boolean, - slotBusy: Boolean, - ): Boolean = building == false && deferred && !slotBusy + fun offerWhenSlotFree( + scope: CoroutineScope, + slotBusy: StateFlow, + offer: () -> Unit, + ): Job = + scope.launch { + slotBusy.first { !it } + offer() + } /** * Whether a synced model's build directory lies in [tree], meaning the sync that produced the diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index a7c9dc558d..b95ad234e8 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -82,6 +82,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.future.await import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -96,7 +99,6 @@ import java.util.UUID import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionException import java.util.concurrent.TimeoutException -import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import kotlin.coroutines.cancellation.CancellationException @@ -115,10 +117,16 @@ class GradleBuildService : private var isToolingServerStarted = false // Atomic: two callers may race for the slot, and Quick Build's pre-check reads it cross-thread. - private val buildSlot = AtomicBoolean(false) + private val buildSlot = MutableStateFlow(false) + + /** + * [isBuildInProgress] as a flow. A finished build's listener can run before the build frees the + * slot, so a caller waiting for the slot to free watches this instead of the listener. + */ + val buildSlotBusy: StateFlow = buildSlot.asStateFlow() override val isBuildInProgress: Boolean - get() = buildSlot.get() + get() = buildSlot.value // Written only while the slot is held, by the build holding it. @Volatile @@ -931,7 +939,7 @@ class GradleBuildService : dispatch() } catch (e: Throwable) { currentBuildId = null - buildSlot.set(false) + buildSlot.value = false throw e } pendingBuild = future @@ -1017,7 +1025,7 @@ class GradleBuildService : throwable: Throwable?, ): T { currentBuildId = null - buildSlot.set(false) + buildSlot.value = false return result } diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index f501dce6fa..ab4f2c07ab 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -7,6 +7,9 @@ import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPU import com.itsaky.androidide.utils.ProjectStorageKey import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -289,13 +292,35 @@ class BuildOutputLocationTest { } @Test - fun `a deferred sync is offered again only once no build holds the slot`() { - assertThat(BuildOutputLocation.shouldOfferDeferredSync(building = false, deferred = true, slotBusy = false)).isTrue() - assertThat(BuildOutputLocation.shouldOfferDeferredSync(building = true, deferred = true, slotBusy = true)).isFalse() - // The user-visible flag dropped, but Quick Build's prebuild still holds the slot. - assertThat(BuildOutputLocation.shouldOfferDeferredSync(building = false, deferred = true, slotBusy = true)).isFalse() - assertThat(BuildOutputLocation.shouldOfferDeferredSync(building = false, deferred = false, slotBusy = false)).isFalse() - } + fun `a deferred sync is offered again once the build frees the slot, though its listener ran first`() = + runTest { + // The build's listener has run and its flags have dropped, but it still holds the slot. + val slotBusy = MutableStateFlow(true) + var offers = 0 + BuildOutputLocation.offerWhenSlotFree(backgroundScope, slotBusy) { offers++ } + runCurrent() + assertThat(offers).isEqualTo(0) + + slotBusy.value = false + runCurrent() + assertThat(offers).isEqualTo(1) + + // Only once: a later build does not bring the offer back. + slotBusy.value = true + runCurrent() + slotBusy.value = false + runCurrent() + assertThat(offers).isEqualTo(1) + } + + @Test + fun `a deferred sync is offered at once when the slot is already free`() = + runTest { + var offers = 0 + BuildOutputLocation.offerWhenSlotFree(backgroundScope, MutableStateFlow(false)) { offers++ } + runCurrent() + assertThat(offers).isEqualTo(1) + } @Test fun `relocating arguments are recognised and others are not`() { From ee5c6b3d9871a125e109654fd94bda671f7e7720 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 16 Oct 2026 19:07:29 -0700 Subject: [PATCH 25/43] ADFA-4930: always relocate build output; remove the switch Hal Eisen's review of #2155: a public "Relocate build output" switch asks users a question about FUSE they cannot answer, and doubles the test matrix. Every sync now relocates. Removed with the switch: its preference, Settings entry, strings and tooltip tag; the banner's "move it back" wording; and deleting a project's output tree after it syncs with relocation off, with the guards that only path needed (the loaded model's tree, a live Quick Build session). Kept: the banner offering a sync. Opening an existing project loads its cached model without syncing, so the project moves its output at its next sync. The banner now offers that sync once per screen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- ARCHITECTURE.md | 4 +- .../editor/ProjectHandlerActivity.kt | 38 ++++++---------- .../preferences/buildAndRunPrefExts.kt | 13 ------ .../services/builder/BuildOutputLocation.kt | 45 ++++--------------- .../services/builder/GradleBuildService.kt | 5 +-- .../builder/BuildOutputLocationTest.kt | 36 ++------------- ...6-10-14-adfa-4930-build-output-off-fuse.md | 12 +++-- .../androidide/idetooltips/TooltipTag.kt | 1 - .../preferences/internal/BuildPreferences.kt | 11 ----- quickbuild/docs/debugging.md | 2 +- resources/src/main/res/values/strings.xml | 3 -- 11 files changed, 35 insertions(+), 135 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bfddc3ba13..c88ba7290e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -63,12 +63,12 @@ The optional file path is attacker-controllable (a URL segment), so it's resolve Whether the open project *can* be named at all is `isDeepLinkTargetOfOpenProject` (`ProjectValidations.kt`): a link can only ever resolve to `/`, but a project can be opened from anywhere. Its `deepLinkTargetOfOpenProjectWithoutIo` half exists so a caller on the main thread can settle the common case — equal path strings name the same directory — without the `canonicalPath` calls the full rule needs. `CreateLinkAction` sends its residue to `Dispatchers.IO` rather than to the StrictMode whitelist (ADR 0007); the two older callers (`EditorHandlerActivity.onNewIntent`, `BaseEditorActivity.onCreate`) still fall through to `canonicalPath` on the main thread and have not been migrated, so the shortcut narrows that exposure rather than removing it. -**Gradle build output lives in app storage, not the project folder (ADFA-4930).** Projects sit on shared storage (`/sdcard`), which Android serves through FUSE, so every file a build writes pays a user-space round trip. With Build & Run > "Relocate build output" on (the default), each project's build output goes to `noBackupFilesDir/gradle-build//` (`services/builder/BuildOutputLocation`), keyed like Quick Build's scratch (`ProjectStorageKey` in `:shared`): +**Gradle build output lives in app storage, not the project folder (ADFA-4930).** Projects sit on shared storage (`/sdcard`), which Android serves through FUSE, so every file a build writes pays a user-space round trip. Each project's build output goes to `noBackupFilesDir/gradle-build//` (`services/builder/BuildOutputLocation`), keyed like Quick Build's scratch (`ProjectStorageKey` in `:shared`): - `main/-/` per Gradle project (`` of its full path, so `:core:ui` is not inside `:core`'s output); nested builds get their own key. The init script plugin sets `layout.buildDirectory` from `-Pcotg.build.outputDir` (`gradle-plugin/.../BuildOutputRedirect`). - `project-cache/` (`--project-cache-dir`, Gradle's `.gradle/`) and `kotlin/` (`kotlin.project.persistent.dir`). - `project-path`, a marker naming the project, used to delete a project's output with it and to sweep orphans at startup. -**The synced model is the record of where output goes.** A sync applies the switch; a build relocates exactly when the model's root build directory is in the tree, so its output is always where the model (APK listing file, classpath, Quick Build's `setup.json`, a plugin's `.cgp`) looks, and a sync that fails changes neither. Code that needs build output reads it from the model's build directories, never from `/build`. Builds started outside the IDE (`./gradlew` in the terminal) don't get the redirect and still write to the project folder. +**The synced model is the record of where output goes.** Every sync relocates, and opening a project does not sync, so an existing project's cached model keeps building into the project folder until its next sync; a build relocates exactly when the model's root build directory is in the tree, so its output is always where the model (APK listing file, classpath, Quick Build's `setup.json`, a plugin's `.cgp`) looks, and a sync that fails changes neither. Code that needs build output reads it from the model's build directories, never from `/build`. Builds started outside the IDE (`./gradlew` in the terminal) don't get the redirect and still write to the project folder. ## Module Structure diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index fd5da7de22..500b24f4a8 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -187,8 +187,8 @@ abstract class ProjectHandlerActivity : private val replaceCoordinator by lazy { ReplaceCoordinator(editorFor = { file -> getEditorForFile(file)?.editor }) } protected var syncNotificationFlashbar: Flashbar? = null - /** The relocation request the sync banner last offered, so Ignore holds until it changes. */ - private var buildOutputSyncOffered: Boolean? = null + /** Whether the relocation banner made its offer, so Not now holds for this screen. */ + private var buildOutputSyncOffered = false /** The banner that offer showed, so withdrawing it never dismisses another one. */ private var buildOutputSyncBanner: Flashbar? = null @@ -1225,9 +1225,9 @@ abstract class ProjectHandlerActivity : } /** - * ADFA-4930: a changed "Relocate build output" only moves the build output at the next sync, so - * offer one, once per change, and withdraw the offer once the synced model agrees with the switch - * again. Turning relocation on by default makes this the first thing an existing project shows. + * ADFA-4930: an existing project's cached model builds into the project folder until its next + * sync, and opening it does not sync, so offer one, once, and withdraw the offer once the synced + * model is relocated. This is the first thing an existing project shows after the update. * * @param afterInit whether a project initialization just finished. It applied any Gradle file * changes too, so the banner can go even when it also stands for those. @@ -1236,20 +1236,8 @@ abstract class ProjectHandlerActivity : activityScope.launch { val path = ProjectManagerImpl.getInstance().projectDirPath.takeIf { it.isNotBlank() } ?: return@launch val projectDir = File(path) - val requested = BuildOutputLocation.isRequested() val modelRelocated = BuildOutputLocation.modelRelocated(noBackupFilesDir, projectDir) - if (BuildOutputLocation.shouldDeleteTrees(afterInit, modelRelocated, isQuickBuildSessionLive())) { - // Never the tree the loaded model builds into, however its path is spelled. - val keep = - ProjectManagerImpl - .getInstance() - .workspace - ?.rootProject - ?.buildDir - val removed = withContext(Dispatchers.IO) { BuildOutputLocation.removeTreesOf(noBackupFilesDir, projectDir, keep) } - if (removed > 0) log.info("Deleted {} unused build output tree(s) of {}", removed, projectDir) - } - val pending = BuildOutputLocation.syncPending(modelRelocated, requested) + val pending = BuildOutputLocation.syncPending(modelRelocated) withContext(Dispatchers.Main) { if (!pending) { val banner = buildOutputSyncBanner @@ -1257,18 +1245,18 @@ abstract class ProjectHandlerActivity : banner.dismiss() } buildOutputSyncBanner = null - buildOutputSyncOffered = null + buildOutputSyncOffered = false } else if ( - buildOutputSyncOffered != requested && + !buildOutputSyncOffered && notifySyncNeeded( onConfirm = ::syncForBuildOutput, - // Short, with a short dismiss label: this banner greets every existing project - // once relocation is on by default, and has to keep its Sync button at 2x font scale. - message = if (requested) string.msg_sync_build_output_relocate else string.msg_sync_build_output_restore, + // Short, with a short dismiss label: this banner greets every existing project, and + // has to keep its Sync button at 2x font scale. + message = string.msg_sync_build_output_relocate, dismissText = string.btn_not_now, ) ) { - buildOutputSyncOffered = requested + buildOutputSyncOffered = true buildOutputSyncBanner = syncNotificationFlashbar } } @@ -1281,7 +1269,7 @@ abstract class ProjectHandlerActivity : * would be refused and drop the loaded model, so the offer is made again when the build ends. */ private fun syncForBuildOutput() { - buildOutputSyncOffered = null + buildOutputSyncOffered = false buildOutputSyncBanner = null val service = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) as? GradleBuildService if (service?.isBuildInProgress == true) { diff --git a/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt b/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt index 3f45c266db..47c7614ee1 100644 --- a/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt +++ b/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt @@ -27,11 +27,9 @@ import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILDRUN_FLAGS_OFFLINE import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILDRUN_FLAGS_SCAN import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILDRUN_FLAGS_STACKTRACE import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILDRUN_FLAGS_WARNINGMODEALL -import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILDRUN_RELOCATE_OUTPUT import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILD_RUN import com.itsaky.androidide.preferences.internal.BuildPreferences.GRADLE_COMMANDS import com.itsaky.androidide.preferences.internal.BuildPreferences.LAUNCH_APP_AFTER_INSTALL -import com.itsaky.androidide.preferences.internal.BuildPreferences.RELOCATE_BUILD_OUTPUT import com.itsaky.androidide.preferences.internal.BuildPreferences.isBuildCacheEnabled import com.itsaky.androidide.preferences.internal.BuildPreferences.isDebugEnabled import com.itsaky.androidide.preferences.internal.BuildPreferences.isInfoEnabled @@ -40,7 +38,6 @@ import com.itsaky.androidide.preferences.internal.BuildPreferences.isScanEnabled import com.itsaky.androidide.preferences.internal.BuildPreferences.isStacktraceEnabled import com.itsaky.androidide.preferences.internal.BuildPreferences.isWarningModeAllEnabled import com.itsaky.androidide.preferences.internal.BuildPreferences.launchAppAfterInstall -import com.itsaky.androidide.preferences.internal.BuildPreferences.relocateBuildOutput import com.itsaky.androidide.resources.R.drawable import com.itsaky.androidide.resources.R.string import kotlinx.parcelize.Parcelize @@ -69,7 +66,6 @@ private class GradleOptions( init { addPreference(GradleCommands()) - addPreference(RelocateBuildOutput()) } } @@ -96,15 +92,6 @@ private class GradleCommands( } -@Parcelize -private class RelocateBuildOutput( - override val key: String = RELOCATE_BUILD_OUTPUT, - override val title: Int = string.idepref_relocateBuildOutput_title, - override val summary: Int? = string.idepref_relocateBuildOutput_summary, - override val icon: Int? = drawable.ic_folder, - override val tooltipTag: String = PREFS_BUILDRUN_RELOCATE_OUTPUT, -) : SwitchPreference(setValue = ::relocateBuildOutput::set, getValue = ::relocateBuildOutput::get) - @Parcelize private class RunOptions( override val key: String = "ide.build.runOptions", diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index cbbf35c2df..ce19bfbf47 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -1,6 +1,5 @@ package com.itsaky.androidide.services.builder -import com.itsaky.androidide.preferences.internal.BuildPreferences import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.api.Workspace import com.itsaky.androidide.projects.models.buildDir @@ -22,11 +21,10 @@ import java.io.IOException * keyed by the same function, from the project's canonical path. App-private f2fs makes warm builds * up to about twice as fast on the devices measured. * - * The synced project model is the record of where output goes. A sync applies the user's choice; - * a build follows the model's root build directory ([modelRelocated]), not the live switch, so it - * writes where the model (APK listing file, classpath) looks, and a sync that fails leaves both - * as they were. A change to the switch therefore waits for the next successful sync - * ([syncPending]). + * The synced project model is the record of where output goes. Every sync relocates; a build + * follows the model's root build directory ([modelRelocated]), so it writes where the model (APK + * listing file, classpath) looks, and a sync that fails leaves both as they were. An existing + * project's cached model builds into the project folder until its next sync ([syncPending]). */ object BuildOutputLocation { /** Parent of every project's tree, under `noBackupFilesDir`. */ @@ -43,9 +41,6 @@ object BuildOutputLocation { /** Where Android mounts storage volumes: internal storage, and each SD card or USB drive. */ private val STORAGE_ROOT = File("/storage") - /** Whether the user currently asks for relocation; applied at the next sync. */ - fun isRequested(): Boolean = BuildPreferences.relocateBuildOutput - /** * @param noBackupDir the app's `noBackupFilesDir`. * @param projectDir the project's root directory. @@ -113,19 +108,14 @@ object BuildOutputLocation { * key, so a path spelled differently (`/sdcard` against `/storage/emulated/0`) still matches. * Call it when the user deletes the project. * - * @param keep a directory whose tree must survive, such as the loaded model's build directory. * @return how many trees were deleted. */ fun removeTreesOf( noBackupDir: File, projectDir: File, - keep: File? = null, ): Int { val location = projectDir.canonicalProjectLocation() - val kept = keep?.absoluteFile?.normalize() - return deleteTreesWhere(noBackupDir) { tree, project -> - project.canonicalProjectLocation() == location && (kept == null || !kept.startsWith(tree.absoluteFile.normalize())) - } + return deleteTreesWhere(noBackupDir) { _, project -> project.canonicalProjectLocation() == location } } /** @@ -197,22 +187,6 @@ object BuildOutputLocation { null } - /** - * Whether a successful initialization should delete the project's relocated output: its model - * builds into the project folder, so the tree is dead weight. Not while a Quick Build session is - * live, which keeps absolute paths into the tree (the proxy APK, its classes and payload) until - * it ends; the next project open deletes it instead. - * - * @param afterInit an initialization, with or without a sync, just succeeded. - * @param modelRelocated [modelRelocated] for the loaded model. - * @param quickBuildLive a Quick Build session is live. - */ - fun shouldDeleteTrees( - afterInit: Boolean, - modelRelocated: Boolean?, - quickBuildLive: Boolean, - ): Boolean = afterInit && modelRelocated == false && !quickBuildLive - /** * Offers a relocation sync the user deferred, once no build holds the Gradle slot. Waits on the * slot itself: a finished build's listener can run before the build frees the slot, so a check @@ -257,13 +231,10 @@ object BuildOutputLocation { ): Boolean? = workspace?.let { isRelocated(it.rootProject.buildDir, treeFor(noBackupDir, projectDir)) } /** - * Whether the switch disagrees with the synced model, so the next - * sync would move the build output. False without a model: there is nothing to disagree with. + * Whether the synced model still builds into the project folder, so the next sync would move the + * build output. False without a model: there is nothing to move yet. * * @param modelRelocated [modelRelocated] for the open project. */ - fun syncPending( - modelRelocated: Boolean?, - requested: Boolean = isRequested(), - ): Boolean = modelRelocated != null && modelRelocated != requested + fun syncPending(modelRelocated: Boolean?): Boolean = modelRelocated == false } diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index b95ad234e8..34b79e0a27 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -760,8 +760,8 @@ class GradleBuildService : /** * The arguments that move the open project's build output to app-private storage (ADFA-4930), - * or none. A sync applies the user's current choice, keeping output in the project folder when - * the tree cannot be written; the model it produces records the outcome. A build follows that + * or none. Every sync relocates, keeping output in the project folder only when the tree cannot + * be written; the model it produces records the outcome. A build follows that * model, so it writes where the model (APK listing file, classpath) looks, and never falls back * to the project folder, which would leave the model pointing at older output. Runs on the * [prepareBuild] worker, so disk I/O is fine. @@ -783,7 +783,6 @@ class GradleBuildService : return BuildOutputLocation.gradleArgs(tree) } - if (!BuildOutputLocation.isRequested()) return emptyList() if (!BuildOutputLocation.prepare(tree, projectDir)) { log.warn("Cannot write build output directory {}; syncing with output in the project folder", tree) return emptyList() diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index ab4f2c07ab..2472f1d91a 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -132,16 +132,14 @@ class BuildOutputLocationTest { } @Test - fun `a sync is pending exactly when the request differs from the synced model`() { - assertThat(BuildOutputLocation.syncPending(modelRelocated = false, requested = false)).isFalse() - assertThat(BuildOutputLocation.syncPending(modelRelocated = false, requested = true)).isTrue() - assertThat(BuildOutputLocation.syncPending(modelRelocated = true, requested = true)).isFalse() - assertThat(BuildOutputLocation.syncPending(modelRelocated = true, requested = false)).isTrue() + fun `a sync is pending exactly when the synced model builds into the project folder`() { + assertThat(BuildOutputLocation.syncPending(modelRelocated = false)).isTrue() + assertThat(BuildOutputLocation.syncPending(modelRelocated = true)).isFalse() } @Test fun `nothing is pending before a model is loaded`() { - assertThat(BuildOutputLocation.syncPending(modelRelocated = null, requested = true)).isFalse() + assertThat(BuildOutputLocation.syncPending(modelRelocated = null)).isFalse() } @Test @@ -265,32 +263,6 @@ class BuildOutputLocationTest { .isEqualTo(BuildOutputLocation.treeFor(noBackup, app)) } - @Test - fun `the tree the loaded model builds into is never deleted`() { - val noBackup = tmp.newFolder("no_backup") - val project = tmp.newFolder("projects", "app") - val tree = treeOf(noBackup, project) - - assertThat(BuildOutputLocation.removeTreesOf(noBackup, project, keep = File(tree, "main/root-5678"))).isEqualTo(0) - assertThat(tree.isDirectory).isTrue() - - assertThat(BuildOutputLocation.removeTreesOf(noBackup, project, keep = File(project, "build"))).isEqualTo(1) - assertThat(tree.exists()).isFalse() - } - - @Test - fun `trees are deleted only after an initialization whose model builds into the project folder`() { - assertThat(BuildOutputLocation.shouldDeleteTrees(afterInit = true, modelRelocated = false, quickBuildLive = false)).isTrue() - assertThat(BuildOutputLocation.shouldDeleteTrees(afterInit = false, modelRelocated = false, quickBuildLive = false)).isFalse() - assertThat(BuildOutputLocation.shouldDeleteTrees(afterInit = true, modelRelocated = true, quickBuildLive = false)).isFalse() - assertThat(BuildOutputLocation.shouldDeleteTrees(afterInit = true, modelRelocated = null, quickBuildLive = false)).isFalse() - } - - @Test - fun `a live Quick Build session keeps the tree it still uses`() { - assertThat(BuildOutputLocation.shouldDeleteTrees(afterInit = true, modelRelocated = false, quickBuildLive = true)).isFalse() - } - @Test fun `a deferred sync is offered again once the build frees the slot, though its listener ran first`() = runTest { diff --git a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md index 1d5bfb0af2..33c25b8941 100644 --- a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md +++ b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md @@ -49,7 +49,7 @@ Measured 2026-10-14 with the IDE's Gradle arguments, same template project on bo Sync (`RootModelBuilder.kt:207`) and builds (`ToolingApiServerImpl.kt:313`) both take their arguments from `prepareBuild`, so this one change covers both. `prepareBuild` already reads the project path (`GradleBuildService.kt:552`). 2. **Gradle side.** `AndroidIDEInitScriptPlugin.apply` (`gradle-plugin`) reads the property from `gradle.startParameter.projectProperties` and, in `beforeProject`, sets `project.layout.buildDirectory`. This runs before any build script, so AGP, Kotlin and Quick Build's plugin all see the new location. The prototype init script did exactly this (`gradle.allprojects { p -> p.layout.buildDirectory.set(...) }`), with 33 builds and no output under the project. -3. **Flag.** A `BuildPreferences` switch, default off, shown only with experiments enabled (`FeatureFlags.isExperimentsEnabled`, REVIEW.md section 12) until validation passes. +3. **Flag.** A `BuildPreferences` switch, default off, shown only with experiments enabled (`FeatureFlags.isExperimentsEnabled`, REVIEW.md section 12) until validation passes. Removed after review: every sync relocates. 4. **Sync applies it.** A sync reads the switch; the model it produces is the record. A build relocates exactly when the model's root build directory is in the tree, so its output is always where the model (APK listing file, classpath) looks, and a failed sync changes neither. When the switch and the model disagree, the editor offers a sync after the project initializes and on resume, once per change, and withdraws the offer when they agree again. ### What reads build output @@ -88,6 +88,7 @@ Each PR compiles and passes tests on its own; the flag stays off until PR 4. 3. **Lifecycle and UX.** Delete-with-project, the orphan sweep, Export APK, strings. About 6 files. 4. **Default on.** Flip the flag after validation. Update `ARCHITECTURE.md` (storage layout) and any user docs. Validation (2026-10-15/16, posted on ADFA-4930): warm edit builds 48% faster on a 288-source project (Pixel 6 Pro); on an itel A667L, clean builds about 50% faster and small edits unchanged. PR 4 also drops the experiments gate, so the switch and Export APK show for everyone, and gives the sync banner its own wording for a relocation change. + After review (Hal Eisen on #2155) the switch is gone: every sync relocates, and the banner only offers an existing project's first relocating sync. ## Validation before default-on @@ -99,18 +100,15 @@ Each PR compiles and passes tests on its own; the flag stays off until PR 4. ```gherkin Feature: Build output in app storage - Background: - Given build output relocation is enabled in Build preferences - Scenario: A project builds and runs with output in app storage Given a new Empty Activity project in CodeOnTheGoProjects When I build and run it Then the app installs and launches And the project folder contains no "build" or ".gradle" folder - Scenario: Changing the switch waits for a sync - Given an open project that was synced with relocation off - When I turn relocation on in Build preferences and return to the editor + Scenario: An existing project moves its output at its next sync + Given a project last synced by a version without relocation + When I open it Then the editor asks for a sync And a build before the sync still writes to the project's "build" folder And after the sync, builds write to app storage diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index 7b36406629..5e60c2754a 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -98,7 +98,6 @@ object TooltipTag { // Preferences - Build & Run screen const val PREFS_BUILDRUN_AUTOLAUNCH = "prefs.buildrun.autolaunch" - const val PREFS_BUILDRUN_RELOCATE_OUTPUT = "prefs.buildrun.relocateoutput" const val PREFS_BUILDRUN_FLAGS = "prefs.buildrun.flags" const val PREFS_BUILDRUN_FLAGS_STACKTRACE = "prefs.buildrun.flags.stacktrace" const val PREFS_BUILDRUN_FLAGS_INFO = "prefs.buildrun.flags.info" diff --git a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt index 730f377121..34155df4eb 100644 --- a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt +++ b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt @@ -36,7 +36,6 @@ object BuildPreferences { const val GRADLE_COMMANDS = "idepref_build_gradleCommands" const val CUSTOM_GRADLE_INSTALLATION = "idepref_build_customGradleInstallation" const val LAUNCH_APP_AFTER_INSTALL = "ide.build.run.launchAppAfterInstall" - const val RELOCATE_BUILD_OUTPUT = "ide.build.relocateBuildOutput" /** Switch for Gradle `--debug` option. */ var isDebugEnabled: Boolean @@ -106,14 +105,4 @@ object BuildPreferences { set(value) { prefManager.putBoolean(LAUNCH_APP_AFTER_INSTALL, value) } - - /** - * Whether builds write their output to app-private storage instead of the project folder - * (ADFA-4930). On by default: builds there are faster than on the FUSE-backed project folder. - */ - var relocateBuildOutput: Boolean - get() = prefManager.getBoolean(RELOCATE_BUILD_OUTPUT, true) - set(value) { - prefManager.putBoolean(RELOCATE_BUILD_OUTPUT, value) - } } diff --git a/quickbuild/docs/debugging.md b/quickbuild/docs/debugging.md index 4c13460a1f..2115a73420 100644 --- a/quickbuild/docs/debugging.md +++ b/quickbuild/docs/debugging.md @@ -185,7 +185,7 @@ to CoGo. | `no_backup/quickbuild-scratch/-<16 hex>/out` | daemon output: classes, dex, relinked resources | as above | | `/.androidide/quickbuild/generation` | the generation counter | plain `adb shell cat` | | `//build/quickbuild//setup.json` | the proxy app build's handshake with CoGo | plain `adb shell cat` | -| `no_backup/gradle-build/-<16 hex>/main/-<16 hex>/quickbuild//setup.json` | the same, when "Relocate build output" is on (ADFA-4930) | `run-as com.itsaky.androidide` | +| `no_backup/gradle-build/-<16 hex>/main/-<16 hex>/quickbuild//setup.json` | the same, once the project has synced since ADFA-4930 | `run-as com.itsaky.androidide` | | `/data/data//files/quickbuild/payload/` | persisted payload: `payload.dex`, `resources.arsc`, `assets.zip`, `meta.json` | `run-as ` | Projects live under `/storage/emulated/0/CodeOnTheGoProjects/`. diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 782f464105..ea51184516 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -122,7 +122,6 @@ Quick run Gradle files have changed. Sync the project to update dependencies and settings. Sync to move build output to faster storage. - Sync to move build output back to the project folder. A build is running. You\'ll be asked to sync again when it finishes. A build is running. Sync once it finishes. Preparing @@ -877,8 +876,6 @@ Launch app after installation If enabled, the IDE will (without confirmation) launch the application right after it is installed. Run options - Relocate build output - Faster builds: build output goes to Code on the Go\'s private storage, not the project folder. Sync to apply. Cannot run application. Unable to determine package name. Cannot run application. No application modules found in project. Launch app From bcff8faaea611a0ee01c15d1265f91aeadc4be01 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 16 Oct 2026 19:12:03 -0700 Subject: [PATCH 26/43] ADFA-4930: remove a relocated project's old build output, once Hal Eisen's review of #2155: once a project's output moves to app storage, the build output its folder still holds stops updating. A file manager shows the last APK built before the update where users look for the new one, and the stale copy doubles the space a project takes. After a project's first relocated initialization, OldBuildOutput removes each Gradle project's build/, and .gradle/ and .kotlin/ at the root and in buildSrc, then records that in the project's tree so it happens once: a later terminal build's output is the user's to keep. The user is told once. Kept: buildSrc/.gradle/ and included builds, which builds still use; a build folder that is or holds a module, or that the model still builds into; anything outside the project folder; symlinks, which the deletion never follows. Skipped while a Quick Build session is live, which may still use the old build directory. Each of the module, symlink and no-follow tests fails with its guard removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- ARCHITECTURE.md | 2 +- .../editor/ProjectHandlerActivity.kt | 31 +++++ .../services/builder/OldBuildOutput.kt | 123 ++++++++++++++++++ .../services/builder/OldBuildOutputTest.kt | 107 +++++++++++++++ ...6-10-14-adfa-4930-build-output-off-fuse.md | 9 +- resources/src/main/res/values/strings.xml | 1 + 6 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt create mode 100644 app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c88ba7290e..c77bc26821 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -68,7 +68,7 @@ Whether the open project *can* be named at all is `isDeepLinkTargetOfOpenProject - `project-cache/` (`--project-cache-dir`, Gradle's `.gradle/`) and `kotlin/` (`kotlin.project.persistent.dir`). - `project-path`, a marker naming the project, used to delete a project's output with it and to sweep orphans at startup. -**The synced model is the record of where output goes.** Every sync relocates, and opening a project does not sync, so an existing project's cached model keeps building into the project folder until its next sync; a build relocates exactly when the model's root build directory is in the tree, so its output is always where the model (APK listing file, classpath, Quick Build's `setup.json`, a plugin's `.cgp`) looks, and a sync that fails changes neither. Code that needs build output reads it from the model's build directories, never from `/build`. Builds started outside the IDE (`./gradlew` in the terminal) don't get the redirect and still write to the project folder. +**The synced model is the record of where output goes.** Every sync relocates, and opening a project does not sync, so an existing project's cached model keeps building into the project folder until its next sync; a build relocates exactly when the model's root build directory is in the tree, so its output is always where the model (APK listing file, classpath, Quick Build's `setup.json`, a plugin's `.cgp`) looks, and a sync that fails changes neither. Code that needs build output reads it from the model's build directories, never from `/build`. Builds started outside the IDE (`./gradlew` in the terminal) don't get the redirect and still write to the project folder. After a project's first relocated initialization, the stale output its folder still held is removed once (`services/builder/OldBuildOutput`), without following symlinks. ## Module Structure diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 500b24f4a8..681cfdc11d 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -102,6 +102,7 @@ import com.itsaky.androidide.search.replace.SkipReason import com.itsaky.androidide.services.builder.BuildOutputLocation import com.itsaky.androidide.services.builder.GradleBuildService import com.itsaky.androidide.services.builder.GradleBuildServiceConnnection +import com.itsaky.androidide.services.builder.OldBuildOutput import com.itsaky.androidide.services.builder.gradleDistributionParams import com.itsaky.androidide.tooling.api.messages.AndroidInitializationParams import com.itsaky.androidide.tooling.api.messages.BuildId @@ -1263,6 +1264,35 @@ abstract class ProjectHandlerActivity : } } + /** + * ADFA-4930: once a project's model builds into app storage, the output its folder still holds is + * stale and takes space, so remove it, once ([OldBuildOutput]). Not while a Quick Build session is + * live: it may still use the old model's build directory, and the next initialization will do it. + */ + private fun removeOldBuildOutput() { + activityScope.launch { + val manager = ProjectManagerImpl.getInstance() + val projectDir = manager.projectDirPath.takeIf { it.isNotBlank() }?.let(::File) ?: return@launch + val workspace = manager.workspace ?: return@launch + if (BuildOutputLocation.modelRelocated(noBackupFilesDir, projectDir, workspace) != true) return@launch + if (isQuickBuildSessionLive()) return@launch + val projects = listOf(workspace.rootProject) + workspace.subProjects + val removed = + withContext(Dispatchers.IO) { + OldBuildOutput.removeOnce( + tree = BuildOutputLocation.treeFor(noBackupFilesDir, projectDir), + rootDir = workspace.rootProject.projectDir, + projectDirs = projects.map { it.projectDir }, + buildDirs = projects.map { it.buildDir }, + ) + } + if (removed > 0) { + log.info("Removed {} old build output folder(s) from {}", removed, projectDir) + withContext(Dispatchers.Main) { flashInfoLong(getString(string.msg_old_build_output_removed)) } + } + } + } + /** * Forgets the offer, so a sync that fails leaves it to be made again on the next resume. While a * build holds the Gradle slot (Quick Build's prebuild starts soon after a project opens), a sync @@ -1828,6 +1858,7 @@ abstract class ProjectHandlerActivity : invalidateOptionsMenu() reconcileBuildOutputSyncOffer(afterInit = true) + removeOldBuildOutput() // ADFA-4128 benchmark: if the bench trampoline armed an autostart for THIS project, // claim it now - the adb-driven stand-in for the human's tap. Claimed BEFORE prebuild diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt b/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt new file mode 100644 index 0000000000..5c5c7160a9 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt @@ -0,0 +1,123 @@ +package com.itsaky.androidide.services.builder + +import java.io.File +import java.io.IOException +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes + +/** + * The build output a project folder still holds after its output moved to app storage (ADFA-4930). + * It no longer updates, so a file manager shows an old APK where users look for the new one, and it + * takes the space a second copy of the output does. It is removed once, after the project's first + * relocated initialization. + */ +object OldBuildOutput { + /** Written in a project's tree once its old output is removed, so it is removed only once. */ + const val REMOVED_FILE = "old-output-removed" + + /** + * The stale folders of a relocated project: each Gradle project's `build/`, and the Gradle and + * Kotlin caches of the root and of `buildSrc`. Only folders Gradle makes by default, inside + * [rootDir]. Left alone: a symlink; a folder that is a project's directory or holds one; a folder + * that holds or lies in a build directory the model still uses; and `buildSrc/.gradle/` and + * included builds, which builds still use. + * + * @param rootDir the root project's directory. + * @param projectDirs the directory of every project in the synced model. + * @param buildDirs the build directory of every project in the synced model. + */ + fun find( + rootDir: File, + projectDirs: List, + buildDirs: List, + ): List { + val root = rootDir.absoluteFile.normalize() + val projects = projectDirs.map { it.absoluteFile.normalize() } + val inUse = buildDirs.map { it.absoluteFile.normalize() } + val candidates = + projects.map { File(it, "build") } + + listOf(".gradle", ".kotlin", "buildSrc/build", "buildSrc/.kotlin").map { File(root, it) } + return candidates.distinct().filter { dir -> + dir.startsWith(root) && + Files.isDirectory(dir.toPath(), LinkOption.NOFOLLOW_LINKS) && + projects.none { it.startsWith(dir) } && + inUse.none { it.startsWith(dir) || dir.startsWith(it) } + } + } + + /** + * Deletes what [find] returns, unless [tree] records that it was done before. + * + * @param tree the project's tree, from [BuildOutputLocation.treeFor]. + * @return how many folders were deleted. + */ + fun removeOnce( + tree: File, + rootDir: File, + projectDirs: List, + buildDirs: List, + ): Int { + val marker = File(tree, REMOVED_FILE) + if (marker.exists()) return 0 + val removed = find(rootDir, projectDirs, buildDirs).count(::deleteWithoutFollowingLinks) + try { + marker.createNewFile() + } catch (e: IOException) { + // Not recorded: the next relocated initialization tries again, and finds less to do. + } + return removed + } + + /** + * Deletes [dir] and everything in it without following symlinks, so a link in a project's folder + * never leads the deletion out of it. A file that cannot be deleted is skipped, not fatal. + * + * @return whether [dir] is gone. + */ + internal fun deleteWithoutFollowingLinks(dir: File): Boolean { + val start = dir.toPath() + if (Files.notExists(start, LinkOption.NOFOLLOW_LINKS)) return true + try { + Files.walkFileTree( + start, + object : SimpleFileVisitor() { + override fun visitFile( + file: Path, + attrs: BasicFileAttributes, + ): FileVisitResult { + deleteQuietly(file) + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed( + file: Path, + exc: IOException, + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun postVisitDirectory( + dir: Path, + exc: IOException?, + ): FileVisitResult { + deleteQuietly(dir) + return FileVisitResult.CONTINUE + } + }, + ) + } catch (e: IOException) { + return false + } + return Files.notExists(start, LinkOption.NOFOLLOW_LINKS) + } + + private fun deleteQuietly(path: Path) { + try { + Files.deleteIfExists(path) + } catch (e: IOException) { + // Left in place; the folder holding it stays too. + } + } +} diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt new file mode 100644 index 0000000000..41e8fc20e7 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt @@ -0,0 +1,107 @@ +package com.itsaky.androidide.services.builder + +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 + +class OldBuildOutputTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun dirs( + root: File, + vararg paths: String, + ): List = paths.map { File(root, it).apply { mkdirs() } } + + @Test + fun `finds each project's build folder and the root's and buildSrc's caches`() { + val root = tmp.newFolder("project") + val tree = tmp.newFolder("tree") + dirs(root, "build", "app/build", ".gradle", ".kotlin", "buildSrc/build", "buildSrc/.kotlin", "buildSrc/.gradle", ".cxx") + + val found = OldBuildOutput.find(root, listOf(root, File(root, "app")), listOf(File(tree, "root"), File(tree, "app"))) + + assertThat(found.map { it.relativeTo(root).path }).containsExactly( + "build", + "app/build", + ".gradle", + ".kotlin", + "buildSrc/build", + "buildSrc/.kotlin", + ) + } + + @Test + fun `a module called build is not build output`() { + val root = tmp.newFolder("project") + val tree = tmp.newFolder("tree") + val module = dirs(root, "build/src").first().parentFile + + val found = OldBuildOutput.find(root, listOf(root, module), listOf(File(tree, "root"), File(tree, "build"))) + + assertThat(found).isEmpty() + } + + @Test + fun `a build folder the model still builds into is kept`() { + val root = tmp.newFolder("project") + val tree = tmp.newFolder("tree") + dirs(root, "build/custom", "app/build") + + // The root project set its own build directory, overriding the relocation. + val found = OldBuildOutput.find(root, listOf(root, File(root, "app")), listOf(File(root, "build/custom"), File(tree, "app"))) + + assertThat(found.map { it.relativeTo(root).path }).containsExactly("app/build") + } + + @Test + fun `folders outside the project and symlinked folders are kept`() { + val root = tmp.newFolder("project") + val tree = tmp.newFolder("tree") + val outside = tmp.newFolder("shared", "lib") + File(outside, "build").mkdirs() + val elsewhere = tmp.newFolder("elsewhere") + Files.createSymbolicLink(File(root, "build").toPath(), elsewhere.toPath()) + + val found = OldBuildOutput.find(root, listOf(root, outside), listOf(File(tree, "root"), File(tree, "lib"))) + + assertThat(found).isEmpty() + } + + @Test + fun `deleting never follows a symlink out of the folder`() { + val build = tmp.newFolder("project", "build") + File(build, "outputs/apk").mkdirs() + File(build, "outputs/apk/app-debug.apk").writeText("apk") + val elsewhere = tmp.newFolder("elsewhere") + val precious = File(elsewhere, "precious.txt").apply { writeText("keep me") } + Files.createSymbolicLink(File(build, "link").toPath(), elsewhere.toPath()) + + assertThat(OldBuildOutput.deleteWithoutFollowingLinks(build)).isTrue() + + assertThat(build.exists()).isFalse() + assertThat(precious.readText()).isEqualTo("keep me") + } + + @Test + fun `old output is removed once`() { + val root = tmp.newFolder("project") + val tree = tmp.newFolder("tree") + dirs(root, "app/build/outputs", ".gradle") + val projects = listOf(root, File(root, "app")) + val buildDirs = listOf(File(tree, "root"), File(tree, "app")) + + assertThat(OldBuildOutput.removeOnce(tree, root, projects, buildDirs)).isEqualTo(2) + assertThat(File(root, "app/build").exists()).isFalse() + assertThat(File(root, ".gradle").exists()).isFalse() + assertThat(File(root, "app").isDirectory).isTrue() + + // A later build from the terminal writes here again; that output is the user's to keep. + dirs(root, "app/build") + assertThat(OldBuildOutput.removeOnce(tree, root, projects, buildDirs)).isEqualTo(0) + assertThat(File(root, "app/build").isDirectory).isTrue() + } +} diff --git a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md index 33c25b8941..f65bd988d3 100644 --- a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md +++ b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md @@ -68,7 +68,7 @@ Measured 2026-10-14 with the IDE's Gradle arguments, same template project on bo - **Project deleted in the IDE**: both deletion paths (Recents and the Delete projects screen) go through `RecentProjectsViewModel`, which deletes the project's trees too. Trees are matched by their `project-path` marker, not by key: deletion uses the canonical path and builds use `projectDirPath`, so a symlinked spelling would key differently. - **Orphans** (project moved, renamed or deleted outside the IDE): at startup, off the main thread, remove trees whose project no longer exists. A project counts as gone when its parent folder exists, or, with the parent deleted too, when the nearest folder that exists is a readable folder on a volume under `/storage`. A volume that is not mounted leaves only `/storage` itself, so it doesn't count. Trees without a marker are left alone. Unlike `QuickBuildScratch.sweep`, which clears every tree, this keeps the output of live projects. -- **Existing `build/` folders in projects**: leave them. They go stale but do no harm. A later "free space" action can offer to remove them. +- **Existing `build/` folders in projects**: removed once, after the project's first relocated initialization (`OldBuildOutput`): each Gradle project's `build/`, and `.gradle/` and `.kotlin/` at the root and in `buildSrc` (not `buildSrc/.gradle/`, which builds still use). A stale APK where a file manager finds it misleads, and the copy doubles the space a project takes. Symlinks are never followed, a `build` folder that is or holds a module stays, and so does anything outside the project folder. The user is told once. - **Storage**: output now counts as app data in Settings, and "Clear storage" removes it (the next build is a full one). Uninstalling removes it as well; sources are unaffected. ### What users will notice @@ -113,6 +113,13 @@ Feature: Build output in app storage And a build before the sync still writes to the project's "build" folder And after the sync, builds write to app storage + Scenario: An existing project's old output is removed after it moves + Given a project last built by a version without relocation, with "app/build" in its folder + When I open it and accept the sync + Then "build", "app/build", ".gradle" and ".kotlin" are gone from the project folder + And I am told the old build output was removed + And a later sync removes nothing, and says nothing + Scenario: Quick Build still works Given an open project that has been built once When I edit MainActivity and run Quick Build diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index ea51184516..0d40e8f393 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -123,6 +123,7 @@ Gradle files have changed. Sync the project to update dependencies and settings. Sync to move build output to faster storage. A build is running. You\'ll be asked to sync again when it finishes. + Old build output was removed from the project folder. Export gets your latest build. A build is running. Sync once it finishes. Preparing Initializing a project may take a few moments. From 5d95f18b20041a67c406e07f31535ac1878f20cc Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 16 Oct 2026 19:13:46 -0700 Subject: [PATCH 27/43] ADFA-4930: delete output trees without following symlinks Deleting a project's tree, and the startup sweep, used Kotlin's File.deleteRecursively, which follows a symlink to a directory and deletes what is inside it. A build that leaves a link in its output, such as one a custom task makes, would have had the target's files deleted along with the project. Trees are now deleted with the same no-follow walk OldBuildOutput uses, moved to its own file. The new test fails with deleteRecursively: the file the link points at is gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../services/builder/BuildOutputLocation.kt | 5 +- .../builder/DeleteWithoutFollowingLinks.kt | 59 +++++++++++++++++++ .../services/builder/OldBuildOutput.kt | 55 +---------------- .../builder/BuildOutputLocationTest.kt | 16 +++++ .../services/builder/OldBuildOutputTest.kt | 2 +- 5 files changed, 80 insertions(+), 57 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/services/builder/DeleteWithoutFollowingLinks.kt diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index ce19bfbf47..f38c7891e2 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -166,12 +166,13 @@ object BuildOutputLocation { /** * Deletes [tree] with its marker last: a deletion that stops part-way keeps the marker, so the - * next sweep can still tell whose tree it is and finish the job. + * next sweep can still tell whose tree it is and finish the job. A build can leave symlinks in its + * output, so the deletion never follows one out of the tree. */ private fun deleteTree(tree: File): Boolean { val marker = File(tree, PROJECT_PATH_FILE) val contents = tree.listFiles() ?: return false - val deleted = contents.filter { it != marker }.map { it.deleteRecursively() }.all { it } + val deleted = contents.filter { it != marker }.map { deleteWithoutFollowingLinks(it) }.all { it } return deleted && (!marker.exists() || marker.delete()) && tree.delete() } diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/DeleteWithoutFollowingLinks.kt b/app/src/main/java/com/itsaky/androidide/services/builder/DeleteWithoutFollowingLinks.kt new file mode 100644 index 0000000000..9490681da0 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/services/builder/DeleteWithoutFollowingLinks.kt @@ -0,0 +1,59 @@ +package com.itsaky.androidide.services.builder + +import java.io.File +import java.io.IOException +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes + +/** + * Deletes [dir] and everything in it without following symlinks, so a link inside never leads the + * deletion out of [dir]. A file that cannot be deleted is skipped, not fatal. + * + * @return whether [dir] is gone. + */ +internal fun deleteWithoutFollowingLinks(dir: File): Boolean { + val start = dir.toPath() + if (Files.notExists(start, LinkOption.NOFOLLOW_LINKS)) return true + try { + Files.walkFileTree( + start, + object : SimpleFileVisitor() { + override fun visitFile( + file: Path, + attrs: BasicFileAttributes, + ): FileVisitResult { + deleteQuietly(file) + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed( + file: Path, + exc: IOException, + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun postVisitDirectory( + dir: Path, + exc: IOException?, + ): FileVisitResult { + deleteQuietly(dir) + return FileVisitResult.CONTINUE + } + }, + ) + } catch (e: IOException) { + return false + } + return Files.notExists(start, LinkOption.NOFOLLOW_LINKS) +} + +private fun deleteQuietly(path: Path) { + try { + Files.deleteIfExists(path) + } catch (e: IOException) { + // Left in place; the folder holding it stays too. + } +} diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt b/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt index 5c5c7160a9..7b4e170ecf 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt @@ -2,12 +2,8 @@ package com.itsaky.androidide.services.builder import java.io.File import java.io.IOException -import java.nio.file.FileVisitResult import java.nio.file.Files import java.nio.file.LinkOption -import java.nio.file.Path -import java.nio.file.SimpleFileVisitor -import java.nio.file.attribute.BasicFileAttributes /** * The build output a project folder still holds after its output moved to app storage (ADFA-4930). @@ -63,7 +59,7 @@ object OldBuildOutput { ): Int { val marker = File(tree, REMOVED_FILE) if (marker.exists()) return 0 - val removed = find(rootDir, projectDirs, buildDirs).count(::deleteWithoutFollowingLinks) + val removed = find(rootDir, projectDirs, buildDirs).count { deleteWithoutFollowingLinks(it) } try { marker.createNewFile() } catch (e: IOException) { @@ -71,53 +67,4 @@ object OldBuildOutput { } return removed } - - /** - * Deletes [dir] and everything in it without following symlinks, so a link in a project's folder - * never leads the deletion out of it. A file that cannot be deleted is skipped, not fatal. - * - * @return whether [dir] is gone. - */ - internal fun deleteWithoutFollowingLinks(dir: File): Boolean { - val start = dir.toPath() - if (Files.notExists(start, LinkOption.NOFOLLOW_LINKS)) return true - try { - Files.walkFileTree( - start, - object : SimpleFileVisitor() { - override fun visitFile( - file: Path, - attrs: BasicFileAttributes, - ): FileVisitResult { - deleteQuietly(file) - return FileVisitResult.CONTINUE - } - - override fun visitFileFailed( - file: Path, - exc: IOException, - ): FileVisitResult = FileVisitResult.CONTINUE - - override fun postVisitDirectory( - dir: Path, - exc: IOException?, - ): FileVisitResult { - deleteQuietly(dir) - return FileVisitResult.CONTINUE - } - }, - ) - } catch (e: IOException) { - return false - } - return Files.notExists(start, LinkOption.NOFOLLOW_LINKS) - } - - private fun deleteQuietly(path: Path) { - try { - Files.deleteIfExists(path) - } catch (e: IOException) { - // Left in place; the folder holding it stays too. - } - } } diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index 2472f1d91a..c3c2db549e 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -156,6 +156,22 @@ class BuildOutputLocationTest { assertThat(otherTree.isDirectory).isTrue() } + @Test + fun `deleting a project's tree never follows a symlink out of it`() { + val noBackup = tmp.newFolder("no_backup") + val project = tmp.newFolder("projects", "app") + val tree = treeOf(noBackup, project) + val elsewhere = tmp.newFolder("elsewhere") + val precious = File(elsewhere, "precious.txt").apply { writeText("keep me") } + // A build can leave a link in its output, such as one a custom task makes. + Files.createSymbolicLink(File(tree, "main/app-1234/outputs/link").toPath(), elsewhere.toPath()) + + assertThat(BuildOutputLocation.removeTreesOf(noBackup, project)).isEqualTo(1) + + assertThat(tree.exists()).isFalse() + assertThat(precious.readText()).isEqualTo("keep me") + } + @Test fun `a tree recorded under another spelling of the project path is still found`() { val noBackup = tmp.newFolder("no_backup") diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt index 41e8fc20e7..edc962ca82 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt @@ -80,7 +80,7 @@ class OldBuildOutputTest { val precious = File(elsewhere, "precious.txt").apply { writeText("keep me") } Files.createSymbolicLink(File(build, "link").toPath(), elsewhere.toPath()) - assertThat(OldBuildOutput.deleteWithoutFollowingLinks(build)).isTrue() + assertThat(deleteWithoutFollowingLinks(build)).isTrue() assertThat(build.exists()).isFalse() assertThat(precious.readText()).isEqualTo("keep me") From 6d6e126411da1858208b30cc29fe7a514bd5fee3 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 16 Oct 2026 19:15:23 -0700 Subject: [PATCH 28/43] ADFA-4930: rename the APK export to BuildExport, no behaviour change ExportApkAction becomes ExportBuildAction and ApkExport becomes BuildExport, ahead of exporting App Bundles and plugins too. Renames only; the next commit changes behaviour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- ...xportApkAction.kt => ExportBuildAction.kt} | 18 ++++++------- .../utils/{ApkExport.kt => BuildExport.kt} | 4 +-- .../androidide/utils/EditorActivityActions.kt | 4 +-- .../{ApkExportTest.kt => BuildExportTest.kt} | 26 +++++++++---------- 4 files changed, 26 insertions(+), 26 deletions(-) rename app/src/main/java/com/itsaky/androidide/actions/etc/{ExportApkAction.kt => ExportBuildAction.kt} (89%) rename app/src/main/java/com/itsaky/androidide/utils/{ApkExport.kt => BuildExport.kt} (97%) rename app/src/test/java/com/itsaky/androidide/utils/{ApkExportTest.kt => BuildExportTest.kt} (76%) diff --git a/app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt b/app/src/main/java/com/itsaky/androidide/actions/etc/ExportBuildAction.kt similarity index 89% rename from app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt rename to app/src/main/java/com/itsaky/androidide/actions/etc/ExportBuildAction.kt index 2924bfa6ed..35fb302a4c 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/etc/ExportBuildAction.kt @@ -17,7 +17,7 @@ import com.itsaky.androidide.projects.builder.BuildService import com.itsaky.androidide.projects.models.assembleTaskOutputListingFile import com.itsaky.androidide.projects.models.buildDir import com.itsaky.androidide.quickbuild.QuickBuildTaskPaths -import com.itsaky.androidide.utils.ApkExport +import com.itsaky.androidide.utils.BuildExport import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.IntentUtils import com.itsaky.androidide.utils.flashError @@ -34,7 +34,7 @@ import java.io.IOException * app-private storage, where a file manager cannot reach it. Offers the share sheet, or a copy in * the Download folder. */ -class ExportApkAction( +class ExportBuildAction( context: Context, override val order: Int, ) : EditorActivityAction() { @@ -81,16 +81,16 @@ class ExportApkAction( activity.activityScope.launch { val found = withContext(Dispatchers.IO) { - ApkExport.resolve( + BuildExport.resolve( listingFile = variant.mainArtifact.assembleTaskOutputListingFile, quickBuildReport = QuickBuildTaskPaths.setupJson(app.buildDir, variant.name), projectDir = projectDir, ) } when (found) { - ApkExport.Exportable.NotBuilt -> flashError(R.string.msg_export_apk_not_built) - ApkExport.Exportable.QuickBuildProxy -> flashError(R.string.msg_export_apk_quick_build) - is ApkExport.Exportable.Apk -> withContext(Dispatchers.Main) { offerExport(activity, found.file, name) } + BuildExport.Exportable.NotBuilt -> flashError(R.string.msg_export_apk_not_built) + BuildExport.Exportable.QuickBuildProxy -> flashError(R.string.msg_export_apk_quick_build) + is BuildExport.Exportable.Apk -> withContext(Dispatchers.Main) { offerExport(activity, found.file, name) } } } } @@ -120,7 +120,7 @@ class ExportApkAction( ), ) { _, which -> if (which == 0) { - IntentUtils.shareFile(activity, apk, ApkExport.MIME_TYPE) + IntentUtils.shareFile(activity, apk, BuildExport.MIME_TYPE) } else if (!isBuilding()) { activity.activityScope.launch(Dispatchers.IO) { copyToDownload(activity, apk, name) } } @@ -136,7 +136,7 @@ class ExportApkAction( @Suppress("DEPRECATION") val download = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) try { - val copy = ApkExport.copyTo(apk, download, name) + val copy = BuildExport.copyTo(apk, download, name) flashSuccess(activity.getString(R.string.msg_export_apk_saved, copy.name)) } catch (e: IOException) { log.error("Cannot copy {} to {}", apk, download, e) @@ -147,6 +147,6 @@ class ExportApkAction( override fun getShowAsActionFlags(data: ActionData): Int = MenuItem.SHOW_AS_ACTION_IF_ROOM companion object { - private val log = LoggerFactory.getLogger(ExportApkAction::class.java) + private val log = LoggerFactory.getLogger(ExportBuildAction::class.java) } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt b/app/src/main/java/com/itsaky/androidide/utils/BuildExport.kt similarity index 97% rename from app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt rename to app/src/main/java/com/itsaky/androidide/utils/BuildExport.kt index c420c8f5a1..41c8706f06 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/BuildExport.kt @@ -7,10 +7,10 @@ import java.io.File import java.io.IOException /** Finds a built APK and copies it out of app storage under a readable name (ADFA-4930). */ -internal object ApkExport { +internal object BuildExport { const val MIME_TYPE = "application/vnd.android.package-archive" - private val log = LoggerFactory.getLogger(ApkExport::class.java) + private val log = LoggerFactory.getLogger(BuildExport::class.java) /** What [resolve] found to export. */ sealed interface Exportable { diff --git a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt index 5c3f7511a3..c74573d821 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt @@ -38,7 +38,7 @@ import com.itsaky.androidide.actions.editor.LongSelectAction import com.itsaky.androidide.actions.editor.PasteAction import com.itsaky.androidide.actions.editor.SelectAllAction import com.itsaky.androidide.actions.etc.DisconnectLogSendersAction -import com.itsaky.androidide.actions.etc.ExportApkAction +import com.itsaky.androidide.actions.etc.ExportBuildAction import com.itsaky.androidide.actions.etc.FindAction import com.itsaky.androidide.actions.etc.FindInFileAction import com.itsaky.androidide.actions.etc.FindInProjectAction @@ -108,7 +108,7 @@ class EditorActivityActions { registry.registerAction(ReplaceInProjectAction(context, order++)) registry.registerAction(LaunchAppAction(context, order++)) // ADFA-4930: relocated build output keeps the APK out of the project folder. - registry.registerAction(ExportApkAction(context, order++)) + registry.registerAction(ExportBuildAction(context, order++)) registry.registerAction(DisconnectLogSendersAction(context, order++)) // Plugin contributions diff --git a/app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt b/app/src/test/java/com/itsaky/androidide/utils/BuildExportTest.kt similarity index 76% rename from app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt rename to app/src/test/java/com/itsaky/androidide/utils/BuildExportTest.kt index b58c2be3a4..4c7f57a2f2 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/BuildExportTest.kt @@ -7,7 +7,7 @@ import org.junit.rules.TemporaryFolder import java.io.File import java.io.IOException -class ApkExportTest { +class BuildExportTest { @get:Rule val tmp = TemporaryFolder() @@ -19,22 +19,22 @@ class ApkExportTest { @Test fun `no listing means nothing to export`() { - assertThat(ApkExport.resolve(File(tmp.root, "out/output-metadata.json"), report(), tmp.root)) - .isEqualTo(ApkExport.Exportable.NotBuilt) + assertThat(BuildExport.resolve(File(tmp.root, "out/output-metadata.json"), report(), tmp.root)) + .isEqualTo(BuildExport.Exportable.NotBuilt) } @Test fun `a malformed listing is nothing to export, not a crash`() { val listing = File(tmp.newFolder("out"), "output-metadata.json").apply { writeText("{\"elements\": [") } - assertThat(ApkExport.resolve(listing, report(), tmp.root)).isEqualTo(ApkExport.Exportable.NotBuilt) + assertThat(BuildExport.resolve(listing, report(), tmp.root)).isEqualTo(BuildExport.Exportable.NotBuilt) } @Test fun `a standard build's APK is exported`() { val (listing, built) = built() - assertThat(ApkExport.resolve(listing, report(), tmp.root)).isEqualTo(ApkExport.Exportable.Apk(built)) + assertThat(BuildExport.resolve(listing, report(), tmp.root)).isEqualTo(BuildExport.Exportable.Apk(built)) } @Test @@ -42,7 +42,7 @@ class ApkExportTest { val (listing, built) = built() val report = report(names = built, at = built.lastModified() + 1_000) - assertThat(ApkExport.resolve(listing, report, tmp.root)).isEqualTo(ApkExport.Exportable.QuickBuildProxy) + assertThat(BuildExport.resolve(listing, report, tmp.root)).isEqualTo(BuildExport.Exportable.QuickBuildProxy) } @Test @@ -50,7 +50,7 @@ class ApkExportTest { val (listing, built) = built() val report = report(names = built, at = built.lastModified() - 1_000) - assertThat(ApkExport.resolve(listing, report, tmp.root)).isEqualTo(ApkExport.Exportable.Apk(built)) + assertThat(BuildExport.resolve(listing, report, tmp.root)).isEqualTo(BuildExport.Exportable.Apk(built)) } @Test @@ -58,7 +58,7 @@ class ApkExportTest { val download = tmp.newFolder("Download") try { - ApkExport.copyTo(apk, download, "app-debug") { _, to -> + BuildExport.copyTo(apk, download, "app-debug") { _, to -> to.writeText("half an ap") throw IOException("disk full") } @@ -101,7 +101,7 @@ class ApkExportTest { fun `the copy is named after the project and variant`() { val download = tmp.newFolder("Download") - val copy = ApkExport.copyTo(apk, download, "My Application-debug") + val copy = BuildExport.copyTo(apk, download, "My Application-debug") assertThat(copy).isEqualTo(File(download, "My Application-debug.apk")) assertThat(copy.readText()).isEqualTo("apk") @@ -113,7 +113,7 @@ class ApkExportTest { File(download, "app-debug.apk").writeText("first") File(download, "app-debug (2).apk").writeText("second") - val copy = ApkExport.copyTo(apk, download, "app-debug") + val copy = BuildExport.copyTo(apk, download, "app-debug") assertThat(copy.name).isEqualTo("app-debug (3).apk") assertThat(File(download, "app-debug.apk").readText()).isEqualTo("first") @@ -121,7 +121,7 @@ class ApkExportTest { @Test fun `characters a file name cannot hold are replaced`() { - val copy = ApkExport.copyTo(apk, tmp.newFolder("Download"), "a/b:c-debug") + val copy = BuildExport.copyTo(apk, tmp.newFolder("Download"), "a/b:c-debug") assertThat(copy.name).isEqualTo("a_b_c-debug.apk") } @@ -130,13 +130,13 @@ class ApkExportTest { fun `a missing Download folder is created`() { val download = File(tmp.root, "sdcard/Download") - assertThat(ApkExport.copyTo(apk, download, "app-debug").isFile).isTrue() + assertThat(BuildExport.copyTo(apk, download, "app-debug").isFile).isTrue() } @Test(expected = IOException::class) fun `a Download folder that cannot be created is reported`() { val blocker = tmp.newFile("sdcard") - ApkExport.copyTo(apk, File(blocker, "Download"), "app-debug") + BuildExport.copyTo(apk, File(blocker, "Download"), "app-debug") } } From 15a83c944ab1f7acc4bf26541b053eb282b54d17 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 16 Oct 2026 19:19:13 -0700 Subject: [PATCH 29/43] ADFA-4930: export App Bundles and plugins, not only the APK Hal Eisen's review of #2155: with output in app storage, the APK was the only build output a user could still reach. A plugin author could not get at the .cgp to submit it, and an App Bundle built from Run Tasks was stranded too. Export APK becomes Export. For the selected variant it offers what was built: the APK, the App Bundle, and a plugin project's .cgp, with a chooser when there is more than one. Each goes to the share sheet or to Download under its own extension. The model has no bundle listing, so the bundle is found where AGP writes it, outputs/bundle// under the module's build directory from the model. Installing a built plugin deleted the .cgp it was given, which would leave nothing to export; the build's install now hands it a copy. The action keeps its id and tooltip tag, which documentation.db keys on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../actions/etc/ExportBuildAction.kt | 95 +++++++++++++------ .../editor/ProjectHandlerActivity.kt | 12 ++- .../itsaky/androidide/utils/BuildExport.kt | 77 ++++++++++++--- .../androidide/utils/BuildExportTest.kt | 51 ++++++++-- ...6-10-14-adfa-4930-build-output-off-fuse.md | 15 ++- resources/src/main/res/values/strings.xml | 19 ++-- 6 files changed, 215 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/actions/etc/ExportBuildAction.kt b/app/src/main/java/com/itsaky/androidide/actions/etc/ExportBuildAction.kt index 35fb302a4c..d4c8180926 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/etc/ExportBuildAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/etc/ExportBuildAction.kt @@ -14,6 +14,7 @@ import com.itsaky.androidide.idetooltips.TooltipTag.EDITOR_TOOLBAR_EXPORT_APK import com.itsaky.androidide.lookup.Lookup import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.builder.BuildService +import com.itsaky.androidide.projects.isPluginProject import com.itsaky.androidide.projects.models.assembleTaskOutputListingFile import com.itsaky.androidide.projects.models.buildDir import com.itsaky.androidide.quickbuild.QuickBuildTaskPaths @@ -22,6 +23,7 @@ import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.IntentUtils import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess +import com.itsaky.androidide.viewmodel.findPluginCgpFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -30,21 +32,22 @@ import java.io.File import java.io.IOException /** - * Hands over the selected variant's built APK (ADFA-4930): with relocated build output it is in - * app-private storage, where a file manager cannot reach it. Offers the share sheet, or a copy in - * the Download folder. + * Hands over what the selected variant built (ADFA-4930): its APK, its App Bundle, or a plugin + * project's `.cgp`. With relocated build output they are in app-private storage, where a file + * manager cannot reach them. Offers the share sheet, or a copy in the Download folder. */ class ExportBuildAction( context: Context, override val order: Int, ) : EditorActivityAction() { + // Named when only the APK was exported; documentation.db keys the tooltip on the tag. override val id: String = "ide.editor.exportApk" override var requiresUIThread: Boolean = true override fun retrieveTooltipTag(isReadOnlyContext: Boolean) = EDITOR_TOOLBAR_EXPORT_APK init { - label = context.getString(R.string.title_export_apk) + label = context.getString(R.string.title_export_build) icon = ContextCompat.getDrawable(context, R.drawable.ic_share) } @@ -75,22 +78,34 @@ class ExportBuildAction( val activity = data.requireActivity() val projectManager = IProjectManager.getInstance() val projectDir = File(projectManager.projectDirPath) + val isPlugin = projectManager.isPluginProject() // The module only when it tells two app modules' exports apart. val module = app.name.takeIf { projectManager.getAndroidAppModules().size > 1 } val name = listOfNotNull(projectDir.name, module, variant.name).joinToString("-") activity.activityScope.launch { - val found = + val (apk, outputs) = withContext(Dispatchers.IO) { - BuildExport.resolve( - listingFile = variant.mainArtifact.assembleTaskOutputListingFile, - quickBuildReport = QuickBuildTaskPaths.setupJson(app.buildDir, variant.name), - projectDir = projectDir, - ) + val apk = + BuildExport.resolve( + listingFile = variant.mainArtifact.assembleTaskOutputListingFile, + quickBuildReport = QuickBuildTaskPaths.setupJson(app.buildDir, variant.name), + projectDir = projectDir, + ) + apk to + BuildExport.outputs( + apk = apk, + bundle = BuildExport.findBundle(app.buildDir, variant.name), + plugin = if (isPlugin) findPluginCgpFile(projectManager.workspace, variant.name) else null, + ) + } + withContext(Dispatchers.Main) { + when { + outputs.size == 1 -> offerExport(activity, outputs.single(), name) + outputs.isNotEmpty() -> chooseOutput(activity, outputs, name) + isPlugin -> flashError(R.string.msg_export_plugin_not_built) + apk == BuildExport.Exportable.QuickBuildProxy -> flashError(R.string.msg_export_apk_quick_build) + else -> flashError(R.string.msg_export_not_built) } - when (found) { - BuildExport.Exportable.NotBuilt -> flashError(R.string.msg_export_apk_not_built) - BuildExport.Exportable.QuickBuildProxy -> flashError(R.string.msg_export_apk_quick_build) - is BuildExport.Exportable.Apk -> withContext(Dispatchers.Main) { offerExport(activity, found.file, name) } } } } @@ -98,31 +113,57 @@ class ExportBuildAction( private fun buildService(): BuildService? = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) - /** A build, Quick Build's prebuild included, may be rewriting the APK; refuse with a reason. */ + /** A build, Quick Build's prebuild included, may be rewriting the output; refuse with a reason. */ private fun isBuilding(): Boolean { if (buildService()?.isBuildInProgress != true) return false - flashError(R.string.msg_export_apk_build_running) + flashError(R.string.msg_export_build_running) return true } + private fun labelOf( + context: Context, + kind: BuildExport.Kind, + ): String = + context.getString( + when (kind) { + BuildExport.Kind.APK -> R.string.export_kind_apk + BuildExport.Kind.BUNDLE -> R.string.export_kind_bundle + BuildExport.Kind.PLUGIN -> R.string.export_kind_plugin + }, + ) + + private fun chooseOutput( + activity: EditorHandlerActivity, + outputs: List, + name: String, + ) { + DialogUtils + .newMaterialDialogBuilder(activity) + .setTitle(R.string.title_export_build) + .setItems(outputs.map { labelOf(activity, it.kind) }.toTypedArray()) { _, which -> + offerExport(activity, outputs[which], name) + }.setNegativeButton(android.R.string.cancel, null) + .show() + } + private fun offerExport( activity: EditorHandlerActivity, - apk: File, + output: BuildExport.Output, name: String, ) { DialogUtils .newMaterialDialogBuilder(activity) - .setTitle(R.string.title_export_apk) + .setTitle(activity.getString(R.string.title_export_kind, labelOf(activity, output.kind))) .setItems( arrayOf( - activity.getString(R.string.export_apk_share), - activity.getString(R.string.export_apk_copy_to_download), + activity.getString(R.string.export_share), + activity.getString(R.string.export_copy_to_download), ), ) { _, which -> if (which == 0) { - IntentUtils.shareFile(activity, apk, BuildExport.MIME_TYPE) + IntentUtils.shareFile(activity, output.file, output.kind.mimeType) } else if (!isBuilding()) { - activity.activityScope.launch(Dispatchers.IO) { copyToDownload(activity, apk, name) } + activity.activityScope.launch(Dispatchers.IO) { copyToDownload(activity, output, name) } } }.setNegativeButton(android.R.string.cancel, null) .show() @@ -130,17 +171,17 @@ class ExportBuildAction( private fun copyToDownload( activity: EditorHandlerActivity, - apk: File, + output: BuildExport.Output, name: String, ) { @Suppress("DEPRECATION") val download = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) try { - val copy = BuildExport.copyTo(apk, download, name) - flashSuccess(activity.getString(R.string.msg_export_apk_saved, copy.name)) + val copy = BuildExport.copyTo(output.file, download, name, output.kind.extension) + flashSuccess(activity.getString(R.string.msg_export_saved, copy.name)) } catch (e: IOException) { - log.error("Cannot copy {} to {}", apk, download, e) - flashError(R.string.msg_export_apk_failed) + log.error("Cannot copy {} to {}", output.file, download, e) + flashError(R.string.msg_export_failed) } } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 681cfdc11d..86495b88eb 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -127,6 +127,7 @@ import com.itsaky.androidide.utils.DURATION_LONG import com.itsaky.androidide.utils.DialogUtils.newMaterialDialogBuilder import com.itsaky.androidide.utils.DialogUtils.showRestartPrompt import com.itsaky.androidide.utils.FeatureFlags +import com.itsaky.androidide.utils.InstallTempFiles import com.itsaky.androidide.utils.ProjectSearchOptions import com.itsaky.androidide.utils.RecursiveFileSearcher import com.itsaky.androidide.utils.dpToPx @@ -166,6 +167,7 @@ import org.koin.core.context.GlobalContext import org.slf4j.LoggerFactory import java.io.File import java.io.FileNotFoundException +import java.io.IOException import java.net.SocketException import java.nio.file.NoSuchFileException import java.util.concurrent.CompletableFuture @@ -1142,7 +1144,15 @@ abstract class ProjectHandlerActivity : private fun installPlugin(cgpFile: File) { lifecycleScope.launch { setStatus(getString(string.status_installing_plugin)) - val result = pluginRepository.installPluginFromFile(cgpFile) + // Installing deletes the file it is given. A copy keeps the built plugin for Export. + val result = + try { + val copy = InstallTempFiles.newTempFile(filesDir, "built_plugin", cgpFile.extension) + withContext(Dispatchers.IO) { cgpFile.copyTo(copy) } + pluginRepository.installPluginFromFile(copy) + } catch (e: IOException) { + Result.failure(e) + } result .onSuccess { showRestartPrompt(this@ProjectHandlerActivity) diff --git a/app/src/main/java/com/itsaky/androidide/utils/BuildExport.kt b/app/src/main/java/com/itsaky/androidide/utils/BuildExport.kt index 41c8706f06..8867cbe63b 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/BuildExport.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/BuildExport.kt @@ -1,17 +1,37 @@ package com.itsaky.androidide.utils import com.itsaky.androidide.models.ApkMetadata +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo import org.slf4j.LoggerFactory import java.io.File import java.io.IOException -/** Finds a built APK and copies it out of app storage under a readable name (ADFA-4930). */ +/** + * Finds what a build produced - an APK, an App Bundle or a plugin - and copies it out of app storage + * under a readable name (ADFA-4930). + */ internal object BuildExport { - const val MIME_TYPE = "application/vnd.android.package-archive" - private val log = LoggerFactory.getLogger(BuildExport::class.java) + /** A kind of build output, with what a share or a copy of it needs. */ + enum class Kind( + val extension: String, + val mimeType: String, + ) { + APK("apk", "application/vnd.android.package-archive"), + + // Neither has a registered MIME type. + BUNDLE("aab", "application/octet-stream"), + PLUGIN(PLUGIN_ARCHIVE_EXTENSION, "application/octet-stream"), + } + + /** A built file to export. */ + data class Output( + val kind: Kind, + val file: File, + ) + /** What [resolve] found to export. */ sealed interface Exportable { data class Apk( @@ -49,6 +69,38 @@ internal object BuildExport { return if (isQuickBuildProxy(apk, quickBuildReport, projectDir)) Exportable.QuickBuildProxy else Exportable.Apk(apk) } + /** + * What a variant built that can be exported, in the order offered: APK, App Bundle, plugin. + * + * @param apk what [resolve] found for the variant. + * @param bundle from [findBundle]. + * @param plugin a plugin project's `.cgp` for the variant. + */ + fun outputs( + apk: Exportable, + bundle: File?, + plugin: File?, + ): List = + listOfNotNull( + (apk as? Exportable.Apk)?.let { Output(Kind.APK, it.file) }, + bundle?.let { Output(Kind.BUNDLE, it) }, + plugin?.let { Output(Kind.PLUGIN, it) }, + ) + + /** + * The newest App Bundle `bundle` wrote for [variantName]. The synced model has no bundle + * listing, so this is AGP's fixed location under the module's build directory. + * + * @param buildDir the module's build directory, from the synced model. + */ + fun findBundle( + buildDir: File, + variantName: String, + ): File? = + File(buildDir, "outputs/bundle/$variantName") + .listFiles { file -> file.isFile && file.extension == Kind.BUNDLE.extension } + ?.maxByOrNull { it.lastModified() } + /** * Quick Build's report is written after the proxy APK it names, so the APK is the proxy when * the report names it and is not older than it. A standard build rewrites the APK and leaves @@ -70,26 +122,28 @@ internal object BuildExport { } /** - * Copies [apk] into [dir] as `.apk`, or ` (2).apk` and so on when that is taken, so - * an earlier export is never overwritten. The copy goes to a temporary name first, so one that - * fails part-way never leaves a broken APK under the export's name. + * Copies [file] into [dir] as `.`, or ` (2).` and so on when + * that is taken, so an earlier export is never overwritten. The copy goes to a temporary name + * first, so one that fails part-way never leaves a broken file under the export's name. * - * @param name the file name without `.apk`; characters a file name cannot hold are replaced. + * @param name the file name without its extension; characters a file name cannot hold are + * replaced. * @return the copy. * @throws IOException when [dir] cannot be created or the copy fails. */ fun copyTo( - apk: File, + file: File, dir: File, name: String, + extension: String, copy: (File, File) -> Unit = { from, to -> from.copyTo(to) }, ): File { if (!dir.isDirectory && !dir.mkdirs()) throw IOException("Cannot create $dir") - val target = freeTarget(dir, safeFileName(name, "app")) + val target = freeTarget(dir, safeFileName(name, "app"), extension) val partial = File(dir, ".${target.name}.partial") try { partial.delete() - copy(apk, partial) + copy(file, partial) if (!partial.renameTo(target)) throw IOException("Cannot rename $partial to $target") } finally { partial.delete() @@ -100,8 +154,9 @@ internal object BuildExport { private fun freeTarget( dir: File, name: String, + extension: String, ): File = generateSequence(1) { it + 1 } - .map { n -> File(dir, if (n == 1) "$name.apk" else "$name ($n).apk") } + .map { n -> File(dir, if (n == 1) "$name.$extension" else "$name ($n).$extension") } .first { !it.exists() } } diff --git a/app/src/test/java/com/itsaky/androidide/utils/BuildExportTest.kt b/app/src/test/java/com/itsaky/androidide/utils/BuildExportTest.kt index 4c7f57a2f2..23327b1ca3 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/BuildExportTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/BuildExportTest.kt @@ -58,7 +58,7 @@ class BuildExportTest { val download = tmp.newFolder("Download") try { - BuildExport.copyTo(apk, download, "app-debug") { _, to -> + BuildExport.copyTo(apk, download, "app-debug", "apk") { _, to -> to.writeText("half an ap") throw IOException("disk full") } @@ -101,7 +101,7 @@ class BuildExportTest { fun `the copy is named after the project and variant`() { val download = tmp.newFolder("Download") - val copy = BuildExport.copyTo(apk, download, "My Application-debug") + val copy = BuildExport.copyTo(apk, download, "My Application-debug", "apk") assertThat(copy).isEqualTo(File(download, "My Application-debug.apk")) assertThat(copy.readText()).isEqualTo("apk") @@ -113,7 +113,7 @@ class BuildExportTest { File(download, "app-debug.apk").writeText("first") File(download, "app-debug (2).apk").writeText("second") - val copy = BuildExport.copyTo(apk, download, "app-debug") + val copy = BuildExport.copyTo(apk, download, "app-debug", "apk") assertThat(copy.name).isEqualTo("app-debug (3).apk") assertThat(File(download, "app-debug.apk").readText()).isEqualTo("first") @@ -121,7 +121,7 @@ class BuildExportTest { @Test fun `characters a file name cannot hold are replaced`() { - val copy = BuildExport.copyTo(apk, tmp.newFolder("Download"), "a/b:c-debug") + val copy = BuildExport.copyTo(apk, tmp.newFolder("Download"), "a/b:c-debug", "apk") assertThat(copy.name).isEqualTo("a_b_c-debug.apk") } @@ -130,13 +130,52 @@ class BuildExportTest { fun `a missing Download folder is created`() { val download = File(tmp.root, "sdcard/Download") - assertThat(BuildExport.copyTo(apk, download, "app-debug").isFile).isTrue() + assertThat(BuildExport.copyTo(apk, download, "app-debug", "apk").isFile).isTrue() } @Test(expected = IOException::class) fun `a Download folder that cannot be created is reported`() { val blocker = tmp.newFile("sdcard") - BuildExport.copyTo(apk, File(blocker, "Download"), "app-debug") + BuildExport.copyTo(apk, File(blocker, "Download"), "app-debug", "apk") + } + + @Test + fun `the newest bundle of the variant is found`() { + val buildDir = tmp.newFolder("tree", "main", "app-1234") + val release = File(buildDir, "outputs/bundle/release").apply { mkdirs() } + val old = File(release, "app-release-old.aab").apply { writeText("old") } + old.setLastModified(1_000) + val newest = File(release, "app-release.aab").apply { writeText("new") } + newest.setLastModified(2_000) + File(release, "app-release.aab.idsig").writeText("not a bundle") + File(buildDir, "outputs/bundle/debug").mkdirs() + + assertThat(BuildExport.findBundle(buildDir, "release")).isEqualTo(newest) + assertThat(BuildExport.findBundle(buildDir, "debug")).isNull() + assertThat(BuildExport.findBundle(buildDir, "freeRelease")).isNull() + } + + @Test + fun `outputs are offered APK first, and never Quick Build's proxy`() { + val built = apk + val bundle = File(tmp.root, "app-release.aab") + val plugin = File(tmp.root, "plugin-debug.cgp") + + assertThat(BuildExport.outputs(BuildExport.Exportable.Apk(built), bundle, plugin).map { it.kind }) + .containsExactly(BuildExport.Kind.APK, BuildExport.Kind.BUNDLE, BuildExport.Kind.PLUGIN) + .inOrder() + assertThat(BuildExport.outputs(BuildExport.Exportable.QuickBuildProxy, bundle, null)) + .containsExactly(BuildExport.Output(BuildExport.Kind.BUNDLE, bundle)) + assertThat(BuildExport.outputs(BuildExport.Exportable.NotBuilt, null, null)).isEmpty() + } + + @Test + fun `a copy keeps its kind's extension`() { + val download = tmp.newFolder("Download") + val bundle = File(tmp.root, "app-release.aab").apply { writeText("aab") } + + assertThat(BuildExport.copyTo(bundle, download, "MyApp-release", "aab").name).isEqualTo("MyApp-release.aab") + assertThat(BuildExport.copyTo(bundle, download, "MyApp-release", "aab").name).isEqualTo("MyApp-release (2).aab") } } diff --git a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md index f65bd988d3..124616e07e 100644 --- a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md +++ b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md @@ -75,7 +75,7 @@ Measured 2026-10-14 with the IDE's Gradle arguments, same template project on bo These are the tradeoffs from the original analysis, with how this plan handles each: -- **The APK is no longer in the project folder**, file manager or MTP. Add an **Export APK** toolbar action, shown while relocation is available, that offers the share sheet or a copy in `Download/` (`[project]-[variant].apk`, numbered when taken). The survey found no existing export action. +- **The APK is no longer in the project folder**, file manager or MTP. Add an **Export APK** toolbar action, shown while relocation is available, that offers the share sheet or a copy in `Download/` (`[project]-[variant].apk`, numbered when taken). The survey found no existing export action. After review it became **Export**, covering the App Bundle (`outputs/bundle/[variant]/`, as the model has no bundle listing) and a plugin project's `.cgp` too, and installing a built plugin no longer deletes the `.cgp`. - **Build output no longer travels with a copied project folder.** That's acceptable: it can be rebuilt, and copying `build/` is rarely wanted. - **Terminal builds** (`./gradlew` in the IDE's terminal) don't get the redirect, so they still build into the project folder, separately from the IDE's tree. Document this. Changing it is a follow-up if users trip over it. @@ -132,9 +132,20 @@ Feature: Build output in app storage Scenario: Export APK Given a built project - When I choose Export APK + When I choose Export Then the APK appears in Download + Scenario: Export an App Bundle + Given a project where Run Tasks has run ":app:bundleRelease" after a run + When I choose Export + Then I can choose between the APK and the App Bundle + And choosing the App Bundle and Copy to Download puts "[project]-release.aab" in Download + + Scenario: Export a plugin after installing it + Given a plugin project that has been run, and its plugin installed + When I choose Export + Then "Copy to Download" puts the plugin's ".cgp" in Download + Scenario: Deleting a project frees its build output Given a built project When I delete it from the main screen diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 0d40e8f393..dcad0b8eb6 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -880,14 +880,19 @@ Cannot run application. Unable to determine package name. Cannot run application. No application modules found in project. Launch app - Export APK - Share - Copy to Download - No APK yet. Run the app once to build it. - Saved to Download as %1$s - Could not copy the APK to Download + Export + Export %1$s + APK + App Bundle (.aab) + Plugin (.cgp) + Share + Copy to Download + Nothing built yet. Run the app once to build it. + No plugin yet. Run the plugin project once to build it. + Saved to Download as %1$s + Could not copy the file to Download The latest APK is Quick Build\'s live-reload app. Run the app once to build a regular APK, then export it. - A build is running. Export the APK when it finishes. + A build is running. Export when it finishes. Experimental You are using %1$s variant of Code On The Go on a %2$s device. This configuration may work, but it\'ll result in reduced performance and possible security vulnerabilities. Please avoid using this configuration whenever possible. Pin line numbers From 0b2f87048f0b75c5bcd488408f6ecb4492cba66e Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 16 Oct 2026 19:23:43 -0700 Subject: [PATCH 30/43] ADFA-4930: relocate included builds' output too The init script plugin read the output directory from each build's own start parameters. Gradle copies project properties into buildSrc's, but not into an included build's, so an included build (includeBuild, such as a build-logic of convention plugins) found no property and kept its build output in the project folder. ARCHITECTURE.md and BuildOutputRedirect said nested builds get a directory of their own. The plugin now reads the property from the root build. The new TestKit test runs a composite with an included build on Gradle 9.6.1; without the fix the included build logs no relocation. An included build's .gradle/ and .kotlin/ still stay in its folder: Gradle passes neither setting to it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../gradle/AndroidIDEInitScriptPlugin.kt | 7 ++++- .../gradle/AndroidIDEInitScriptPluginTest.kt | 30 +++++++++++++++++++ .../com/itsaky/androidide/gradle/utils.kt | 4 ++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPlugin.kt b/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPlugin.kt index cea40ca955..33e8b6ee36 100644 --- a/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPlugin.kt +++ b/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPlugin.kt @@ -120,9 +120,14 @@ class AndroidIDEInitScriptPlugin : Plugin { * [PROPERTY_BUILD_OUTPUT_DIR], off the FUSE-backed project folder (ADFA-4930). `beforeProject` * runs before any build script, so AGP, Kotlin and the IDE's own plugins all see the new * location. Without the property, Gradle's default stays. + * + * The property is read from the root build: Gradle copies project properties into `buildSrc`'s + * start parameters but not into an included build's, which would otherwise keep its output in + * the project folder. */ private fun redirectBuildOutput(gradle: Gradle) { - val outputDir = gradle.startParameter.projectProperties[PROPERTY_BUILD_OUTPUT_DIR] + val rootBuild = generateSequence(gradle) { it.parent }.last() + val outputDir = rootBuild.startParameter.projectProperties[PROPERTY_BUILD_OUTPUT_DIR] if (outputDir.isNullOrBlank()) return val root = File(outputDir) diff --git a/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPluginTest.kt b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPluginTest.kt index 6f716e50fb..e84cc36d7a 100644 --- a/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPluginTest.kt +++ b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPluginTest.kt @@ -106,6 +106,36 @@ class AndroidIDEInitScriptPluginTest { } } + @Test + fun `test an included build's output is relocated too`( + @TempDir dir: File, + ) { + val project = File(dir, "composite") + File( + project, + "settings.gradle.kts", + ).also { it.parentFile.mkdirs() }.writeText("rootProject.name = \"composite\"\nincludeBuild(\"lib\")\n") + File(project, "build.gradle.kts").writeText("") + val lib = File(project, "lib").apply { mkdirs() } + File(lib, "settings.gradle.kts").writeText("rootProject.name = \"lib\"\n") + File(lib, "build.gradle.kts").writeText("") + val output = File(dir, "output") + + val result = + buildProject( + task = ":lib:help", + projectDir = project.toPath(), + configureArgs = { + it += "-P${GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR}=${output.absolutePath}" + it += "--info" + }, + ) + + // An included build gets no project properties of its own, so it reads the root build's. + val expected = BuildOutputRedirect.buildDirFor(output, true, lib.canonicalFile, ":") + assertThat(result.output).contains("Build directory of project ':' relocated to $expected") + } + @Test fun `test build output stays put by default`() { val result = buildProject(configureArgs = { it += "--info" }) diff --git a/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/utils.kt b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/utils.kt index ade8511fd2..ab3f32e6e5 100644 --- a/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/utils.kt +++ b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/utils.kt @@ -36,9 +36,11 @@ internal fun buildProject( logSenderAar: File? = null, task: String = ":app:tasks", configureArgs: (MutableList) -> Unit = {}, + // A project of the test's own; the sample project otherwise. + projectDir: Path? = null, vararg plugins: String ): BuildResult { - val projectRoot = openProject(agpVersion, useApplyPluginGroovySyntax, *plugins) + val projectRoot = projectDir ?: openProject(agpVersion, useApplyPluginGroovySyntax, *plugins) val initScript = FileProvider.testHomeDir() .resolve("${SharedEnvironment.PROJECT_CACHE_DIR_NAME}/init/androidide.init.gradle") val mavenLocal = FileProvider.projectRoot().resolve("gradle-plugin/build/maven-local/repos.txt").toFile() From 0ab45854c949e73f3830a03615a41f80dbc96362 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 16 Oct 2026 19:26:26 -0700 Subject: [PATCH 31/43] ADFA-4930: OldBuildOutput and ARCHITECTURE.md after the included-build fix Included builds now relocate their build directories (merged from #2149), so only their .gradle/ and .kotlin/ stay in use in the project folder; say so instead of "included builds". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- ARCHITECTURE.md | 2 +- .../androidide/services/builder/OldBuildOutput.kt | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c77bc26821..fec0642ae7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -65,7 +65,7 @@ Whether the open project *can* be named at all is `isDeepLinkTargetOfOpenProject **Gradle build output lives in app storage, not the project folder (ADFA-4930).** Projects sit on shared storage (`/sdcard`), which Android serves through FUSE, so every file a build writes pays a user-space round trip. Each project's build output goes to `noBackupFilesDir/gradle-build//` (`services/builder/BuildOutputLocation`), keyed like Quick Build's scratch (`ProjectStorageKey` in `:shared`): - `main/-/` per Gradle project (`` of its full path, so `:core:ui` is not inside `:core`'s output); nested builds get their own key. The init script plugin sets `layout.buildDirectory` from `-Pcotg.build.outputDir` (`gradle-plugin/.../BuildOutputRedirect`). -- `project-cache/` (`--project-cache-dir`, Gradle's `.gradle/`) and `kotlin/` (`kotlin.project.persistent.dir`). +- `project-cache/` (`--project-cache-dir`, Gradle's `.gradle/`) and `kotlin/` (`kotlin.project.persistent.dir`), for the main build; an included build keeps its own, as Gradle passes neither setting to it. - `project-path`, a marker naming the project, used to delete a project's output with it and to sweep orphans at startup. **The synced model is the record of where output goes.** Every sync relocates, and opening a project does not sync, so an existing project's cached model keeps building into the project folder until its next sync; a build relocates exactly when the model's root build directory is in the tree, so its output is always where the model (APK listing file, classpath, Quick Build's `setup.json`, a plugin's `.cgp`) looks, and a sync that fails changes neither. Code that needs build output reads it from the model's build directories, never from `/build`. Builds started outside the IDE (`./gradlew` in the terminal) don't get the redirect and still write to the project folder. After a project's first relocated initialization, the stale output its folder still held is removed once (`services/builder/OldBuildOutput`), without following symlinks. diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt b/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt index 7b4e170ecf..412af8a041 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt @@ -16,11 +16,11 @@ object OldBuildOutput { const val REMOVED_FILE = "old-output-removed" /** - * The stale folders of a relocated project: each Gradle project's `build/`, and the Gradle and - * Kotlin caches of the root and of `buildSrc`. Only folders Gradle makes by default, inside - * [rootDir]. Left alone: a symlink; a folder that is a project's directory or holds one; a folder - * that holds or lies in a build directory the model still uses; and `buildSrc/.gradle/` and - * included builds, which builds still use. + * The stale folders of a relocated project: the `build/` of each project the synced model lists, + * and the Gradle and Kotlin caches of the root and of `buildSrc`. Only folders Gradle makes by + * default, inside [rootDir]. Left alone: a symlink; a folder that is a project's directory or + * holds one; a folder that holds or lies in a build directory the model still uses; and + * `buildSrc/.gradle/` and an included build's `.gradle/` and `.kotlin/`, which builds still use. * * @param rootDir the root project's directory. * @param projectDirs the directory of every project in the synced model. From c61696ffbb259d771d432c6b104534ddaf24e014 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 16 Oct 2026 20:35:57 -0700 Subject: [PATCH 32/43] ADFA-4930: keep old-output removal inside the project, and retry it Two findings by CodeRabbit on #2155, both reproduced by a test that fails before the fix: - A module folder that is a symlink out of the project passed find(): its build/ lay lexically under the root, and NOFOLLOW_LINKS only checks the last component. Containment is now checked on canonical paths, so the linked module's build/ outside the project is kept. - removeOnce recorded the removal even when a folder could not be deleted, so a half-deleted build/, stale APK included, was never tried again. The marker is now written only once everything found is gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../services/builder/OldBuildOutput.kt | 30 +++++++++++----- .../services/builder/OldBuildOutputTest.kt | 36 +++++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt b/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt index 412af8a041..1b61b1a77b 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt @@ -18,7 +18,8 @@ object OldBuildOutput { /** * The stale folders of a relocated project: the `build/` of each project the synced model lists, * and the Gradle and Kotlin caches of the root and of `buildSrc`. Only folders Gradle makes by - * default, inside [rootDir]. Left alone: a symlink; a folder that is a project's directory or + * default, inside [rootDir] once symlinks are resolved, so a linked module folder cannot lead the + * deletion out of the project. Left alone: a symlink; a folder that is a project's directory or * holds one; a folder that holds or lies in a build directory the model still uses; and * `buildSrc/.gradle/` and an included build's `.gradle/` and `.kotlin/`, which builds still use. * @@ -32,13 +33,14 @@ object OldBuildOutput { buildDirs: List, ): List { val root = rootDir.absoluteFile.normalize() + val canonicalRoot = canonicalOrNull(root) ?: return emptyList() val projects = projectDirs.map { it.absoluteFile.normalize() } val inUse = buildDirs.map { it.absoluteFile.normalize() } val candidates = projects.map { File(it, "build") } + listOf(".gradle", ".kotlin", "buildSrc/build", "buildSrc/.kotlin").map { File(root, it) } return candidates.distinct().filter { dir -> - dir.startsWith(root) && + canonicalOrNull(dir)?.startsWith(canonicalRoot) == true && Files.isDirectory(dir.toPath(), LinkOption.NOFOLLOW_LINKS) && projects.none { it.startsWith(dir) } && inUse.none { it.startsWith(dir) || dir.startsWith(it) } @@ -46,7 +48,9 @@ object OldBuildOutput { } /** - * Deletes what [find] returns, unless [tree] records that it was done before. + * Deletes what [find] returns, unless [tree] records that it was done before. It is recorded only + * once everything is gone, so a deletion that fails part-way is tried again at the next relocated + * initialization. * * @param tree the project's tree, from [BuildOutputLocation.treeFor]. * @return how many folders were deleted. @@ -59,12 +63,22 @@ object OldBuildOutput { ): Int { val marker = File(tree, REMOVED_FILE) if (marker.exists()) return 0 - val removed = find(rootDir, projectDirs, buildDirs).count { deleteWithoutFollowingLinks(it) } - try { - marker.createNewFile() - } catch (e: IOException) { - // Not recorded: the next relocated initialization tries again, and finds less to do. + val stale = find(rootDir, projectDirs, buildDirs) + val removed = stale.count { deleteWithoutFollowingLinks(it) } + if (removed == stale.size) { + try { + marker.createNewFile() + } catch (e: IOException) { + // Not recorded: the next relocated initialization tries again, and finds nothing to do. + } } return removed } + + private fun canonicalOrNull(file: File): File? = + try { + file.canonicalFile + } catch (e: IOException) { + null + } } diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt index edc962ca82..ab76a1dbae 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt @@ -104,4 +104,40 @@ class OldBuildOutputTest { assertThat(OldBuildOutput.removeOnce(tree, root, projects, buildDirs)).isEqualTo(0) assertThat(File(root, "app/build").isDirectory).isTrue() } + + @Test + fun `a build folder reached through a symlinked module folder is kept`() { + val root = tmp.newFolder("project") + val tree = tmp.newFolder("tree") + val shared = tmp.newFolder("shared", "app") + File(shared, "build/outputs").mkdirs() + // The module's folder is a link out of the project; its build folder itself is real. + Files.createSymbolicLink(File(root, "app").toPath(), shared.toPath()) + + val found = OldBuildOutput.find(root, listOf(root, File(root, "app")), listOf(File(tree, "root"), File(tree, "app"))) + + assertThat(found).isEmpty() + assertThat(File(shared, "build/outputs").isDirectory).isTrue() + } + + @Test + fun `a deletion that fails is tried again at the next initialization`() { + val root = tmp.newFolder("project") + val tree = tmp.newFolder("tree") + dirs(root, "app/build/outputs", ".gradle") + val locked = File(root, "app/build/outputs").apply { File(this, "app-debug.apk").writeText("apk") } + val projects = listOf(root, File(root, "app")) + val buildDirs = listOf(File(tree, "root"), File(tree, "app")) + + locked.setWritable(false) + try { + assertThat(OldBuildOutput.removeOnce(tree, root, projects, buildDirs)).isEqualTo(1) + assertThat(File(tree, OldBuildOutput.REMOVED_FILE).exists()).isFalse() + } finally { + locked.setWritable(true) + } + assertThat(OldBuildOutput.removeOnce(tree, root, projects, buildDirs)).isEqualTo(1) + assertThat(File(root, "app/build").exists()).isFalse() + assertThat(File(tree, OldBuildOutput.REMOVED_FILE).exists()).isTrue() + } } From 5b25a221eed3e432d420cbb3897a3c19d69e52fd Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 27 Oct 2026 16:54:22 -0700 Subject: [PATCH 33/43] ADFA-4930: take old build/ folders only from relocated projects Hal Eisen's review of #2155: OldBuildOutput.find made /build a candidate for every project in the model. A subproject whose build script sets its own build directory (layout.buildDirectory = file("out")) keeps /out in the model, so inUse protected only that, and a user's own /build folder passed every filter and was deleted for good. build/ candidates now come only from projects whose build directory in the model is in the tree: for those, build/ was Gradle's default before the move. The new test, a subproject with its own build directory and a user file in its build/ folder, fails without the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../services/builder/OldBuildOutput.kt | 15 +++++++---- .../services/builder/OldBuildOutputTest.kt | 26 +++++++++++++++---- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt b/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt index 1b61b1a77b..411612a0ad 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt @@ -16,19 +16,23 @@ object OldBuildOutput { const val REMOVED_FILE = "old-output-removed" /** - * The stale folders of a relocated project: the `build/` of each project the synced model lists, - * and the Gradle and Kotlin caches of the root and of `buildSrc`. Only folders Gradle makes by - * default, inside [rootDir] once symlinks are resolved, so a linked module folder cannot lead the + * The stale folders of a relocated project: the `build/` of each project the synced model put in + * [tree], and the Gradle and Kotlin caches of the root and of `buildSrc`. For a project in [tree], + * `build/` was Gradle's default before the move; a project whose build script sets its own build + * directory stays where it was, so a `build/` folder in it was never Gradle's and is kept. Only + * folders inside [rootDir] once symlinks are resolved, so a linked module folder cannot lead the * deletion out of the project. Left alone: a symlink; a folder that is a project's directory or * holds one; a folder that holds or lies in a build directory the model still uses; and * `buildSrc/.gradle/` and an included build's `.gradle/` and `.kotlin/`, which builds still use. * * @param rootDir the root project's directory. + * @param tree the project's tree, from [BuildOutputLocation.treeFor]. * @param projectDirs the directory of every project in the synced model. * @param buildDirs the build directory of every project in the synced model. */ fun find( rootDir: File, + tree: File, projectDirs: List, buildDirs: List, ): List { @@ -36,8 +40,9 @@ object OldBuildOutput { val canonicalRoot = canonicalOrNull(root) ?: return emptyList() val projects = projectDirs.map { it.absoluteFile.normalize() } val inUse = buildDirs.map { it.absoluteFile.normalize() } + val relocated = projects.zip(inUse).filter { (_, buildDir) -> BuildOutputLocation.isRelocated(buildDir, tree) } val candidates = - projects.map { File(it, "build") } + + relocated.map { (projectDir, _) -> File(projectDir, "build") } + listOf(".gradle", ".kotlin", "buildSrc/build", "buildSrc/.kotlin").map { File(root, it) } return candidates.distinct().filter { dir -> canonicalOrNull(dir)?.startsWith(canonicalRoot) == true && @@ -63,7 +68,7 @@ object OldBuildOutput { ): Int { val marker = File(tree, REMOVED_FILE) if (marker.exists()) return 0 - val stale = find(rootDir, projectDirs, buildDirs) + val stale = find(rootDir, tree, projectDirs, buildDirs) val removed = stale.count { deleteWithoutFollowingLinks(it) } if (removed == stale.size) { try { diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt index ab76a1dbae..9e426ef9f4 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt @@ -22,7 +22,7 @@ class OldBuildOutputTest { val tree = tmp.newFolder("tree") dirs(root, "build", "app/build", ".gradle", ".kotlin", "buildSrc/build", "buildSrc/.kotlin", "buildSrc/.gradle", ".cxx") - val found = OldBuildOutput.find(root, listOf(root, File(root, "app")), listOf(File(tree, "root"), File(tree, "app"))) + val found = OldBuildOutput.find(root, tree, listOf(root, File(root, "app")), listOf(File(tree, "root"), File(tree, "app"))) assertThat(found.map { it.relativeTo(root).path }).containsExactly( "build", @@ -40,7 +40,7 @@ class OldBuildOutputTest { val tree = tmp.newFolder("tree") val module = dirs(root, "build/src").first().parentFile - val found = OldBuildOutput.find(root, listOf(root, module), listOf(File(tree, "root"), File(tree, "build"))) + val found = OldBuildOutput.find(root, tree, listOf(root, module), listOf(File(tree, "root"), File(tree, "build"))) assertThat(found).isEmpty() } @@ -52,7 +52,7 @@ class OldBuildOutputTest { dirs(root, "build/custom", "app/build") // The root project set its own build directory, overriding the relocation. - val found = OldBuildOutput.find(root, listOf(root, File(root, "app")), listOf(File(root, "build/custom"), File(tree, "app"))) + val found = OldBuildOutput.find(root, tree, listOf(root, File(root, "app")), listOf(File(root, "build/custom"), File(tree, "app"))) assertThat(found.map { it.relativeTo(root).path }).containsExactly("app/build") } @@ -66,7 +66,7 @@ class OldBuildOutputTest { val elsewhere = tmp.newFolder("elsewhere") Files.createSymbolicLink(File(root, "build").toPath(), elsewhere.toPath()) - val found = OldBuildOutput.find(root, listOf(root, outside), listOf(File(tree, "root"), File(tree, "lib"))) + val found = OldBuildOutput.find(root, tree, listOf(root, outside), listOf(File(tree, "root"), File(tree, "lib"))) assertThat(found).isEmpty() } @@ -114,7 +114,7 @@ class OldBuildOutputTest { // The module's folder is a link out of the project; its build folder itself is real. Files.createSymbolicLink(File(root, "app").toPath(), shared.toPath()) - val found = OldBuildOutput.find(root, listOf(root, File(root, "app")), listOf(File(tree, "root"), File(tree, "app"))) + val found = OldBuildOutput.find(root, tree, listOf(root, File(root, "app")), listOf(File(tree, "root"), File(tree, "app"))) assertThat(found).isEmpty() assertThat(File(shared, "build/outputs").isDirectory).isTrue() @@ -140,4 +140,20 @@ class OldBuildOutputTest { assertThat(File(root, "app/build").exists()).isFalse() assertThat(File(tree, OldBuildOutput.REMOVED_FILE).exists()).isTrue() } + + @Test + fun `a build folder in a project with its own build directory is the user's`() { + val root = tmp.newFolder("project") + val tree = tmp.newFolder("tree") + dirs(root, "app/build", "lib/out", "lib/build") + val notes = File(root, "lib/build/notes.txt").apply { writeText("mine") } + val projects = listOf(root, File(root, "app"), File(root, "lib")) + // :lib sets layout.buildDirectory = file("out"), which overrides the relocation. + val buildDirs = listOf(File(tree, "root"), File(tree, "app"), File(root, "lib/out")) + + assertThat(OldBuildOutput.find(root, tree, projects, buildDirs).map { it.relativeTo(root).path }) + .containsExactly("app/build") + OldBuildOutput.removeOnce(tree, root, projects, buildDirs) + assertThat(notes.readText()).isEqualTo("mine") + } } From 55ebfde539af8a21492832a4420c5f85a1fb2c06 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 27 Oct 2026 16:54:22 -0700 Subject: [PATCH 34/43] ADFA-4930: relocate when any project does; stop offering a futile sync Hal Eisen's review of #2155: the relocation banner came back after every sync, and on every open, for a project a sync cannot relocate, and with the setting gone nothing stopped it. Two causes, each fixed: - A root build script that sets its own build directory. Builds relocated only when the root's model build directory was in the tree, so here they ran without the relocation flags while the model still put every subproject's output in the tree: :app built into app/build, where Run and Export do not look. A model now counts as relocated when any project's build directory is in the tree, so builds follow it, and the banner sees it as relocated. - A sync that cannot relocate at all: every build script sets its own build directory, or the tree cannot be written. Each relocating sync now records itself in the tree (relocating-sync), and the banner is offered only while none has run and the tree can be written. Each new test fails without its fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- ARCHITECTURE.md | 2 +- .../editor/ProjectHandlerActivity.kt | 13 ++++- .../services/builder/BuildOutputLocation.kt | 47 ++++++++++++++--- .../services/builder/GradleBuildService.kt | 1 + .../builder/BuildOutputLocationTest.kt | 52 +++++++++++++++++-- ...6-10-14-adfa-4930-build-output-off-fuse.md | 4 +- 6 files changed, 102 insertions(+), 17 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fec0642ae7..71b44d1461 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -68,7 +68,7 @@ Whether the open project *can* be named at all is `isDeepLinkTargetOfOpenProject - `project-cache/` (`--project-cache-dir`, Gradle's `.gradle/`) and `kotlin/` (`kotlin.project.persistent.dir`), for the main build; an included build keeps its own, as Gradle passes neither setting to it. - `project-path`, a marker naming the project, used to delete a project's output with it and to sweep orphans at startup. -**The synced model is the record of where output goes.** Every sync relocates, and opening a project does not sync, so an existing project's cached model keeps building into the project folder until its next sync; a build relocates exactly when the model's root build directory is in the tree, so its output is always where the model (APK listing file, classpath, Quick Build's `setup.json`, a plugin's `.cgp`) looks, and a sync that fails changes neither. Code that needs build output reads it from the model's build directories, never from `/build`. Builds started outside the IDE (`./gradlew` in the terminal) don't get the redirect and still write to the project folder. After a project's first relocated initialization, the stale output its folder still held is removed once (`services/builder/OldBuildOutput`), without following symlinks. +**The synced model is the record of where output goes.** Every sync relocates, and opening a project does not sync, so an existing project's cached model keeps building into the project folder until its next sync; a build relocates exactly when any project's build directory in the model is in the tree (a build script that sets its own overrides the relocation for that project alone), so its output is always where the model (APK listing file, classpath, Quick Build's `setup.json`, a plugin's `.cgp`) looks, and a sync that fails changes neither. Code that needs build output reads it from the model's build directories, never from `/build`. Builds started outside the IDE (`./gradlew` in the terminal) don't get the redirect and still write to the project folder. After a project's first relocated initialization, the stale output its folder still held is removed once (`services/builder/OldBuildOutput`), without following symlinks. ## Module Structure diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 86495b88eb..4eb60fb9c5 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -1238,7 +1238,8 @@ abstract class ProjectHandlerActivity : /** * ADFA-4930: an existing project's cached model builds into the project folder until its next * sync, and opening it does not sync, so offer one, once, and withdraw the offer once the synced - * model is relocated. This is the first thing an existing project shows after the update. + * model is relocated, or once a sync can no longer relocate it. This is the first thing an + * existing project shows after the update. * * @param afterInit whether a project initialization just finished. It applied any Gradle file * changes too, so the banner can go even when it also stands for those. @@ -1248,7 +1249,15 @@ abstract class ProjectHandlerActivity : val path = ProjectManagerImpl.getInstance().projectDirPath.takeIf { it.isNotBlank() } ?: return@launch val projectDir = File(path) val modelRelocated = BuildOutputLocation.modelRelocated(noBackupFilesDir, projectDir) - val pending = BuildOutputLocation.syncPending(modelRelocated) + val pending = + BuildOutputLocation.syncPending(modelRelocated) && + withContext(Dispatchers.IO) { + BuildOutputLocation.syncCanRelocate(BuildOutputLocation.treeFor(noBackupFilesDir, projectDir), projectDir) + }.also { canRelocate -> + if (!canRelocate) { + log.info("No relocation sync offered for {}: one already ran, or its tree cannot be written", projectDir) + } + } withContext(Dispatchers.Main) { if (!pending) { val banner = buildOutputSyncBanner diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index f38c7891e2..934e5ccdb9 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -22,9 +22,9 @@ import java.io.IOException * up to about twice as fast on the devices measured. * * The synced project model is the record of where output goes. Every sync relocates; a build - * follows the model's root build directory ([modelRelocated]), so it writes where the model (APK - * listing file, classpath) looks, and a sync that fails leaves both as they were. An existing - * project's cached model builds into the project folder until its next sync ([syncPending]). + * follows the model ([modelRelocated]), so it writes where the model (APK listing file, classpath) + * looks, and a sync that fails leaves both as they were. An existing project's cached model builds + * into the project folder until its next sync ([syncPending]). */ object BuildOutputLocation { /** Parent of every project's tree, under `noBackupFilesDir`. */ @@ -33,6 +33,9 @@ object BuildOutputLocation { /** Holds the project's canonical path, so a sweep can find trees whose project is gone. */ const val PROJECT_PATH_FILE = "project-path" + /** Written in a tree by every sync that relocates; see [syncCanRelocate]. */ + const val RELOCATING_SYNC_FILE = "relocating-sync" + private const val KOTLIN_PERSISTENT_DIR = "kotlin.project.persistent.dir" private const val WATCH_FS = "--watch-fs" private const val NO_WATCH_FS = "--no-watch-fs" @@ -208,9 +211,9 @@ object BuildOutputLocation { /** * Whether a synced model's build directory lies in [tree], meaning the sync that produced the - * model relocated build output. + * model relocated that project's build output. * - * @param modelBuildDir the root project's build directory in the synced model. + * @param modelBuildDir a project's build directory in the synced model. * @param tree the project's tree, from [treeFor]. */ fun isRelocated( @@ -219,7 +222,11 @@ object BuildOutputLocation { ): Boolean = modelBuildDir.absoluteFile.normalize().startsWith(tree.absoluteFile.normalize()) /** - * [isRelocated] for the synced model of the open project, or null when no model is loaded. + * Whether the synced model of the open project was produced by a relocating sync, or null when no + * model is loaded: [isRelocated] for any of its projects. A build script that sets its own build + * directory overrides the relocation for that project alone, the root's included, while the + * model still puts every other project's output in the tree; a build must relocate then too, or + * it writes where the model does not look. * * @param noBackupDir the app's `noBackupFilesDir`. * @param projectDir the open project's root directory. @@ -229,7 +236,11 @@ object BuildOutputLocation { noBackupDir: File, projectDir: File, workspace: Workspace? = ProjectManagerImpl.getInstance().workspace, - ): Boolean? = workspace?.let { isRelocated(it.rootProject.buildDir, treeFor(noBackupDir, projectDir)) } + ): Boolean? = + workspace?.let { model -> + val tree = treeFor(noBackupDir, projectDir) + (listOf(model.rootProject) + model.subProjects).any { isRelocated(it.buildDir, tree) } + } /** * Whether the synced model still builds into the project folder, so the next sync would move the @@ -238,4 +249,26 @@ object BuildOutputLocation { * @param modelRelocated [modelRelocated] for the open project. */ fun syncPending(modelRelocated: Boolean?): Boolean = modelRelocated == false + + /** Records in [tree] that a relocating sync ran. A failure is ignored: the offer then repeats. */ + fun recordRelocatingSync(tree: File) { + try { + File(tree, RELOCATING_SYNC_FILE).createNewFile() + } catch (e: IOException) { + // Unrecorded; the next sync tries again. + } + } + + /** + * Whether a sync could still move the build output of a project whose model builds into the + * project folder ([syncPending]), so offering one is worth it. Not when a relocating sync already + * ran ([recordRelocatingSync]) and its model still did not relocate, as when every build script + * sets its own build directory; and not when [tree] cannot be written, as a sync then keeps the + * output in the project folder. Offering then would repeat after every sync and every open. + * Creates [tree] when it is missing ([prepare]); runs on a background thread. + */ + fun syncCanRelocate( + tree: File, + projectDir: File, + ): Boolean = !File(tree, RELOCATING_SYNC_FILE).exists() && prepare(tree, projectDir) } diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index 34b79e0a27..ddd1f61c2b 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -787,6 +787,7 @@ class GradleBuildService : log.warn("Cannot write build output directory {}; syncing with output in the project folder", tree) return emptyList() } + BuildOutputLocation.recordRelocatingSync(tree) return BuildOutputLocation.gradleArgs(tree) } diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index c3c2db549e..a7a46ad85a 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -121,7 +121,7 @@ class BuildOutputLocationTest { } @Test - fun `the open project's model is read through its root project's build directory`() { + fun `the open project's model is read through its projects' build directories`() { val noBackup = tmp.newFolder("no_backup") val project = File(tmp.root, "projects/app") val tree = BuildOutputLocation.treeFor(noBackup, project) @@ -131,6 +131,43 @@ class BuildOutputLocationTest { assertThat(BuildOutputLocation.modelRelocated(noBackup, project, workspace = null)).isNull() } + @Test + fun `a model is relocated when only the root sets its own build directory`() { + val noBackup = tmp.newFolder("no_backup") + val project = File(tmp.root, "projects/app") + val tree = BuildOutputLocation.treeFor(noBackup, project) + + // The root script set layout.buildDirectory; :app's output is still in the tree, so builds + // must relocate too, or :app builds where the model does not look. + assertThat(BuildOutputLocation.modelRelocated(noBackup, project, workspace(File(project, "out"), File(tree, "main/app-1234")))) + .isTrue() + assertThat(BuildOutputLocation.modelRelocated(noBackup, project, workspace(File(project, "out"), File(project, "app/out")))) + .isFalse() + } + + @Test + fun `no sync is offered once a relocating sync ran without relocating`() { + val noBackup = tmp.newFolder("no_backup") + val project = tmp.newFolder("projects", "app") + val tree = BuildOutputLocation.treeFor(noBackup, project) + + assertThat(BuildOutputLocation.syncCanRelocate(tree, project)).isTrue() + assertThat(File(tree, BuildOutputLocation.PROJECT_PATH_FILE).isFile).isTrue() + + BuildOutputLocation.recordRelocatingSync(tree) + assertThat(BuildOutputLocation.syncCanRelocate(tree, project)).isFalse() + } + + @Test + fun `no sync is offered when the tree cannot be written`() { + val noBackup = tmp.newFolder("no_backup") + val project = tmp.newFolder("projects", "app") + // A file where gradle-build should be, so the tree cannot be created. + File(noBackup, BuildOutputLocation.DIR_NAME).writeText("") + + assertThat(BuildOutputLocation.syncCanRelocate(BuildOutputLocation.treeFor(noBackup, project), project)).isFalse() + } + @Test fun `a sync is pending exactly when the synced model builds into the project folder`() { assertThat(BuildOutputLocation.syncPending(modelRelocated = false)).isTrue() @@ -316,9 +353,14 @@ class BuildOutputLocationTest { assertThat(BuildOutputLocation.isRelocating(listOf("--parallel", "--watch-fs", "-Pother=1"))).isFalse() } - private fun workspace(rootBuildDir: File): Workspace { - val root = mockk() - every { root.buildDirPath } returns rootBuildDir.path - return mockk().also { every { it.rootProject } returns root } + private fun workspace( + rootBuildDir: File, + vararg subprojectBuildDirs: File, + ): Workspace { + fun project(buildDir: File) = mockk().also { every { it.buildDirPath } returns buildDir.path } + return mockk().also { + every { it.rootProject } returns project(rootBuildDir) + every { it.subProjects } returns subprojectBuildDirs.map(::project) + } } } diff --git a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md index 124616e07e..de9448fc55 100644 --- a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md +++ b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md @@ -50,7 +50,7 @@ Measured 2026-10-14 with the IDE's Gradle arguments, same template project on bo Sync (`RootModelBuilder.kt:207`) and builds (`ToolingApiServerImpl.kt:313`) both take their arguments from `prepareBuild`, so this one change covers both. `prepareBuild` already reads the project path (`GradleBuildService.kt:552`). 2. **Gradle side.** `AndroidIDEInitScriptPlugin.apply` (`gradle-plugin`) reads the property from `gradle.startParameter.projectProperties` and, in `beforeProject`, sets `project.layout.buildDirectory`. This runs before any build script, so AGP, Kotlin and Quick Build's plugin all see the new location. The prototype init script did exactly this (`gradle.allprojects { p -> p.layout.buildDirectory.set(...) }`), with 33 builds and no output under the project. 3. **Flag.** A `BuildPreferences` switch, default off, shown only with experiments enabled (`FeatureFlags.isExperimentsEnabled`, REVIEW.md section 12) until validation passes. Removed after review: every sync relocates. -4. **Sync applies it.** A sync reads the switch; the model it produces is the record. A build relocates exactly when the model's root build directory is in the tree, so its output is always where the model (APK listing file, classpath) looks, and a failed sync changes neither. When the switch and the model disagree, the editor offers a sync after the project initializes and on resume, once per change, and withdraws the offer when they agree again. +4. **Sync applies it.** A sync reads the switch; the model it produces is the record. A build relocates exactly when the model's root build directory is in the tree (after review: any project's build directory, as a root script can set its own), so its output is always where the model (APK listing file, classpath) looks, and a failed sync changes neither. When the switch and the model disagree, the editor offers a sync after the project initializes and on resume, once per change, and withdraws the offer when they agree again. ### What reads build output @@ -88,7 +88,7 @@ Each PR compiles and passes tests on its own; the flag stays off until PR 4. 3. **Lifecycle and UX.** Delete-with-project, the orphan sweep, Export APK, strings. About 6 files. 4. **Default on.** Flip the flag after validation. Update `ARCHITECTURE.md` (storage layout) and any user docs. Validation (2026-10-15/16, posted on ADFA-4930): warm edit builds 48% faster on a 288-source project (Pixel 6 Pro); on an itel A667L, clean builds about 50% faster and small edits unchanged. PR 4 also drops the experiments gate, so the switch and Export APK show for everyone, and gives the sync banner its own wording for a relocation change. - After review (Hal Eisen on #2155) the switch is gone: every sync relocates, and the banner only offers an existing project's first relocating sync. + After review (Hal Eisen on #2155) the switch is gone: every sync relocates, and the banner only offers an existing project's first relocating sync. A second review round (Hal Eisen) stopped the banner returning when a sync cannot relocate (a relocating sync already ran, or the tree cannot be written), and took old `build/` folders only from projects the model relocated, so a project with its own build directory keeps its `build/`. ## Validation before default-on From 8f9392defbd6041076d9538b4acb3365f3f20473 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 27 Oct 2026 17:37:43 -0700 Subject: [PATCH 35/43] ADFA-4930: record a relocating sync only once it succeeds Hal Eisen (and CodeRabbit) on #2155: the relocating-sync marker was written in prepareBuild, before the sync ran. A first sync that failed or was cancelled (offline, a missing dependency, Stop) left the marker while the model still built into the project folder, so the banner never offered the sync again, against syncForBuildOutput's KDoc. The sync's arguments now come from BuildOutputLocation.syncArgs, which prepares the tree and records nothing. The activity records the sync once it succeeded and its model is loaded. The new test, a sync that starts with relocating arguments and never finishes, fails with the early write: syncCanRelocate returned false. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../editor/ProjectHandlerActivity.kt | 15 ++++++++++-- .../services/builder/BuildOutputLocation.kt | 23 +++++++++++++++---- .../services/builder/GradleBuildService.kt | 9 +++----- .../builder/BuildOutputLocationTest.kt | 15 +++++++++++- 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 4eb60fb9c5..9b421fe948 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -1638,7 +1638,7 @@ abstract class ProjectHandlerActivity : return@whenCompleteAsync } - onProjectInitialized(result as InitializeResult.Success) + onProjectInitialized(result as InitializeResult.Success, synced = needsSync) } } @@ -1759,7 +1759,11 @@ abstract class ProjectHandlerActivity : } } - protected open fun onProjectInitialized(result: InitializeResult.Success) { + /** @param synced whether this initialization ran a Gradle sync, rather than reading the cached model. */ + protected open fun onProjectInitialized( + result: InitializeResult.Success, + synced: Boolean, + ) { Memprof.mark("sync_complete") editorActivityScope.launch(Dispatchers.IO) { val manager = ProjectManagerImpl.getInstance() @@ -1795,6 +1799,13 @@ abstract class ProjectHandlerActivity : } manager.setup(gradleBuildResult.getOrThrow()) + // ADFA-4930: only now that the synced model is loaded. A sync that fails or is cancelled + // leaves the model as it was, so the relocation offer must stand. + if (synced) { + manager.projectDirPath.takeIf { it.isNotBlank() }?.let { path -> + BuildOutputLocation.recordRelocatingSync(BuildOutputLocation.treeFor(noBackupFilesDir, File(path))) + } + } manager.notifyProjectUpdate() updateBuildVariants(manager.androidBuildVariants) diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index 934e5ccdb9..001ce5ee58 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -33,7 +33,7 @@ object BuildOutputLocation { /** Holds the project's canonical path, so a sweep can find trees whose project is gone. */ const val PROJECT_PATH_FILE = "project-path" - /** Written in a tree by every sync that relocates; see [syncCanRelocate]. */ + /** Written in a tree once a sync has succeeded and its model is loaded; see [syncCanRelocate]. */ const val RELOCATING_SYNC_FILE = "relocating-sync" private const val KOTLIN_PERSISTENT_DIR = "kotlin.project.persistent.dir" @@ -75,6 +75,17 @@ object BuildOutputLocation { NO_WATCH_FS, ) + /** + * The arguments a sync passes so it relocates, or none when [tree] cannot be written ([prepare]); + * the sync then keeps output in the project folder. Records nothing: the sync has not run yet, + * and one that fails or is cancelled leaves the model as it was, so the offer of another must + * stand. Its success is recorded after its model loads ([recordRelocatingSync]). + */ + fun syncArgs( + tree: File, + projectDir: File, + ): List = if (prepare(tree, projectDir)) gradleArgs(tree) else emptyList() + /** Whether a sync's or build's arguments relocate its output, from [gradleArgs]. */ fun isRelocating(args: List): Boolean = args.any { it.startsWith("-P$PROPERTY_BUILD_OUTPUT_DIR=") } @@ -250,7 +261,11 @@ object BuildOutputLocation { */ fun syncPending(modelRelocated: Boolean?): Boolean = modelRelocated == false - /** Records in [tree] that a relocating sync ran. A failure is ignored: the offer then repeats. */ + /** + * Records in [tree] that a sync succeeded, once its model is loaded. Every sync relocates when + * [tree] can be written ([syncArgs]), so after one succeeds another changes nothing. A failure to + * write is ignored: the offer then repeats. + */ fun recordRelocatingSync(tree: File) { try { File(tree, RELOCATING_SYNC_FILE).createNewFile() @@ -261,8 +276,8 @@ object BuildOutputLocation { /** * Whether a sync could still move the build output of a project whose model builds into the - * project folder ([syncPending]), so offering one is worth it. Not when a relocating sync already - * ran ([recordRelocatingSync]) and its model still did not relocate, as when every build script + * project folder ([syncPending]), so offering one is worth it. Not when a sync already succeeded + * ([recordRelocatingSync]) and its model still did not relocate, as when every build script * sets its own build directory; and not when [tree] cannot be written, as a sync then keeps the * output in the project folder. Offering then would repeat after every sync and every open. * Creates [tree] when it is missing ([prepare]); runs on a background thread. diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index ddd1f61c2b..97311a66e1 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -783,12 +783,9 @@ class GradleBuildService : return BuildOutputLocation.gradleArgs(tree) } - if (!BuildOutputLocation.prepare(tree, projectDir)) { - log.warn("Cannot write build output directory {}; syncing with output in the project folder", tree) - return emptyList() - } - BuildOutputLocation.recordRelocatingSync(tree) - return BuildOutputLocation.gradleArgs(tree) + val args = BuildOutputLocation.syncArgs(tree, projectDir) + if (args.isEmpty()) log.warn("Cannot write build output directory {}; syncing with output in the project folder", tree) + return args } override fun checkGradleWrapperAvailability(): CompletableFuture = diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index a7a46ad85a..de90336177 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -146,7 +146,7 @@ class BuildOutputLocationTest { } @Test - fun `no sync is offered once a relocating sync ran without relocating`() { + fun `no sync is offered once a sync succeeded without relocating`() { val noBackup = tmp.newFolder("no_backup") val project = tmp.newFolder("projects", "app") val tree = BuildOutputLocation.treeFor(noBackup, project) @@ -158,6 +158,19 @@ class BuildOutputLocationTest { assertThat(BuildOutputLocation.syncCanRelocate(tree, project)).isFalse() } + @Test + fun `a sync that fails leaves the offer available`() { + val noBackup = tmp.newFolder("no_backup") + val project = tmp.newFolder("projects", "app") + val tree = BuildOutputLocation.treeFor(noBackup, project) + + // The sync starts with relocating arguments, then fails or is cancelled before its model + // loads, so nothing records it. + assertThat(BuildOutputLocation.isRelocating(BuildOutputLocation.syncArgs(tree, project))).isTrue() + + assertThat(BuildOutputLocation.syncCanRelocate(tree, project)).isTrue() + } + @Test fun `no sync is offered when the tree cannot be written`() { val noBackup = tmp.newFolder("no_backup") From 714ef05d93411ffbc1e35593f831aa08454fbd6c Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 27 Oct 2026 17:37:43 -0700 Subject: [PATCH 36/43] ADFA-4930: keep buildSrc/build in old-output removal CodeRabbit on #2155: buildSrc/build was always a candidate, even when buildSrc sets its own build directory, so a user's folder there could be deleted. buildSrc is not in the model, so whether it kept the default cannot be told; its build/ is now left alone, like a project with its own build directory (5b25a22). A stale buildSrc/build holds no APK, so leaving it costs only space. The new test fails without the fix: find returned buildSrc/build. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../services/builder/OldBuildOutput.kt | 10 ++++++---- .../services/builder/OldBuildOutputTest.kt | 19 +++++++++++++++++-- ...6-10-14-adfa-4930-build-output-off-fuse.md | 2 +- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt b/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt index 411612a0ad..5b64f662ac 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/OldBuildOutput.kt @@ -17,13 +17,15 @@ object OldBuildOutput { /** * The stale folders of a relocated project: the `build/` of each project the synced model put in - * [tree], and the Gradle and Kotlin caches of the root and of `buildSrc`. For a project in [tree], + * [tree], the root's `.gradle/` and `.kotlin/`, and `buildSrc/.kotlin/`. For a project in [tree], * `build/` was Gradle's default before the move; a project whose build script sets its own build * directory stays where it was, so a `build/` folder in it was never Gradle's and is kept. Only * folders inside [rootDir] once symlinks are resolved, so a linked module folder cannot lead the * deletion out of the project. Left alone: a symlink; a folder that is a project's directory or - * holds one; a folder that holds or lies in a build directory the model still uses; and - * `buildSrc/.gradle/` and an included build's `.gradle/` and `.kotlin/`, which builds still use. + * holds one; a folder that holds or lies in a build directory the model still uses; + * `buildSrc/build/`, as `buildSrc` is not in the model, so whether it kept that default cannot be + * told; and `buildSrc/.gradle/` and an included build's `.gradle/` and `.kotlin/`, which builds + * still use. * * @param rootDir the root project's directory. * @param tree the project's tree, from [BuildOutputLocation.treeFor]. @@ -43,7 +45,7 @@ object OldBuildOutput { val relocated = projects.zip(inUse).filter { (_, buildDir) -> BuildOutputLocation.isRelocated(buildDir, tree) } val candidates = relocated.map { (projectDir, _) -> File(projectDir, "build") } + - listOf(".gradle", ".kotlin", "buildSrc/build", "buildSrc/.kotlin").map { File(root, it) } + listOf(".gradle", ".kotlin", "buildSrc/.kotlin").map { File(root, it) } return candidates.distinct().filter { dir -> canonicalOrNull(dir)?.startsWith(canonicalRoot) == true && Files.isDirectory(dir.toPath(), LinkOption.NOFOLLOW_LINKS) && diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt index 9e426ef9f4..3b9bc3a8e5 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/OldBuildOutputTest.kt @@ -17,7 +17,7 @@ class OldBuildOutputTest { ): List = paths.map { File(root, it).apply { mkdirs() } } @Test - fun `finds each project's build folder and the root's and buildSrc's caches`() { + fun `finds each relocated project's build folder and the Gradle and Kotlin caches`() { val root = tmp.newFolder("project") val tree = tmp.newFolder("tree") dirs(root, "build", "app/build", ".gradle", ".kotlin", "buildSrc/build", "buildSrc/.kotlin", "buildSrc/.gradle", ".cxx") @@ -29,7 +29,6 @@ class OldBuildOutputTest { "app/build", ".gradle", ".kotlin", - "buildSrc/build", "buildSrc/.kotlin", ) } @@ -156,4 +155,20 @@ class OldBuildOutputTest { OldBuildOutput.removeOnce(tree, root, projects, buildDirs) assertThat(notes.readText()).isEqualTo("mine") } + + @Test + fun `buildSrc's build folder is kept, as the model cannot show where buildSrc builds`() { + val root = tmp.newFolder("project") + val tree = tmp.newFolder("tree") + // buildSrc may set its own build directory, such as buildSrc/out; its build/ is then the user's. + dirs(root, "buildSrc/out", "buildSrc/build", "app/build") + val notes = File(root, "buildSrc/build/notes.txt").apply { writeText("mine") } + val projects = listOf(root, File(root, "app")) + val buildDirs = listOf(File(tree, "root"), File(tree, "app")) + + assertThat(OldBuildOutput.find(root, tree, projects, buildDirs).map { it.relativeTo(root).path }) + .containsExactly("app/build") + OldBuildOutput.removeOnce(tree, root, projects, buildDirs) + assertThat(notes.readText()).isEqualTo("mine") + } } diff --git a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md index de9448fc55..be351f8fe1 100644 --- a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md +++ b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md @@ -68,7 +68,7 @@ Measured 2026-10-14 with the IDE's Gradle arguments, same template project on bo - **Project deleted in the IDE**: both deletion paths (Recents and the Delete projects screen) go through `RecentProjectsViewModel`, which deletes the project's trees too. Trees are matched by their `project-path` marker, not by key: deletion uses the canonical path and builds use `projectDirPath`, so a symlinked spelling would key differently. - **Orphans** (project moved, renamed or deleted outside the IDE): at startup, off the main thread, remove trees whose project no longer exists. A project counts as gone when its parent folder exists, or, with the parent deleted too, when the nearest folder that exists is a readable folder on a volume under `/storage`. A volume that is not mounted leaves only `/storage` itself, so it doesn't count. Trees without a marker are left alone. Unlike `QuickBuildScratch.sweep`, which clears every tree, this keeps the output of live projects. -- **Existing `build/` folders in projects**: removed once, after the project's first relocated initialization (`OldBuildOutput`): each Gradle project's `build/`, and `.gradle/` and `.kotlin/` at the root and in `buildSrc` (not `buildSrc/.gradle/`, which builds still use). A stale APK where a file manager finds it misleads, and the copy doubles the space a project takes. Symlinks are never followed, a `build` folder that is or holds a module stays, and so does anything outside the project folder. The user is told once. +- **Existing `build/` folders in projects**: removed once, after the project's first relocated initialization (`OldBuildOutput`): the `build/` of each Gradle project the model relocated, the root's `.gradle/` and `.kotlin/`, and `buildSrc/.kotlin/` (not `buildSrc/build/`, whose build directory the model doesn't show, nor `buildSrc/.gradle/`, which builds still use). A stale APK where a file manager finds it misleads, and the copy doubles the space a project takes. Symlinks are never followed, a `build` folder that is or holds a module stays, and so does anything outside the project folder. The user is told once. - **Storage**: output now counts as app data in Settings, and "Clear storage" removes it (the next build is a full one). Uninstalling removes it as well; sources are unaffected. ### What users will notice From 5a2099487ee03b2c59955f8900b655aa714e9ced Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 27 Oct 2026 17:58:21 -0700 Subject: [PATCH 37/43] ADFA-4930: gate the relocation switch on an Experiment entry Hal Eisen on #2149: stage made FeatureFlags.isExperimentsEnabled internal to :common (44fda21, ADFA-6388), so BuildOutputLocation's isAvailable() no longer compiles once this layer meets stage, and EXPERIMENTS.md says to gate on Experiments.isOn(Experiment.X), never the raw flag. With stage merged in, the old line fails :app's compile ("it is internal in 'common'"). The switch is now gated on a BUILD_OUTPUT_RELOCATION entry. Its Jira key is ADFA-4930 itself rather than a separate graduate-or-remove ticket: #2155, in the same ticket, turns relocation on for everyone and deletes the entry with isAvailable(). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../androidide/services/builder/BuildOutputLocation.kt | 7 ++++--- .../main/java/com/itsaky/androidide/utils/Experiment.kt | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index 6070fb3d1e..26e893066d 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -5,7 +5,8 @@ import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.api.Workspace import com.itsaky.androidide.projects.models.buildDir import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR -import com.itsaky.androidide.utils.FeatureFlags +import com.itsaky.androidide.utils.Experiment +import com.itsaky.androidide.utils.Experiments import com.itsaky.androidide.utils.ProjectStorageKey import java.io.File import java.io.IOException @@ -33,8 +34,8 @@ object BuildOutputLocation { private const val NO_WATCH_FS = "--no-watch-fs" private const val PROJECT_CACHE_DIR = "--project-cache-dir" - /** Whether the switch exists at all: early access only (REVIEW.md section 12). */ - fun isAvailable(): Boolean = FeatureFlags.isExperimentsEnabled + /** Whether the switch exists at all: early access only (EXPERIMENTS.md). */ + fun isAvailable(): Boolean = Experiments.isOn(Experiment.BUILD_OUTPUT_RELOCATION) /** Whether the user currently asks for relocation; applied at the next sync. */ fun isRequested(): Boolean = isAvailable() && BuildPreferences.relocateBuildOutput diff --git a/common/src/main/java/com/itsaky/androidide/utils/Experiment.kt b/common/src/main/java/com/itsaky/androidide/utils/Experiment.kt index 23f5c22de7..e02ea1e6b4 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/Experiment.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/Experiment.kt @@ -18,6 +18,7 @@ enum class Experiment( ERROR_BAR_TOUCH_DISMISS("Error bars close on tap or swipe", "ADFA-6516"), PROFILER("CPU and RAM profiler", "ADFA-6517"), GRADLE_AUTO_TUNE("Gradle build auto-tune", "ADFA-6518"), + BUILD_OUTPUT_RELOCATION("Relocate build output", "ADFA-4930"), } object Experiments { From 937257396e0dd9d9841398c2d50cea1d02d8430c Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 27 Oct 2026 18:02:09 -0700 Subject: [PATCH 38/43] ADFA-4930: delete output trees without following symlinks Hal Eisen on #2151: deleting a project's tree, and the startup sweep, used Kotlin's File.deleteRecursively, which follows a symlink to a directory and deletes what it points to. The trees live on ext4, where a build script, a CMake step or the terminal can leave such a link, so deleting a project could delete files outside its tree. The fix was in #2155 only (5d95f18), and this layer has to be safe to merge on its own. deleteTree now uses deleteWithoutFollowingLinks, the same file #2155 adds, and the sweep's listing of trees skips links (NOFOLLOW_LINKS), so a link in gradle-build/ is never treated as a tree. Each of the two new tests fails without its half of the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../services/builder/BuildOutputLocation.kt | 12 +++- .../builder/DeleteWithoutFollowingLinks.kt | 59 +++++++++++++++++++ .../builder/BuildOutputLocationTest.kt | 30 ++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/services/builder/DeleteWithoutFollowingLinks.kt diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index e48c4922c2..8512399691 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -12,6 +12,8 @@ import com.itsaky.androidide.utils.canonicalProjectLocation import com.itsaky.androidide.utils.isDeletedProjectDirectory import java.io.File import java.io.IOException +import java.nio.file.Files +import java.nio.file.LinkOption /** * Where a project's Gradle build output goes when it is moved off the FUSE-backed project folder @@ -157,7 +159,10 @@ object BuildOutputLocation { noBackupDir: File, isOrphan: (File) -> Boolean, ): Int { - val trees = File(noBackupDir, DIR_NAME).listFiles { file -> file.isDirectory } ?: return 0 + // A link among the trees is not a tree; following it would delete what it points to. + val trees = + File(noBackupDir, DIR_NAME).listFiles { file -> Files.isDirectory(file.toPath(), LinkOption.NOFOLLOW_LINKS) } + ?: return 0 return trees.count { tree -> val project = projectOf(tree) project != null && isOrphan(project) && deleteTree(tree) @@ -166,12 +171,13 @@ object BuildOutputLocation { /** * Deletes [tree] with its marker last: a deletion that stops part-way keeps the marker, so the - * next sweep can still tell whose tree it is and finish the job. + * next sweep can still tell whose tree it is and finish the job. A build can leave symlinks in its + * output, so the deletion never follows one out of the tree. */ private fun deleteTree(tree: File): Boolean { val marker = File(tree, PROJECT_PATH_FILE) val contents = tree.listFiles() ?: return false - val deleted = contents.filter { it != marker }.map { it.deleteRecursively() }.all { it } + val deleted = contents.filter { it != marker }.map { deleteWithoutFollowingLinks(it) }.all { it } return deleted && (!marker.exists() || marker.delete()) && tree.delete() } diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/DeleteWithoutFollowingLinks.kt b/app/src/main/java/com/itsaky/androidide/services/builder/DeleteWithoutFollowingLinks.kt new file mode 100644 index 0000000000..9490681da0 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/services/builder/DeleteWithoutFollowingLinks.kt @@ -0,0 +1,59 @@ +package com.itsaky.androidide.services.builder + +import java.io.File +import java.io.IOException +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes + +/** + * Deletes [dir] and everything in it without following symlinks, so a link inside never leads the + * deletion out of [dir]. A file that cannot be deleted is skipped, not fatal. + * + * @return whether [dir] is gone. + */ +internal fun deleteWithoutFollowingLinks(dir: File): Boolean { + val start = dir.toPath() + if (Files.notExists(start, LinkOption.NOFOLLOW_LINKS)) return true + try { + Files.walkFileTree( + start, + object : SimpleFileVisitor() { + override fun visitFile( + file: Path, + attrs: BasicFileAttributes, + ): FileVisitResult { + deleteQuietly(file) + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed( + file: Path, + exc: IOException, + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun postVisitDirectory( + dir: Path, + exc: IOException?, + ): FileVisitResult { + deleteQuietly(dir) + return FileVisitResult.CONTINUE + } + }, + ) + } catch (e: IOException) { + return false + } + return Files.notExists(start, LinkOption.NOFOLLOW_LINKS) +} + +private fun deleteQuietly(path: Path) { + try { + Files.deleteIfExists(path) + } catch (e: IOException) { + // Left in place; the folder holding it stays too. + } +} diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index a76bf9b556..2ec543dfa6 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -155,6 +155,36 @@ class BuildOutputLocationTest { assertThat(otherTree.isDirectory).isTrue() } + @Test + fun `deleting a project's tree never follows a symlink out of it`() { + val noBackup = tmp.newFolder("no_backup") + val project = tmp.newFolder("projects", "app") + val tree = treeOf(noBackup, project) + val elsewhere = tmp.newFolder("elsewhere") + val precious = File(elsewhere, "precious.txt").apply { writeText("keep me") } + // A build can leave a link in its output, such as one a custom task makes. + Files.createSymbolicLink(File(tree, "main/app-1234/outputs/link").toPath(), elsewhere.toPath()) + + assertThat(BuildOutputLocation.removeTreesOf(noBackup, project)).isEqualTo(1) + + assertThat(tree.exists()).isFalse() + assertThat(precious.readText()).isEqualTo("keep me") + } + + @Test + fun `the sweep does not follow a link among the trees`() { + val noBackup = tmp.newFolder("no_backup") + // A folder outside gradle-build whose marker names a deleted project, reached through a link. + val elsewhere = tmp.newFolder("elsewhere") + File(elsewhere, BuildOutputLocation.PROJECT_PATH_FILE).writeText(File(tmp.newFolder("projects"), "gone").path) + val precious = File(elsewhere, "precious.txt").apply { writeText("keep me") } + Files.createSymbolicLink(File(File(noBackup, BuildOutputLocation.DIR_NAME).apply { mkdirs() }, "link").toPath(), elsewhere.toPath()) + + assertThat(BuildOutputLocation.sweep(noBackup, tmp.root)).isEqualTo(0) + + assertThat(precious.readText()).isEqualTo("keep me") + } + @Test fun `a tree recorded under another spelling of the project path is still found`() { val noBackup = tmp.newFolder("no_backup") From 88a8f4924ea955e386dacc3cc780c06190cefd09 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 27 Oct 2026 18:05:03 -0700 Subject: [PATCH 39/43] ADFA-4930: graduate the build-output relocation experiment #2149 now gates the relocation switch on Experiment BUILD_OUTPUT_RELOCATION (merged in from #2151). This PR turns relocation on for everyone and has already removed the switch and isAvailable(), its only gate, so the entry goes too, as EXPERIMENTS.md asks of a graduating experiment. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- common/src/main/java/com/itsaky/androidide/utils/Experiment.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/common/src/main/java/com/itsaky/androidide/utils/Experiment.kt b/common/src/main/java/com/itsaky/androidide/utils/Experiment.kt index e02ea1e6b4..23f5c22de7 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/Experiment.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/Experiment.kt @@ -18,7 +18,6 @@ enum class Experiment( ERROR_BAR_TOUCH_DISMISS("Error bars close on tap or swipe", "ADFA-6516"), PROFILER("CPU and RAM profiler", "ADFA-6517"), GRADLE_AUTO_TUNE("Gradle build auto-tune", "ADFA-6518"), - BUILD_OUTPUT_RELOCATION("Relocate build output", "ADFA-4930"), } object Experiments { From a455db0290b1133b79a84ed81a835ad74dc174f2 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 28 Oct 2026 18:08:34 -0700 Subject: [PATCH 40/43] ADFA-4930: relocate when any project's output is in the tree CodeRabbit on #2149: a build script that sets its own build directory overrides the relocation for that project alone. When the root does, the sync model still puts the modules' output in the tree, but modelRelocated looked at the root alone and returned false, so the next non-sync build left out the relocation flags and built a module into its project folder, where the model's APK listing does not look; Quick Run could then pick a stale APK from the tree. modelRelocated is now true when any project's build directory is in the tree, so builds follow the model. The new test fails without the fix. The same change was made in #2155 (55ebfde) while answering Hal Eisen; this layer has to be right on its own. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../services/builder/BuildOutputLocation.kt | 16 +++++++--- .../builder/BuildOutputLocationTest.kt | 29 +++++++++++++++---- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index 26e893066d..6b0e7a3291 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -99,9 +99,9 @@ object BuildOutputLocation { /** * Whether a synced model's build directory lies in [tree], meaning the sync that produced the - * model relocated build output. + * model relocated that project's build output. * - * @param modelBuildDir the root project's build directory in the synced model. + * @param modelBuildDir a project's build directory in the synced model. * @param tree the project's tree, from [treeFor]. */ fun isRelocated( @@ -110,7 +110,11 @@ object BuildOutputLocation { ): Boolean = modelBuildDir.absoluteFile.normalize().startsWith(tree.absoluteFile.normalize()) /** - * [isRelocated] for the synced model of the open project, or null when no model is loaded. + * Whether the synced model of the open project was produced by a relocating sync, or null when no + * model is loaded: [isRelocated] for any of its projects. A build script that sets its own build + * directory overrides the relocation for that project alone, the root's included, while the + * model still puts every other project's output in the tree; a build must relocate then too, or + * it writes where the model does not look. * * @param noBackupDir the app's `noBackupFilesDir`. * @param projectDir the open project's root directory. @@ -120,7 +124,11 @@ object BuildOutputLocation { noBackupDir: File, projectDir: File, workspace: Workspace? = ProjectManagerImpl.getInstance().workspace, - ): Boolean? = workspace?.let { isRelocated(it.rootProject.buildDir, treeFor(noBackupDir, projectDir)) } + ): Boolean? = + workspace?.let { model -> + val tree = treeFor(noBackupDir, projectDir) + (listOf(model.rootProject) + model.subProjects).any { isRelocated(it.buildDir, tree) } + } /** * Whether the switch (or the experiments flag) disagrees with the synced model, so the next diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index 372942c0d5..b63acc1597 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -117,7 +117,7 @@ class BuildOutputLocationTest { } @Test - fun `the open project's model is read through its root project's build directory`() { + fun `the open project's model is read through its projects' build directories`() { val noBackup = tmp.newFolder("no_backup") val project = File(tmp.root, "projects/app") val tree = BuildOutputLocation.treeFor(noBackup, project) @@ -127,6 +127,20 @@ class BuildOutputLocationTest { assertThat(BuildOutputLocation.modelRelocated(noBackup, project, workspace = null)).isNull() } + @Test + fun `a model is relocated when only the root sets its own build directory`() { + val noBackup = tmp.newFolder("no_backup") + val project = File(tmp.root, "projects/app") + val tree = BuildOutputLocation.treeFor(noBackup, project) + + // The root script set layout.buildDirectory; :app's output is still in the tree, so builds + // must relocate too, or :app builds where the model does not look. + assertThat(BuildOutputLocation.modelRelocated(noBackup, project, workspace(File(project, "out"), File(tree, "main/app-1234")))) + .isTrue() + assertThat(BuildOutputLocation.modelRelocated(noBackup, project, workspace(File(project, "out"), File(project, "app/out")))) + .isFalse() + } + @Test fun `a sync is pending exactly when the request differs from the synced model`() { assertThat(BuildOutputLocation.syncPending(modelRelocated = false, requested = false)).isFalse() @@ -140,9 +154,14 @@ class BuildOutputLocationTest { assertThat(BuildOutputLocation.syncPending(modelRelocated = null, requested = true)).isFalse() } - private fun workspace(rootBuildDir: File): Workspace { - val root = mockk() - every { root.buildDirPath } returns rootBuildDir.path - return mockk().also { every { it.rootProject } returns root } + private fun workspace( + rootBuildDir: File, + vararg subprojectBuildDirs: File, + ): Workspace { + fun project(buildDir: File) = mockk().also { every { it.buildDirPath } returns buildDir.path } + return mockk().also { + every { it.rootProject } returns project(rootBuildDir) + every { it.subProjects } returns subprojectBuildDirs.map(::project) + } } } From 5749dfb6dc727d0593f5054c10db448f1f799fa9 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 28 Oct 2026 18:13:41 -0700 Subject: [PATCH 41/43] ADFA-4930: delete the temporary plugin copy; retry a blocked offer Two findings by CodeRabbit on #2149/#2155, checked against the stack tip: - Installing a built plugin copies it to filesDir/temp first, because an install deletes its input. A failed install (the plugin system unavailable, a validation error after the copy), a copy that stopped part-way, or a cancelled caller left that copy behind. The copy now goes through installFromCopy, which always deletes it. The new tests fail without the deletion: "expected to be false". - The relocation banner is refused while a build holds the Gradle slot, and nothing offered it again when the build ended. A refused offer now waits for the slot (offerAgainIfBlockedByBuild), only when a build is the reason: an offer already shown, or refused while the project initializes, schedules nothing, so it cannot loop. The helper is unit-tested; the activity wiring is not exercised by a test, and the case needs the first offer to land during a build, which is rare. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../editor/ProjectHandlerActivity.kt | 56 ++++++++-------- .../services/builder/BuildOutputLocation.kt | 15 +++++ .../androidide/utils/InstallFromCopy.kt | 30 +++++++++ .../builder/BuildOutputLocationTest.kt | 27 ++++++++ .../androidide/utils/InstallFromCopyTest.kt | 64 +++++++++++++++++++ 5 files changed, 166 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/utils/InstallFromCopy.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/InstallFromCopyTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 94a537f9e5..26d4f85459 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -136,6 +136,7 @@ import com.itsaky.androidide.utils.flashInfo import com.itsaky.androidide.utils.flashInfoLong import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.flashbarBuilder +import com.itsaky.androidide.utils.installFromCopy import com.itsaky.androidide.utils.onLongPress import com.itsaky.androidide.utils.resolveAttr import com.itsaky.androidide.utils.showOnUiThread @@ -167,7 +168,6 @@ import org.koin.core.context.GlobalContext import org.slf4j.LoggerFactory import java.io.File import java.io.FileNotFoundException -import java.io.IOException import java.net.SocketException import java.nio.file.NoSuchFileException import java.util.concurrent.CompletableFuture @@ -1143,14 +1143,8 @@ abstract class ProjectHandlerActivity : lifecycleScope.launch { setStatus(getString(string.status_installing_plugin)) // Installing deletes the file it is given. A copy keeps the built plugin for Export. - val result = - try { - val copy = InstallTempFiles.newTempFile(filesDir, "built_plugin", cgpFile.extension) - withContext(Dispatchers.IO) { cgpFile.copyTo(copy) } - pluginRepository.installPluginFromFile(copy) - } catch (e: IOException) { - Result.failure(e) - } + val copy = InstallTempFiles.newTempFile(filesDir, "built_plugin", cgpFile.extension) + val result = installFromCopy(cgpFile, copy) { pluginRepository.installPluginFromFile(it) } result .onSuccess { showRestartPrompt(this@ProjectHandlerActivity) @@ -1264,18 +1258,22 @@ abstract class ProjectHandlerActivity : } buildOutputSyncBanner = null buildOutputSyncOffered = false - } else if ( - !buildOutputSyncOffered && - notifySyncNeeded( - onConfirm = ::syncForBuildOutput, - // Short, with a short dismiss label: this banner greets every existing project, and - // has to keep its Sync button at 2x font scale. - message = string.msg_sync_build_output_relocate, - dismissText = string.btn_not_now, - ) - ) { - buildOutputSyncOffered = true - buildOutputSyncBanner = syncNotificationFlashbar + } else if (!buildOutputSyncOffered) { + val shown = + notifySyncNeeded( + onConfirm = ::syncForBuildOutput, + // Short, with a short dismiss label: this banner greets every existing project, and + // has to keep its Sync button at 2x font scale. + message = string.msg_sync_build_output_relocate, + dismissText = string.btn_not_now, + ) + if (shown) { + buildOutputSyncOffered = true + buildOutputSyncBanner = syncNotificationFlashbar + } else { + // A build holds the Gradle slot, so no banner can show now; offer again when it ends. + offerAgainAfterBuild(shown = false) + } } } } @@ -1321,16 +1319,22 @@ abstract class ProjectHandlerActivity : val service = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) as? GradleBuildService if (service?.isBuildInProgress == true) { flashInfoLong(getString(string.msg_sync_build_output_deferred)) - buildOutputSyncDeferral?.cancel() - buildOutputSyncDeferral = - BuildOutputLocation.offerWhenSlotFree(activityScope, service.buildSlotBusy) { - reconcileBuildOutputSyncOffer(afterInit = false) - } + offerAgainAfterBuild(shown = false) return } initializeProject(forceSync = true) } + /** Offers the relocation sync again once the build holding the Gradle slot ends; see [BuildOutputLocation]. */ + private fun offerAgainAfterBuild(shown: Boolean) { + val service = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) as? GradleBuildService ?: return + buildOutputSyncDeferral?.cancel() + buildOutputSyncDeferral = + BuildOutputLocation.offerAgainIfBlockedByBuild(shown, activityScope, service.buildSlotBusy) { + reconcileBuildOutputSyncOffer(afterInit = false) + } + } + /** Prebuilding writes to the current model's build directory; every later state uses the proxy's paths. */ private fun isQuickBuildSessionLive(): Boolean = when (quickBuildSessionManager()?.state?.value) { diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index 6c0f7e3cb5..363391077b 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -225,6 +225,21 @@ object BuildOutputLocation { offer() } + /** + * [offerWhenSlotFree], but only when the offer could not be shown because a build holds the slot. + * An offer that was shown needs no retry, and one refused for another reason (the project is + * still initializing) would be refused again at once, so neither schedules one. + * + * @param shown whether the offer was shown. + * @return the wait, or null when none was needed. + */ + fun offerAgainIfBlockedByBuild( + shown: Boolean, + scope: CoroutineScope, + slotBusy: StateFlow, + offer: () -> Unit, + ): Job? = if (shown || !slotBusy.value) null else offerWhenSlotFree(scope, slotBusy, offer) + /** * Whether a synced model's build directory lies in [tree], meaning the sync that produced the * model relocated that project's build output. diff --git a/app/src/main/java/com/itsaky/androidide/utils/InstallFromCopy.kt b/app/src/main/java/com/itsaky/androidide/utils/InstallFromCopy.kt new file mode 100644 index 0000000000..4a835ec5ad --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/InstallFromCopy.kt @@ -0,0 +1,30 @@ +package com.itsaky.androidide.utils + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException + +/** + * Installs from a copy of [source] at [copy], because an install deletes the file it is given and a + * built plugin should stay for Export (ADFA-4930). The copy is always deleted afterwards: a failed + * install, a copy that stops part-way, or a cancelled caller must not leave one in `filesDir/temp`. + * + * @param copy where to copy [source]; it must not exist yet. + * @param install installs the copy. + * @return the install's result, or the failure of the copy. + */ +internal suspend fun installFromCopy( + source: File, + copy: File, + install: suspend (File) -> Result, +): Result = + try { + withContext(Dispatchers.IO) { source.copyTo(copy) } + install(copy) + } catch (e: IOException) { + Result.failure(e) + } finally { + withContext(NonCancellable + Dispatchers.IO) { copy.delete() } + } diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index 3a0192c6dd..0672645866 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -365,6 +365,33 @@ class BuildOutputLocationTest { assertThat(offers).isEqualTo(1) } + @Test + fun `an offer a build blocked is made again when the build ends`() = + runTest { + val slotBusy = MutableStateFlow(true) + var offers = 0 + + val wait = BuildOutputLocation.offerAgainIfBlockedByBuild(shown = false, backgroundScope, slotBusy) { offers++ } + runCurrent() + assertThat(wait).isNotNull() + assertThat(offers).isEqualTo(0) + + slotBusy.value = false + runCurrent() + assertThat(offers).isEqualTo(1) + } + + @Test + fun `no retry is scheduled for an offer that was shown or refused for another reason`() = + runTest { + val busy = MutableStateFlow(true) + val free = MutableStateFlow(false) + + assertThat(BuildOutputLocation.offerAgainIfBlockedByBuild(shown = true, backgroundScope, busy) {}).isNull() + // Not shown, but no build holds the slot, as while the project initializes: retrying would loop. + assertThat(BuildOutputLocation.offerAgainIfBlockedByBuild(shown = false, backgroundScope, free) {}).isNull() + } + @Test fun `a deferred sync is offered at once when the slot is already free`() = runTest { diff --git a/app/src/test/java/com/itsaky/androidide/utils/InstallFromCopyTest.kt b/app/src/test/java/com/itsaky/androidide/utils/InstallFromCopyTest.kt new file mode 100644 index 0000000000..f3328be9c2 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/InstallFromCopyTest.kt @@ -0,0 +1,64 @@ +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class InstallFromCopyTest { + @get:Rule + val tmp = TemporaryFolder() + + private val plugin: File get() = File(tmp.root, "built.cgp").apply { writeText("cgp") } + + @Test + fun `the install gets a copy and the built plugin stays`() = + runTest { + val source = plugin + val copy = File(tmp.root, "temp/copy.cgp").apply { parentFile.mkdirs() } + var seen = "" + + val result = + installFromCopy(source, copy) { + seen = it.readText() + Result.success(Unit) + } + + assertThat(result.isSuccess).isTrue() + assertThat(seen).isEqualTo("cgp") + assertThat(source.isFile).isTrue() + assertThat(copy.exists()).isFalse() + } + + @Test + fun `a failed install leaves no copy behind`() = + runTest { + val copy = File(tmp.root, "temp/copy.cgp").apply { parentFile.mkdirs() } + + // The repository fails before it deletes the file it was given, as when the plugin + // system is unavailable. + val result = installFromCopy(plugin, copy) { Result.failure(IllegalStateException("Plugin system not available")) } + + assertThat(result.isFailure).isTrue() + assertThat(copy.exists()).isFalse() + } + + @Test + fun `a copy that cannot be made is a failure and leaves no copy behind`() = + runTest { + val copy = File(tmp.root, "temp/copy.cgp").apply { parentFile.mkdirs() } + var installed = false + + val result = + installFromCopy(File(tmp.root, "missing.cgp"), copy) { + installed = true + Result.success(Unit) + } + + assertThat(result.isFailure).isTrue() + assertThat(installed).isFalse() + assertThat(copy.exists()).isFalse() + } +} From 377f88abc05e13d7cc67ff1472c57798bbc17dde Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 28 Oct 2026 18:30:53 -0700 Subject: [PATCH 42/43] ADFA-4930: put the alias spelling in the marker the test names CodeRabbit on #2151: prepare writes the canonical path into the marker, so a tree recorded under another spelling of the project path never held that spelling in the test, which therefore could not catch removeTreesOf comparing the raw marker text. The test now writes the alias spelling into the marker, as an older build wrote it. With the comparison made raw, it fails: expected 1, was 0. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --- .../androidide/services/builder/BuildOutputLocationTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index a99d73b004..cb14c2d744 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -205,7 +205,9 @@ class BuildOutputLocationTest { val app = tmp.newFolder("storage", "app") val link = File(tmp.root, "sdcard").toPath() Files.createSymbolicLink(link, app.parentFile.toPath()) - val tree = treeOf(noBackup, File(link.toFile(), "app")) + val tree = treeOf(noBackup, app) + // A marker written before the canonical path was recorded holds the alias spelling. + File(tree, BuildOutputLocation.PROJECT_PATH_FILE).writeText(File(link.toFile(), "app").path) assertThat(BuildOutputLocation.removeTreesOf(noBackup, app)).isEqualTo(1) assertThat(tree.exists()).isFalse() From adeeb2031502be00b7d39961feaf1331671f7005 Mon Sep 17 00:00:00 2001 From: davidschachterADFA Date: Wed, 28 Oct 2026 18:38:08 -0700 Subject: [PATCH 43/43] ADFA-4930: find Quick Build and plugin output via the project model (#2150) * ADFA-4930: find Quick Build and plugin output via the project model Quick Build's setup.json and a plugin project's .cgp were looked up under `/build`. With relocated build output that path holds nothing, or a stale report from before the relocation, which Quick Build would then read. Both now resolve against the build directory the synced model gives: the module's for setup.json, the root project's for the .cgp. The model and the output agree because builds follow the last sync (PR 1). `QuickBuildTaskPaths.setupJson` is now relative to the build directory, and the debugging doc lists the relocated location. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * ADFA-4930: pin the .cgp lookup and pass setupJson its build directory From the #2150 review: - The plugin .cgp lookup moves into `findPluginCgpFile(workspace, variantName)`, so a test can pin it to the model's root build directory. `PluginCgpLookupTest` fails against the old `/build/plugin` lookup. - `QuickBuildTaskPaths.setupJson` takes the build directory and returns a File, so a caller cannot resolve it against the module folder by mistake. - The fake module's default build directory is absolute, so it never resolves against the test JVM's working directory. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * ADFA-4930: delete relocated output with its project, sweep orphans, Export APK (#2151) * ADFA-4930: delete relocated build output with its project, sweep orphans Relocated build output outlived its project: deleting a project in the IDE left its tree in app storage, and so did deleting, moving or renaming it outside the IDE. - Deleting a project (Recents' delete and the Delete projects screen, both through RecentProjectsViewModel) deletes its trees. - At startup, off the main thread, a sweep deletes the trees of projects that no longer exist. A project on a volume that is not mounted is not gone, and a tree without a marker is left alone. Trees are matched by their project-path marker rather than by key, because deletion uses the canonical path and builds use projectDirPath; a symlinked spelling of the same project would key differently. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * ADFA-4930: Export APK from the editor toolbar With relocated build output the built APK is in app-private storage, where a file manager cannot reach it. A toolbar action, after Launch app and shown only while relocation is available (experiments), resolves the selected variant's APK from the synced model and offers: - Share: the share sheet, through the existing FileProvider. - Copy to Download: `-.apk`, numbered when taken, so an earlier export is never overwritten. Long-press help uses TooltipTag.EDITOR_TOOLBAR_EXPORT_APK ("project.export.apk"); its documentation.db text is a separate SQL script for OfflineDocumentationTools. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * ADFA-4930: plan doc matches PR 3's lifecycle and Export APK Docs only: where deletion hooks in, how trees are matched, what the sweep keeps, and that Export APK offers both the share sheet and a copy in Download. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * ADFA-4930: harden Export APK and tree deletion From the #2151 review: - A malformed or truncated APK listing no longer crashes Export; it reads as nothing to export. - Export refuses Quick Build's proxy APK, which Quick Build's build writes to the variant's output path: the APK is the proxy when the variant's setup.json names it and is not older than it. - A copy goes to a temporary name and is renamed into place, so one that fails part-way leaves no broken APK in Download. - Export is disabled during a user-visible build and refused, with a reason, while any build (Quick Build's prebuild included) runs. - The export name includes the module when the project has more than one app module. - A tree's marker is deleted last, so a deletion that stops part-way leaves a tree the next sweep can still identify. - The unsafe file-name rule is shared with ExternalFileInstallViewModel (no behaviour change there). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * ADFA-4930: sweep trees of projects deleted with their folder The startup sweep counted a project as gone only while its parent folder existed, so deleting a folder of projects in a file manager left every one of their trees in app storage until Clear storage. With the parent missing too, the nearest folder that exists now decides: a readable folder on a volume under /storage means the folders were deleted, while /storage itself means the project's volume is not mounted, and its tree is kept. Recents keeps its own rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * ADFA-4930: record the canonical project path in the marker A project opened through a symlinked root recorded the link in its tree's marker. Recents deletes the real folder, which leaves the link dangling, so the marker no longer canonicalized to the deleted project and delete-with-project missed the tree until the next startup sweep. The marker now holds the canonical path, the same spelling deletion compares against. Found by CodeRabbit on #2151. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * ADFA-4930: delete output trees without following symlinks Hal Eisen on #2151: deleting a project's tree, and the startup sweep, used Kotlin's File.deleteRecursively, which follows a symlink to a directory and deletes what it points to. The trees live on ext4, where a build script, a CMake step or the terminal can leave such a link, so deleting a project could delete files outside its tree. The fix was in #2155 only (5d95f18), and this layer has to be safe to merge on its own. deleteTree now uses deleteWithoutFollowingLinks, the same file #2155 adds, and the sweep's listing of trees skips links (NOFOLLOW_LINKS), so a link in gradle-build/ is never treated as a tree. Each of the two new tests fails without its half of the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --------- Co-authored-by: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../androidide/actions/etc/ExportApkAction.kt | 152 ++++++++++++++++++ .../CredentialProtectedApplicationLoader.kt | 8 + .../quickbuild/GradleQuickBuildProvisioner.kt | 36 ++--- .../quickbuild/QuickBuildTaskPaths.kt | 18 ++- .../services/builder/BuildOutputLocation.kt | 102 +++++++++++- .../builder/DeleteWithoutFollowingLinks.kt | 59 +++++++ .../com/itsaky/androidide/utils/ApkExport.kt | 107 ++++++++++++ .../androidide/utils/EditorActivityActions.kt | 6 + .../com/itsaky/androidide/utils/FileNames.kt | 14 ++ .../androidide/viewmodel/BuildViewModel.kt | 44 ++--- .../viewmodel/RecentProjectsViewModel.kt | 9 ++ .../ExternalFileInstallViewModel.kt | 4 +- ...GradleQuickBuildProvisionerBuildDirTest.kt | 61 +++++++ .../GradleQuickBuildProvisionerFakes.kt | 15 +- .../quickbuild/QuickBuildTaskPathsTest.kt | 17 +- .../builder/BuildOutputLocationTest.kt | 149 ++++++++++++++++- .../itsaky/androidide/utils/ApkExportTest.kt | 142 ++++++++++++++++ .../viewmodel/PluginCgpLookupTest.kt | 60 +++++++ ...6-10-14-adfa-4930-build-output-off-fuse.md | 6 +- .../androidide/idetooltips/TooltipTag.kt | 1 + quickbuild/docs/debugging.md | 3 +- resources/src/main/res/values/strings.xml | 8 + 22 files changed, 953 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt create mode 100644 app/src/main/java/com/itsaky/androidide/services/builder/DeleteWithoutFollowingLinks.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/FileNames.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerBuildDirTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/viewmodel/PluginCgpLookupTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt b/app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt new file mode 100644 index 0000000000..2924bfa6ed --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt @@ -0,0 +1,152 @@ +package com.itsaky.androidide.actions.etc + +import android.content.Context +import android.os.Environment +import android.view.MenuItem +import androidx.core.content.ContextCompat +import com.itsaky.androidide.R +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.EditorActivityAction +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.actions.openApplicationModuleChooser +import com.itsaky.androidide.activities.editor.EditorHandlerActivity +import com.itsaky.androidide.idetooltips.TooltipTag.EDITOR_TOOLBAR_EXPORT_APK +import com.itsaky.androidide.lookup.Lookup +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.projects.builder.BuildService +import com.itsaky.androidide.projects.models.assembleTaskOutputListingFile +import com.itsaky.androidide.projects.models.buildDir +import com.itsaky.androidide.quickbuild.QuickBuildTaskPaths +import com.itsaky.androidide.utils.ApkExport +import com.itsaky.androidide.utils.DialogUtils +import com.itsaky.androidide.utils.IntentUtils +import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashSuccess +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException + +/** + * Hands over the selected variant's built APK (ADFA-4930): with relocated build output it is in + * app-private storage, where a file manager cannot reach it. Offers the share sheet, or a copy in + * the Download folder. + */ +class ExportApkAction( + context: Context, + override val order: Int, +) : EditorActivityAction() { + override val id: String = "ide.editor.exportApk" + override var requiresUIThread: Boolean = true + + override fun retrieveTooltipTag(isReadOnlyContext: Boolean) = EDITOR_TOOLBAR_EXPORT_APK + + init { + label = context.getString(R.string.title_export_apk) + icon = ContextCompat.getDrawable(context, R.drawable.ic_share) + } + + override fun prepare(data: ActionData) { + super.prepare(data) + data.getActivity() ?: run { + markInvisible() + return + } + + visible = true + // The user-visible flag, like the build actions: Quick Build's prebuild after every project + // open is refused at the tap instead (isBuilding), with a reason. + enabled = + IProjectManager.getInstance().getAndroidAppModules().isNotEmpty() && + buildService()?.isUserVisibleBuildInProgress != true + } + + override suspend fun execAction(data: ActionData) { + if (isBuilding()) return + openApplicationModuleChooser(data) { app -> + val variant = app.getSelectedVariant() + if (variant == null) { + flashError(R.string.err_selected_variant_not_found) + return@openApplicationModuleChooser + } + + val activity = data.requireActivity() + val projectManager = IProjectManager.getInstance() + val projectDir = File(projectManager.projectDirPath) + // The module only when it tells two app modules' exports apart. + val module = app.name.takeIf { projectManager.getAndroidAppModules().size > 1 } + val name = listOfNotNull(projectDir.name, module, variant.name).joinToString("-") + activity.activityScope.launch { + val found = + withContext(Dispatchers.IO) { + ApkExport.resolve( + listingFile = variant.mainArtifact.assembleTaskOutputListingFile, + quickBuildReport = QuickBuildTaskPaths.setupJson(app.buildDir, variant.name), + projectDir = projectDir, + ) + } + when (found) { + ApkExport.Exportable.NotBuilt -> flashError(R.string.msg_export_apk_not_built) + ApkExport.Exportable.QuickBuildProxy -> flashError(R.string.msg_export_apk_quick_build) + is ApkExport.Exportable.Apk -> withContext(Dispatchers.Main) { offerExport(activity, found.file, name) } + } + } + } + } + + private fun buildService(): BuildService? = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) + + /** A build, Quick Build's prebuild included, may be rewriting the APK; refuse with a reason. */ + private fun isBuilding(): Boolean { + if (buildService()?.isBuildInProgress != true) return false + flashError(R.string.msg_export_apk_build_running) + return true + } + + private fun offerExport( + activity: EditorHandlerActivity, + apk: File, + name: String, + ) { + DialogUtils + .newMaterialDialogBuilder(activity) + .setTitle(R.string.title_export_apk) + .setItems( + arrayOf( + activity.getString(R.string.export_apk_share), + activity.getString(R.string.export_apk_copy_to_download), + ), + ) { _, which -> + if (which == 0) { + IntentUtils.shareFile(activity, apk, ApkExport.MIME_TYPE) + } else if (!isBuilding()) { + activity.activityScope.launch(Dispatchers.IO) { copyToDownload(activity, apk, name) } + } + }.setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun copyToDownload( + activity: EditorHandlerActivity, + apk: File, + name: String, + ) { + @Suppress("DEPRECATION") + val download = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + try { + val copy = ApkExport.copyTo(apk, download, name) + flashSuccess(activity.getString(R.string.msg_export_apk_saved, copy.name)) + } catch (e: IOException) { + log.error("Cannot copy {} to {}", apk, download, e) + flashError(R.string.msg_export_apk_failed) + } + } + + override fun getShowAsActionFlags(data: ActionData): Int = MenuItem.SHOW_AS_ACTION_IF_ROOM + + companion object { + private val log = LoggerFactory.getLogger(ExportApkAction::class.java) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt index eeac14fa2e..82f0d511ce 100644 --- a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt @@ -27,6 +27,7 @@ import com.itsaky.androidide.plugins.manager.services.IdeTerminalServiceImpl import com.itsaky.androidide.preferences.internal.DevOpsPreferences import com.itsaky.androidide.preferences.internal.GeneralPreferences import com.itsaky.androidide.resources.localization.LocaleProvider +import com.itsaky.androidide.services.builder.BuildOutputLocation import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult import com.itsaky.androidide.ui.themes.IDETheme import com.itsaky.androidide.ui.themes.IThemeManager @@ -142,6 +143,13 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { IDEColorSchemeProvider.init() } + app.coroutineScope.launch(Dispatchers.IO) { + // ADFA-4930: relocated build output of projects deleted, moved or renamed outside + // the IDE. + val removed = BuildOutputLocation.sweep(app.noBackupFilesDir) + if (removed > 0) logger.info("Swept {} orphaned build output tree(s)", removed) + } + if (!VMUtils.isJvm || VMUtils.isInstrumentedTest) { ToolsManager.init(app, null) } diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt index 11fc7691fe..5822ff1265 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt @@ -7,6 +7,7 @@ import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.api.AndroidModule import com.itsaky.androidide.projects.builder.BuildService import com.itsaky.androidide.projects.isPluginProject +import com.itsaky.androidide.projects.models.buildDir import com.itsaky.androidide.resources.R import com.itsaky.androidide.services.builder.GradleBuildService import com.itsaky.androidide.tooling.api.GradlePluginConfig @@ -459,28 +460,19 @@ class GradleQuickBuildProvisioner( // Variant-scoped, matching where the Gradle plugin writes it: one report per // debuggable variant, so a flavored project has several and only this variant's is - // the built app. - val reportPath = QuickBuildTaskPaths.setupJson(variantName) - val reportFile = - sequenceOf( - File(moduleDir, reportPath), - File(projectRoot, reportPath), - ).firstOrNull { it.isFile } - ?: run { - log.error( - "{} not found under {} or {} after the proxy app build", - reportPath, - moduleDir, - projectRoot, - ) - // The build succeeded but wrote no Quick Build setup, which all but - // names the cause: the plugin only configures DEBUGGABLE variants, and - // the release-name check above only catches AGP's own release build - // type. Say so instead of the generic "setup failed". - return ProxyAppBuildResult.Failed( - context.getString(R.string.quick_build_variant_setup_missing, variantName), - ) - } + // the built app. Under the build directory the synced model gives the module, which + // relocated build output (ADFA-4930) moves out of the project folder. + val reportFile = QuickBuildTaskPaths.setupJson(module.buildDir, variantName) + if (!reportFile.isFile) { + log.error("{} not found after the proxy app build", reportFile) + // The build succeeded but wrote no Quick Build setup, which all but names the + // cause: the plugin only configures DEBUGGABLE variants, and the release-name + // check above only catches AGP's own release build type. Say so instead of the + // generic "setup failed". + return ProxyAppBuildResult.Failed( + context.getString(R.string.quick_build_variant_setup_missing, variantName), + ) + } val proxyApp = ProxyAppInfo.parse(reportFile.readText(), projectRoot) diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt index e281c677da..2b0bc3d9a7 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt @@ -1,5 +1,7 @@ package com.itsaky.androidide.quickbuild +import java.io.File + /** * Composes the Gradle task path for the quick-build proxy app build's `assemble` task from * a module's Gradle project path and the variant CoGo has selected. @@ -36,10 +38,16 @@ object QuickBuildTaskPaths { } /** - * Where the Gradle plugin writes that variant's proxy app report, relative to the - * directory owning the `build/` dir - the other half of the same contract, kept next to - * the task name so the two cannot drift apart. Variant-scoped like every other Quick - * Build output: a flavored project has one report per debuggable variant. + * Where the Gradle plugin writes that variant's proxy app report - the other half of the same + * contract, kept next to the task name so the two cannot drift apart. Variant-scoped like + * every other Quick Build output: a flavored project has one report per debuggable variant. + * + * @param buildDir the module's build directory as the project model has it; relocated build + * output (ADFA-4930) is not under `/build`. + * @param variantName the variant; blank falls back to [DEFAULT_VARIANT]. */ - fun setupJson(variantName: String = DEFAULT_VARIANT): String = "build/quickbuild/${variantName.ifBlank { DEFAULT_VARIANT }}/setup.json" + fun setupJson( + buildDir: File, + variantName: String = DEFAULT_VARIANT, + ): File = File(buildDir, "quickbuild/${variantName.ifBlank { DEFAULT_VARIANT }}/setup.json") } diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt index 6b0e7a3291..794834c6df 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -8,8 +8,12 @@ import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPU import com.itsaky.androidide.utils.Experiment import com.itsaky.androidide.utils.Experiments import com.itsaky.androidide.utils.ProjectStorageKey +import com.itsaky.androidide.utils.canonicalProjectLocation +import com.itsaky.androidide.utils.isDeletedProjectDirectory import java.io.File import java.io.IOException +import java.nio.file.Files +import java.nio.file.LinkOption /** * Where a project's Gradle build output goes when it is moved off the FUSE-backed project folder @@ -26,7 +30,7 @@ object BuildOutputLocation { /** Parent of every project's tree, under `noBackupFilesDir`. */ const val DIR_NAME = "gradle-build" - /** Holds the project's path, so a sweep can find trees whose project is gone. */ + /** Holds the project's canonical path, so a sweep can find trees whose project is gone. */ const val PROJECT_PATH_FILE = "project-path" private const val KOTLIN_PERSISTENT_DIR = "kotlin.project.persistent.dir" @@ -34,6 +38,9 @@ object BuildOutputLocation { private const val NO_WATCH_FS = "--no-watch-fs" private const val PROJECT_CACHE_DIR = "--project-cache-dir" + /** Where Android mounts storage volumes: internal storage, and each SD card or USB drive. */ + private val STORAGE_ROOT = File("/storage") + /** Whether the switch exists at all: early access only (EXPERIMENTS.md). */ fun isAvailable(): Boolean = Experiments.isOn(Experiment.BUILD_OUTPUT_RELOCATION) @@ -77,7 +84,8 @@ object BuildOutputLocation { fun withoutWatchConflict(args: List): List = if (PROJECT_CACHE_DIR in args) args.filterNot { it == WATCH_FS } else args /** - * Creates [tree] and records [projectDir] in it. + * Creates [tree] and records [projectDir] in it, canonically: a project deleted through its real + * path must still match a marker written while it was opened through a symlink. * * @return false when [tree] or its marker cannot be written; a sync then keeps output in the * project folder. @@ -88,7 +96,7 @@ object BuildOutputLocation { ): Boolean { if (!tree.isDirectory && !tree.mkdirs()) return false val marker = File(tree, PROJECT_PATH_FILE) - val path = projectDir.absolutePath + val path = projectDir.canonicalProjectLocation() return try { if (!marker.isFile || marker.readText() != path) marker.writeText(path) true @@ -97,6 +105,94 @@ object BuildOutputLocation { } } + /** + * Deletes the trees of [projectDir], matched by their [PROJECT_PATH_FILE] marker rather than by + * key, so a path spelled differently (`/sdcard` against `/storage/emulated/0`) still matches. + * Call it when the user deletes the project. + * + * @return how many trees were deleted. + */ + fun removeTreesOf( + noBackupDir: File, + projectDir: File, + ): Int { + val location = projectDir.canonicalProjectLocation() + return deleteTreesWhere(noBackupDir) { it.canonicalProjectLocation() == location } + } + + /** + * Deletes the trees of projects that no longer exist, such as a project deleted, moved or renamed + * outside the IDE, or deleted with the folder holding it. A project on a volume that is not + * mounted is not gone, and a tree without a marker is left alone. Runs on a background thread at + * startup. + * + * @param volumesRoot where storage volumes are mounted; tests pass their own. + * @return how many trees were deleted. + */ + fun sweep( + noBackupDir: File, + volumesRoot: File = STORAGE_ROOT, + ): Int = deleteTreesWhere(noBackupDir) { isGone(it, volumesRoot) } + + /** + * Whether [project] was deleted. With its parent folder still there, Recents' rule decides. With + * the parent missing too, the nearest folder that exists decides: a readable folder on a volume + * means the folders were deleted, while [volumesRoot] itself means the volume is not mounted. + */ + private fun isGone( + project: File, + volumesRoot: File, + ): Boolean { + if (project.isDeletedProjectDirectory()) return true + if (project.exists()) return false + val nearest = generateSequence(project.parentFile) { it.parentFile }.firstOrNull { it.exists() } ?: return false + return try { + val ancestor = nearest.canonicalFile + val root = volumesRoot.canonicalFile + ancestor != root && ancestor.startsWith(root) && ancestor.isDirectory && ancestor.canRead() + } catch (e: IOException) { + false + } + } + + private fun deleteTreesWhere( + noBackupDir: File, + isOrphan: (File) -> Boolean, + ): Int { + // A link among the trees is not a tree; following it would delete what it points to. + val trees = + File(noBackupDir, DIR_NAME).listFiles { file -> Files.isDirectory(file.toPath(), LinkOption.NOFOLLOW_LINKS) } + ?: return 0 + return trees.count { tree -> + val project = projectOf(tree) + project != null && isOrphan(project) && deleteTree(tree) + } + } + + /** + * Deletes [tree] with its marker last: a deletion that stops part-way keeps the marker, so the + * next sweep can still tell whose tree it is and finish the job. A build can leave symlinks in its + * output, so the deletion never follows one out of the tree. + */ + private fun deleteTree(tree: File): Boolean { + val marker = File(tree, PROJECT_PATH_FILE) + val contents = tree.listFiles() ?: return false + val deleted = contents.filter { it != marker }.map { deleteWithoutFollowingLinks(it) }.all { it } + return deleted && (!marker.exists() || marker.delete()) && tree.delete() + } + + /** The project [tree] belongs to, from its marker, or null when the marker is missing or unreadable. */ + private fun projectOf(tree: File): File? = + try { + File(tree, PROJECT_PATH_FILE) + .takeIf { it.isFile } + ?.readText() + ?.takeIf { it.isNotBlank() } + ?.let(::File) + } catch (e: IOException) { + null + } + /** * Whether a synced model's build directory lies in [tree], meaning the sync that produced the * model relocated that project's build output. diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/DeleteWithoutFollowingLinks.kt b/app/src/main/java/com/itsaky/androidide/services/builder/DeleteWithoutFollowingLinks.kt new file mode 100644 index 0000000000..9490681da0 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/services/builder/DeleteWithoutFollowingLinks.kt @@ -0,0 +1,59 @@ +package com.itsaky.androidide.services.builder + +import java.io.File +import java.io.IOException +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes + +/** + * Deletes [dir] and everything in it without following symlinks, so a link inside never leads the + * deletion out of [dir]. A file that cannot be deleted is skipped, not fatal. + * + * @return whether [dir] is gone. + */ +internal fun deleteWithoutFollowingLinks(dir: File): Boolean { + val start = dir.toPath() + if (Files.notExists(start, LinkOption.NOFOLLOW_LINKS)) return true + try { + Files.walkFileTree( + start, + object : SimpleFileVisitor() { + override fun visitFile( + file: Path, + attrs: BasicFileAttributes, + ): FileVisitResult { + deleteQuietly(file) + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed( + file: Path, + exc: IOException, + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun postVisitDirectory( + dir: Path, + exc: IOException?, + ): FileVisitResult { + deleteQuietly(dir) + return FileVisitResult.CONTINUE + } + }, + ) + } catch (e: IOException) { + return false + } + return Files.notExists(start, LinkOption.NOFOLLOW_LINKS) +} + +private fun deleteQuietly(path: Path) { + try { + Files.deleteIfExists(path) + } catch (e: IOException) { + // Left in place; the folder holding it stays too. + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt b/app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt new file mode 100644 index 0000000000..c420c8f5a1 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt @@ -0,0 +1,107 @@ +package com.itsaky.androidide.utils + +import com.itsaky.androidide.models.ApkMetadata +import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException + +/** Finds a built APK and copies it out of app storage under a readable name (ADFA-4930). */ +internal object ApkExport { + const val MIME_TYPE = "application/vnd.android.package-archive" + + private val log = LoggerFactory.getLogger(ApkExport::class.java) + + /** What [resolve] found to export. */ + sealed interface Exportable { + data class Apk( + val file: File, + ) : Exportable + + /** No APK yet, or its listing cannot be read. */ + data object NotBuilt : Exportable + + /** + * The APK at the variant's output path is Quick Build's live-reload proxy app, which its + * build writes there; exporting it would hand over the wrong app. + */ + data object QuickBuildProxy : Exportable + } + + /** + * @param listingFile the variant's APK listing file, from the synced model. + * @param quickBuildReport the variant's Quick Build `setup.json`, which names the proxy APK. + * @param projectDir the project's root, which the report's relative paths resolve against. + */ + fun resolve( + listingFile: File, + quickBuildReport: File, + projectDir: File, + ): Exportable { + val apk = + try { + ApkMetadata.findApkFile(listingFile)?.takeIf { it.isFile } + } catch (e: Exception) { + // A truncated or malformed listing (JSON or redirect) means there is no APK to trust. + log.warn("Cannot read the APK listing {}", listingFile, e) + null + } ?: return Exportable.NotBuilt + return if (isQuickBuildProxy(apk, quickBuildReport, projectDir)) Exportable.QuickBuildProxy else Exportable.Apk(apk) + } + + /** + * Quick Build's report is written after the proxy APK it names, so the APK is the proxy when + * the report names it and is not older than it. A standard build rewrites the APK and leaves + * the report alone, so after one the APK is newer. + */ + private fun isQuickBuildProxy( + apk: File, + report: File, + projectDir: File, + ): Boolean { + if (!report.isFile) return false + val reported = + try { + ProxyAppInfo.parse(report.readText(), projectDir)?.apk + } catch (e: IOException) { + null + } ?: return false + return reported.absoluteFile.normalize() == apk.absoluteFile.normalize() && report.lastModified() >= apk.lastModified() + } + + /** + * Copies [apk] into [dir] as `.apk`, or ` (2).apk` and so on when that is taken, so + * an earlier export is never overwritten. The copy goes to a temporary name first, so one that + * fails part-way never leaves a broken APK under the export's name. + * + * @param name the file name without `.apk`; characters a file name cannot hold are replaced. + * @return the copy. + * @throws IOException when [dir] cannot be created or the copy fails. + */ + fun copyTo( + apk: File, + dir: File, + name: String, + copy: (File, File) -> Unit = { from, to -> from.copyTo(to) }, + ): File { + if (!dir.isDirectory && !dir.mkdirs()) throw IOException("Cannot create $dir") + val target = freeTarget(dir, safeFileName(name, "app")) + val partial = File(dir, ".${target.name}.partial") + try { + partial.delete() + copy(apk, partial) + if (!partial.renameTo(target)) throw IOException("Cannot rename $partial to $target") + } finally { + partial.delete() + } + return target + } + + private fun freeTarget( + dir: File, + name: String, + ): File = + generateSequence(1) { it + 1 } + .map { n -> File(dir, if (n == 1) "$name.apk" else "$name ($n).apk") } + .first { !it.exists() } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt index 2ac9699f3b..cc037930d9 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt @@ -38,6 +38,7 @@ import com.itsaky.androidide.actions.editor.LongSelectAction import com.itsaky.androidide.actions.editor.PasteAction import com.itsaky.androidide.actions.editor.SelectAllAction import com.itsaky.androidide.actions.etc.DisconnectLogSendersAction +import com.itsaky.androidide.actions.etc.ExportApkAction import com.itsaky.androidide.actions.etc.FindAction import com.itsaky.androidide.actions.etc.FindInFileAction import com.itsaky.androidide.actions.etc.FindInProjectAction @@ -64,6 +65,7 @@ import com.itsaky.androidide.actions.text.UndoAction import com.itsaky.androidide.plugins.extensions.UIExtension import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager import com.itsaky.androidide.plugins.manager.core.PluginManager +import com.itsaky.androidide.services.builder.BuildOutputLocation /** * Takes care of registering actions to the actions registry for the editor activity. @@ -106,6 +108,10 @@ class EditorActivityActions { registry.registerAction(FindInProjectAction(context, order++)) registry.registerAction(ReplaceInProjectAction(context, order++)) registry.registerAction(LaunchAppAction(context, order++)) + // ADFA-4930: relocated build output keeps the APK out of the project folder. + if (BuildOutputLocation.isAvailable()) { + registry.registerAction(ExportApkAction(context, order++)) + } registry.registerAction(DisconnectLogSendersAction(context, order++)) // Plugin contributions diff --git a/app/src/main/java/com/itsaky/androidide/utils/FileNames.kt b/app/src/main/java/com/itsaky/androidide/utils/FileNames.kt new file mode 100644 index 0000000000..410d9358dd --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/FileNames.kt @@ -0,0 +1,14 @@ +package com.itsaky.androidide.utils + +/** Characters a file name on shared storage cannot hold. */ +internal val UNSAFE_FILENAME_CHARS = Regex("[\\\\/:*?\"<>|]") + +/** + * [raw] with every character a file name cannot hold replaced by `_`, and trimmed. + * + * @param fallback the name to use when nothing is left. + */ +internal fun safeFileName( + raw: String, + fallback: String, +): String = raw.replace(UNSAFE_FILENAME_CHARS, "_").trim().ifBlank { fallback } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt index 87487e4420..99b3209b4f 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt @@ -11,9 +11,11 @@ import com.itsaky.androidide.models.installTaskRequestsIn import com.itsaky.androidide.project.AndroidModels import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.api.AndroidModule +import com.itsaky.androidide.projects.api.Workspace import com.itsaky.androidide.projects.builder.BuildService import com.itsaky.androidide.projects.isPluginProject import com.itsaky.androidide.projects.models.assembleTaskOutputListingFile +import com.itsaky.androidide.projects.models.buildDir import com.itsaky.androidide.resources.R import com.itsaky.androidide.tooling.api.messages.BuildRunType import com.itsaky.androidide.tooling.api.messages.GradleBuildParams @@ -147,15 +149,14 @@ class BuildViewModel( } if (isPluginProject) { - val projectRoot = IProjectManager.getInstance().projectDirPath - val cgpFile = - withContext(Dispatchers.IO) { findPluginCgpFile(projectRoot, variant) } + val workspace = IProjectManager.getInstance().workspace + val cgpFile = withContext(Dispatchers.IO) { findPluginCgpFile(workspace, variant.name) } if (cgpFile != null) { reporter.finish(BuildState.AwaitingPluginInstall(cgpFile)) } else { - log.warn("Plugin built successfully but .cgp file not found") + log.warn("Plugin built successfully but no .cgp file under {}/plugin", workspace?.rootProject?.buildDir) reporter.finish( - BuildState.Error("Plugin built but output file (.cgp) not found in build/plugin"), + BuildState.Error("Plugin built but output file (.cgp) not found"), ) } return@launch @@ -319,18 +320,25 @@ class BuildViewModel( _buildState.value = BuildState.Idle } } +} - private fun findPluginCgpFile( - projectRoot: String, - variant: AndroidModels.AndroidVariant, - ): File? { - val pluginDir = File(projectRoot, "build/plugin") - if (!pluginDir.exists()) return null - - val isDebug = variant.name.contains("debug", ignoreCase = true) - return pluginDir - .listFiles { file -> file.extension.equals(PLUGIN_ARCHIVE_EXTENSION, ignoreCase = true) } - ?.filter { it.name.contains("-debug") == isDebug } - ?.maxByOrNull { it.lastModified() } - } +/** + * The newest `.cgp` a plugin build produced for [variantName], under the root project's build + * directory as the synced model has it: relocated build output (ADFA-4930) is not under + * `/build`, which may still hold a plugin from before the relocation. + * + * @return null when there is no model or no matching `.cgp`. + */ +internal fun findPluginCgpFile( + workspace: Workspace?, + variantName: String, +): File? { + val pluginDir = File(workspace?.rootProject?.buildDir ?: return null, "plugin") + if (!pluginDir.exists()) return null + + val isDebug = variantName.contains("debug", ignoreCase = true) + return pluginDir + .listFiles { file -> file.extension.equals(PLUGIN_ARCHIVE_EXTENSION, ignoreCase = true) } + ?.filter { it.name.contains("-debug") == isDebug } + ?.maxByOrNull { it.lastModified() } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt index 1436c3079a..84f398345f 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt @@ -15,6 +15,7 @@ import com.itsaky.androidide.roomData.recentproject.RecentProject import com.itsaky.androidide.roomData.recentproject.RecentProjectDao import com.itsaky.androidide.roomData.recentproject.RecentProjectMaintenance import com.itsaky.androidide.roomData.recentproject.RecentProjectRoomDatabase +import com.itsaky.androidide.services.builder.BuildOutputLocation import com.itsaky.androidide.templates.Language import com.itsaky.androidide.utils.canonicalProjectLocation import com.itsaky.androidide.utils.getCreatedTime @@ -284,6 +285,12 @@ class RecentProjectsViewModel( } } + /** ADFA-4930: the project's relocated build output goes with it. Call on [Dispatchers.IO]. */ + private fun removeBuildOutput(location: String) { + val removed = BuildOutputLocation.removeTreesOf(getApplication().noBackupFilesDir, File(location)) + if (removed > 0) logger.info("Deleted {} build output tree(s) of {}", removed, location) + } + fun deleteProjectByLocation(location: String) = viewModelScope.launch { try { @@ -297,6 +304,7 @@ class RecentProjectsViewModel( if (isDeleted) { recentProjectDao.deleteByLocation(projectToDelete.location) + removeBuildOutput(projectToDelete.location) } projectToDelete.takeIf { isDeleted } } @@ -394,6 +402,7 @@ class RecentProjectsViewModel( if (isDeletedFromStorage) { successfullyDeletedLocations.add(project.location) + removeBuildOutput(project.location) } else { logger.warn("Failed to delete project files from storage: ${project.location}") allDeletionsSucceeded = false diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt index f2bb0c6dc3..1b238ff2d8 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt @@ -13,6 +13,7 @@ import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent import com.itsaky.androidide.utils.InstallTempFiles import com.itsaky.androidide.utils.LastValueGate import com.itsaky.androidide.utils.UriFileImporter +import com.itsaky.androidide.utils.safeFileName import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable @@ -41,7 +42,6 @@ class ExternalFileInstallViewModel( ) : ViewModel() { private companion object { private val log = LoggerFactory.getLogger(ExternalFileInstallViewModel::class.java) - private val UNSAFE_FILENAME_CHARS = Regex("[\\\\/:*?\"<>|]") // A cold OS-triggered launch of this activity can win the race against IDEApplication's // async setup (device-unlock -> CredentialProtectedApplicationLoader.load(), which itself @@ -345,7 +345,7 @@ class ExternalFileInstallViewModel( return candidate } - fun sanitizeBaseName(rawName: String): String = rawName.replace(UNSAFE_FILENAME_CHARS, "_").trim().ifBlank { "templates" } + fun sanitizeBaseName(rawName: String): String = safeFileName(rawName, "templates") private suspend fun sendErrorAndFinish( generation: Int, diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerBuildDirTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerBuildDirTest.kt new file mode 100644 index 0000000000..f1d328c60a --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerBuildDirTest.kt @@ -0,0 +1,61 @@ +package com.itsaky.androidide.quickbuild + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.service.provision.ProvisionOutcome +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +/** + * The proxy app report is read from the module's build directory as the synced project model + * gives it. With build output relocated off the project folder (ADFA-4930) that directory is + * not `/build`, which may still hold a report from before the relocation. + */ +@RunWith(RobolectricTestRunner::class) +class GradleQuickBuildProvisionerBuildDirTest { + @get:Rule + val temp = TemporaryFolder() + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + private val projectRoot: File get() = File(temp.root, "project") + + private val moduleDir: File get() = File(projectRoot, "app").apply { mkdirs() } + + private val relocatedBuildDir: File get() = File(temp.root, "no_backup/gradle-build/project-1234/main/app-5678") + + @Test + fun `a report under a relocated build directory is found`() = + runTest { + writeSetupJson(moduleDir, buildDir = relocatedBuildDir) + + val outcome = provisioner().provision() + + assertThat(outcome).isInstanceOf(ProvisionOutcome.Success::class.java) + } + + @Test + fun `a stale report left in the project folder is not read`() = + runTest { + writeSetupJson(moduleDir, entryActivity = "com.example.demo.StaleActivity") + writeSetupJson(moduleDir, buildDir = relocatedBuildDir) + + val outcome = provisioner().provision() + + assertThat((outcome as ProvisionOutcome.Success).proxyApp.entryActivity) + .isEqualTo("com.example.demo.MainActivity") + } + + private fun provisioner(): GradleQuickBuildProvisioner = + testProvisioner( + context, + projectRoot, + projectManager = FakeProjectManager(projectRoot, appModules = listOf(androidModule(buildDir = relocatedBuildDir))), + ) +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFakes.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFakes.kt index d38c83182f..b46b121d99 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFakes.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFakes.kt @@ -143,13 +143,16 @@ internal fun unsyncedProjectManager(projectRoot: File): FakeProjectManager = Fak * @param path the module's Gradle path. * @param variantName the selected variant, or null for the mid-sync read the provisioner * falls back to the default variant on. + * @param buildDir the module's build directory as the synced model reports it. */ internal fun androidModule( path: String = ":app", variantName: String? = "debug", + buildDir: File = File("/fake-project/app/build"), ): AndroidModule = mockk().also { module -> every { module.path } returns path + every { module.buildDirPath } returns buildDir.path every { module.getSelectedVariant() } returns variantName?.let { AndroidModels.AndroidVariant @@ -208,17 +211,20 @@ internal const val REAL_APPLICATION_ID = "com.example.demo" /** * Writes the report the Gradle plugin would have written for a successful proxy app build. * - * @param moduleDir the module whose `build/` dir owns the report. + * @param moduleDir the module whose `build/` dir owns the report, as [testProvisioner]'s default + * module reports it. * @param variantName the variant the report belongs to; reports are variant-scoped. * @param entryActivity the launcher activity, or null for the No-Activity template, which is a * successful build with nothing to launch. + * @param buildDir the build directory the report goes under. */ internal fun writeSetupJson( moduleDir: File, variantName: String = "debug", entryActivity: String? = "com.example.demo.MainActivity", + buildDir: File = File(moduleDir, "build"), ) { - val report = File(moduleDir, QuickBuildTaskPaths.setupJson(variantName)) + val report = QuickBuildTaskPaths.setupJson(buildDir, variantName) report.parentFile.mkdirs() val apkPath = "app/build/outputs/apk/$variantName/app-$variantName.apk" report.writeText( @@ -235,7 +241,7 @@ internal fun writeUnparseableSetupJson( moduleDir: File, variantName: String = "debug", ) { - val report = File(moduleDir, QuickBuildTaskPaths.setupJson(variantName)) + val report = QuickBuildTaskPaths.setupJson(File(moduleDir, "build"), variantName) report.parentFile.mkdirs() // A well-formed JSON object with no proxyAppId: parseable as JSON, unusable as a report, // which is the shape a schema drift actually produces. @@ -250,7 +256,8 @@ internal fun testProvisioner( context: Context, projectRoot: File, buildService: BuildService? = FakeBuildService(), - projectManager: IProjectManager = FakeProjectManager(projectRoot, appModules = listOf(androidModule())), + projectManager: IProjectManager = + FakeProjectManager(projectRoot, appModules = listOf(androidModule(buildDir = File(projectRoot, "app/build")))), installer: ProxyAppInstaller = recordingInstaller(), packages: InstalledPackages = FakePackages(), apkCertSha256: (File) -> String? = { null }, diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt index 3408e4af87..d06f049d2b 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.quickbuild import com.google.common.truth.Truth.assertThat import org.junit.Test +import java.io.File /** * Two regressions are pinned here. @@ -73,18 +74,20 @@ class QuickBuildTaskPathsTest { @Test fun `the report path is variant-scoped, matching where the Gradle plugin writes it`() { - // Both halves of the plugin contract: `build/quickbuild//setup.json`. A + // Both halves of the plugin contract: `/quickbuild//setup.json`. A // flavor-agnostic path here would read another flavor's report - the wrong APK and // the wrong applicationId. - assertThat(QuickBuildTaskPaths.setupJson("debug")) - .isEqualTo("build/quickbuild/debug/setup.json") - assertThat(QuickBuildTaskPaths.setupJson("demoDebug")) - .isEqualTo("build/quickbuild/demoDebug/setup.json") + val buildDir = File("/projects/app/build") + assertThat(QuickBuildTaskPaths.setupJson(buildDir, "debug")) + .isEqualTo(File(buildDir, "quickbuild/debug/setup.json")) + assertThat(QuickBuildTaskPaths.setupJson(buildDir, "demoDebug")) + .isEqualTo(File(buildDir, "quickbuild/demoDebug/setup.json")) } @Test fun `a blank variant reads the default variant's report`() { - assertThat(QuickBuildTaskPaths.setupJson("")).isEqualTo("build/quickbuild/debug/setup.json") - assertThat(QuickBuildTaskPaths.setupJson()).isEqualTo("build/quickbuild/debug/setup.json") + val buildDir = File("/projects/app/build") + assertThat(QuickBuildTaskPaths.setupJson(buildDir, "")).isEqualTo(File(buildDir, "quickbuild/debug/setup.json")) + assertThat(QuickBuildTaskPaths.setupJson(buildDir)).isEqualTo(File(buildDir, "quickbuild/debug/setup.json")) } } diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt index b63acc1597..cb14c2d744 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -11,6 +11,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +import java.nio.file.Files class BuildOutputLocationTest { @get:Rule @@ -58,14 +59,14 @@ class BuildOutputLocationTest { } @Test - fun `prepare creates the tree and records the project path`() { + fun `prepare creates the tree and records the canonical project path`() { val tree = File(tmp.root, "gradle-build/app-1234") val project = File(tmp.root, "projects/app") assertThat(BuildOutputLocation.prepare(tree, project)).isTrue() assertThat(tree.isDirectory).isTrue() - assertThat(File(tree, BuildOutputLocation.PROJECT_PATH_FILE).readText()).isEqualTo(project.absolutePath) + assertThat(File(tree, BuildOutputLocation.PROJECT_PATH_FILE).readText()).isEqualTo(project.canonicalPath) } @Test @@ -76,7 +77,7 @@ class BuildOutputLocationTest { assertThat(BuildOutputLocation.prepare(tree, project)).isTrue() - assertThat(File(tree, BuildOutputLocation.PROJECT_PATH_FILE).readText()).isEqualTo(project.absolutePath) + assertThat(File(tree, BuildOutputLocation.PROJECT_PATH_FILE).readText()).isEqualTo(project.canonicalPath) } @Test @@ -154,6 +155,148 @@ class BuildOutputLocationTest { assertThat(BuildOutputLocation.syncPending(modelRelocated = null, requested = true)).isFalse() } + @Test + fun `deleting a project deletes its tree and no other`() { + val noBackup = tmp.newFolder("no_backup") + val app = tmp.newFolder("projects", "app") + val other = tmp.newFolder("projects", "other") + val appTree = treeOf(noBackup, app) + val otherTree = treeOf(noBackup, other) + + assertThat(BuildOutputLocation.removeTreesOf(noBackup, app)).isEqualTo(1) + + assertThat(appTree.exists()).isFalse() + assertThat(otherTree.isDirectory).isTrue() + } + + @Test + fun `deleting a project's tree never follows a symlink out of it`() { + val noBackup = tmp.newFolder("no_backup") + val project = tmp.newFolder("projects", "app") + val tree = treeOf(noBackup, project) + val elsewhere = tmp.newFolder("elsewhere") + val precious = File(elsewhere, "precious.txt").apply { writeText("keep me") } + // A build can leave a link in its output, such as one a custom task makes. + Files.createSymbolicLink(File(tree, "main/app-1234/outputs/link").toPath(), elsewhere.toPath()) + + assertThat(BuildOutputLocation.removeTreesOf(noBackup, project)).isEqualTo(1) + + assertThat(tree.exists()).isFalse() + assertThat(precious.readText()).isEqualTo("keep me") + } + + @Test + fun `the sweep does not follow a link among the trees`() { + val noBackup = tmp.newFolder("no_backup") + // A folder outside gradle-build whose marker names a deleted project, reached through a link. + val elsewhere = tmp.newFolder("elsewhere") + File(elsewhere, BuildOutputLocation.PROJECT_PATH_FILE).writeText(File(tmp.newFolder("projects"), "gone").path) + val precious = File(elsewhere, "precious.txt").apply { writeText("keep me") } + Files.createSymbolicLink(File(File(noBackup, BuildOutputLocation.DIR_NAME).apply { mkdirs() }, "link").toPath(), elsewhere.toPath()) + + assertThat(BuildOutputLocation.sweep(noBackup, tmp.root)).isEqualTo(0) + + assertThat(precious.readText()).isEqualTo("keep me") + } + + @Test + fun `a tree recorded under another spelling of the project path is still found`() { + val noBackup = tmp.newFolder("no_backup") + val app = tmp.newFolder("storage", "app") + val link = File(tmp.root, "sdcard").toPath() + Files.createSymbolicLink(link, app.parentFile.toPath()) + val tree = treeOf(noBackup, app) + // A marker written before the canonical path was recorded holds the alias spelling. + File(tree, BuildOutputLocation.PROJECT_PATH_FILE).writeText(File(link.toFile(), "app").path) + + assertThat(BuildOutputLocation.removeTreesOf(noBackup, app)).isEqualTo(1) + assertThat(tree.exists()).isFalse() + } + + @Test + fun `deleting a project opened through a symlinked root deletes its tree`() { + val noBackup = tmp.newFolder("no_backup") + val real = tmp.newFolder("real", "app") + val alias = File(tmp.root, "alias-app").also { Files.createSymbolicLink(it.toPath(), real.toPath()) } + val tree = treeOf(noBackup, alias) + // Recents deletes the real folder, which leaves the alias dangling. + real.deleteRecursively() + + assertThat(BuildOutputLocation.removeTreesOf(noBackup, real)).isEqualTo(1) + assertThat(tree.exists()).isFalse() + } + + @Test + fun `the sweep deletes the trees of deleted projects only`() { + val noBackup = tmp.newFolder("no_backup") + val kept = tmp.newFolder("projects", "kept") + val deleted = tmp.newFolder("projects", "deleted") + val keptTree = treeOf(noBackup, kept) + val deletedTree = treeOf(noBackup, deleted) + val volumes = tmp.newFolder("storage") + val unmountedTree = treeOf(noBackup, File(volumes, "usb-drive/Projects/app")) + val markerless = File(noBackup, "${BuildOutputLocation.DIR_NAME}/no-marker").apply { mkdirs() } + deleted.deleteRecursively() + + assertThat(BuildOutputLocation.sweep(noBackup, volumes)).isEqualTo(1) + + assertThat(deletedTree.exists()).isFalse() + assertThat(keptTree.isDirectory).isTrue() + // Only the volumes root is left of the path: the volume is not mounted, not deleted. + assertThat(unmountedTree.isDirectory).isTrue() + assertThat(markerless.isDirectory).isTrue() + } + + @Test + fun `the sweep deletes the trees of projects deleted with their folder`() { + val noBackup = tmp.newFolder("no_backup") + val volumes = tmp.newFolder("storage") + val experiments = tmp.newFolder("storage", "emulated", "Projects", "Experiments") + val first = treeOf(noBackup, File(experiments, "first").apply { mkdirs() }) + val second = treeOf(noBackup, File(experiments, "nested/second").apply { mkdirs() }) + val outsideVolumes = treeOf(noBackup, File(tmp.root, "elsewhere/gone/app")) + experiments.deleteRecursively() + + assertThat(BuildOutputLocation.sweep(noBackup, volumes)).isEqualTo(2) + + assertThat(first.exists()).isFalse() + assertThat(second.exists()).isFalse() + // Off the volumes, a missing parent proves nothing, so the tree stays. + assertThat(outsideVolumes.isDirectory).isTrue() + } + + @Test + fun `a deletion that stops part-way keeps the marker for the next sweep`() { + val noBackup = tmp.newFolder("no_backup") + val project = tmp.newFolder("projects", "app") + val tree = treeOf(noBackup, project) + val locked = File(tree, "main/app-1234/outputs").apply { File(this, "app-debug.apk").writeText("apk") } + locked.setWritable(false) + try { + assertThat(BuildOutputLocation.removeTreesOf(noBackup, project)).isEqualTo(0) + + assertThat(File(tree, BuildOutputLocation.PROJECT_PATH_FILE).isFile).isTrue() + } finally { + locked.setWritable(true) + } + assertThat(BuildOutputLocation.removeTreesOf(noBackup, project)).isEqualTo(1) + assertThat(tree.exists()).isFalse() + } + + @Test + fun `the sweep copes with no trees at all`() { + assertThat(BuildOutputLocation.sweep(tmp.newFolder("no_backup"))).isEqualTo(0) + } + + private fun treeOf( + noBackup: File, + project: File, + ): File = + BuildOutputLocation.treeFor(noBackup, project).also { + assertThat(BuildOutputLocation.prepare(it, project)).isTrue() + File(it, "main/app-1234/outputs").mkdirs() + } + private fun workspace( rootBuildDir: File, vararg subprojectBuildDirs: File, diff --git a/app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt b/app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt new file mode 100644 index 0000000000..b58c2be3a4 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt @@ -0,0 +1,142 @@ +package com.itsaky.androidide.utils + +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.io.IOException + +class ApkExportTest { + @get:Rule + val tmp = TemporaryFolder() + + private val apk: File get() = + File(tmp.root, "tree/app-debug.apk").apply { + parentFile.mkdirs() + writeText("apk") + } + + @Test + fun `no listing means nothing to export`() { + assertThat(ApkExport.resolve(File(tmp.root, "out/output-metadata.json"), report(), tmp.root)) + .isEqualTo(ApkExport.Exportable.NotBuilt) + } + + @Test + fun `a malformed listing is nothing to export, not a crash`() { + val listing = File(tmp.newFolder("out"), "output-metadata.json").apply { writeText("{\"elements\": [") } + + assertThat(ApkExport.resolve(listing, report(), tmp.root)).isEqualTo(ApkExport.Exportable.NotBuilt) + } + + @Test + fun `a standard build's APK is exported`() { + val (listing, built) = built() + + assertThat(ApkExport.resolve(listing, report(), tmp.root)).isEqualTo(ApkExport.Exportable.Apk(built)) + } + + @Test + fun `Quick Build's proxy APK at the same path is refused`() { + val (listing, built) = built() + val report = report(names = built, at = built.lastModified() + 1_000) + + assertThat(ApkExport.resolve(listing, report, tmp.root)).isEqualTo(ApkExport.Exportable.QuickBuildProxy) + } + + @Test + fun `a standard build after Quick Build's makes the APK exportable again`() { + val (listing, built) = built() + val report = report(names = built, at = built.lastModified() - 1_000) + + assertThat(ApkExport.resolve(listing, report, tmp.root)).isEqualTo(ApkExport.Exportable.Apk(built)) + } + + @Test + fun `a copy that fails part-way leaves nothing under the export's name`() { + val download = tmp.newFolder("Download") + + try { + ApkExport.copyTo(apk, download, "app-debug") { _, to -> + to.writeText("half an ap") + throw IOException("disk full") + } + } catch (e: IOException) { + // expected + } + + assertThat(download.list()).isEmpty() + } + + /** A variant's output directory holding `app-debug.apk` and its listing. */ + private fun built(): Pair { + val dir = tmp.newFolder("outputs", "apk", "debug") + val apk = File(dir, "app-debug.apk").apply { writeText("apk") } + val listing = + File(dir, "output-metadata.json").apply { + writeText( + """{"version":3,"artifactType":{"type":"APK","kind":"Directory"},"applicationId":"com.example.app",""" + + """"variantName":"debug","elements":[{"type":"SINGLE","outputFile":"app-debug.apk"}],"elementType":"File"}""", + ) + } + return listing to apk + } + + /** Quick Build's report, naming [names] as its proxy APK, written at [at]; absent without [names]. */ + private fun report( + names: File? = null, + at: Long = 0, + ): File { + val report = File(tmp.root, "quickbuild/debug/setup.json") + if (names != null) { + report.parentFile.mkdirs() + report.writeText("""{"proxyAppId":"com.example.app","entryActivity":null,"apk":"${names.absolutePath}"}""") + report.setLastModified(at) + } + return report + } + + @Test + fun `the copy is named after the project and variant`() { + val download = tmp.newFolder("Download") + + val copy = ApkExport.copyTo(apk, download, "My Application-debug") + + assertThat(copy).isEqualTo(File(download, "My Application-debug.apk")) + assertThat(copy.readText()).isEqualTo("apk") + } + + @Test + fun `an earlier export is never overwritten`() { + val download = tmp.newFolder("Download") + File(download, "app-debug.apk").writeText("first") + File(download, "app-debug (2).apk").writeText("second") + + val copy = ApkExport.copyTo(apk, download, "app-debug") + + assertThat(copy.name).isEqualTo("app-debug (3).apk") + assertThat(File(download, "app-debug.apk").readText()).isEqualTo("first") + } + + @Test + fun `characters a file name cannot hold are replaced`() { + val copy = ApkExport.copyTo(apk, tmp.newFolder("Download"), "a/b:c-debug") + + assertThat(copy.name).isEqualTo("a_b_c-debug.apk") + } + + @Test + fun `a missing Download folder is created`() { + val download = File(tmp.root, "sdcard/Download") + + assertThat(ApkExport.copyTo(apk, download, "app-debug").isFile).isTrue() + } + + @Test(expected = IOException::class) + fun `a Download folder that cannot be created is reported`() { + val blocker = tmp.newFile("sdcard") + + ApkExport.copyTo(apk, File(blocker, "Download"), "app-debug") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/PluginCgpLookupTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/PluginCgpLookupTest.kt new file mode 100644 index 0000000000..6895f9a6db --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/PluginCgpLookupTest.kt @@ -0,0 +1,60 @@ +package com.itsaky.androidide.viewmodel + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.projects.api.GradleProject +import com.itsaky.androidide.projects.api.Workspace +import io.mockk.every +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * A plugin build's `.cgp` is found under the root project's build directory as the synced model + * has it. With relocated build output (ADFA-4930) that is in app storage, and `/build` + * may still hold a plugin from before the relocation. + */ +class PluginCgpLookupTest { + @get:Rule + val tmp = TemporaryFolder() + + private val project: File get() = File(tmp.root, "project") + + private val relocated: File get() = File(tmp.root, "no_backup/gradle-build/project-1234/main/root-5678") + + @Test + fun `the plugin is found under a relocated root build directory, not the project folder`() { + val stale = cgp(File(project, "build/plugin"), "old-debug.cgp") + val built = cgp(File(relocated, "plugin"), "myplugin-debug.cgp") + + assertThat(findPluginCgpFile(workspace(relocated), "debug")).isEqualTo(built) + assertThat(findPluginCgpFile(workspace(relocated), "debug")).isNotEqualTo(stale) + } + + @Test + fun `a release variant takes the plugin without the debug suffix`() { + cgp(File(relocated, "plugin"), "myplugin-debug.cgp") + val release = cgp(File(relocated, "plugin"), "myplugin.cgp") + + assertThat(findPluginCgpFile(workspace(relocated), "release")).isEqualTo(release) + } + + @Test + fun `nothing is found without a model or a plugin`() { + assertThat(findPluginCgpFile(null, "debug")).isNull() + assertThat(findPluginCgpFile(workspace(relocated), "debug")).isNull() + } + + private fun cgp( + dir: File, + name: String, + ): File = File(dir.apply { mkdirs() }, name).apply { writeText("cgp") } + + private fun workspace(rootBuildDir: File): Workspace { + val root = mockk() + every { root.buildDirPath } returns rootBuildDir.path + every { root.projectDirPath } returns project.path + return mockk().also { every { it.rootProject } returns root } + } +} diff --git a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md index f966a1091d..a50ae4798c 100644 --- a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md +++ b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md @@ -66,8 +66,8 @@ Measured 2026-10-14 with the IDE's Gradle arguments, same template project on bo ### Lifecycle -- **Project deleted in the IDE** (`RecentProjectsAdapter.kt:213`, `DeleteProjectFragment`): delete its output tree too. -- **Orphans** (project moved, renamed or deleted outside the IDE): at startup, off the main thread, remove trees whose `project-path` marker no longer exists - the same shape as `QuickBuildScratch.sweep`. +- **Project deleted in the IDE**: both deletion paths (Recents and the Delete projects screen) go through `RecentProjectsViewModel`, which deletes the project's trees too. Trees are matched by their `project-path` marker, not by key: deletion uses the canonical path and builds use `projectDirPath`, so a symlinked spelling would key differently. +- **Orphans** (project moved, renamed or deleted outside the IDE): at startup, off the main thread, remove trees whose project no longer exists. A project counts as gone when its parent folder exists, or, with the parent deleted too, when the nearest folder that exists is a readable folder on a volume under `/storage`. A volume that is not mounted leaves only `/storage` itself, so it doesn't count. Trees without a marker are left alone. Unlike `QuickBuildScratch.sweep`, which clears every tree, this keeps the output of live projects. - **Existing `build/` folders in projects**: leave them. They go stale but do no harm. A later "free space" action can offer to remove them. - **Storage**: output now counts as app data in Settings, and "Clear storage" removes it (the next build is a full one). Uninstalling removes it as well; sources are unaffected. @@ -75,7 +75,7 @@ Measured 2026-10-14 with the IDE's Gradle arguments, same template project on bo These are the tradeoffs from the original analysis, with how this plan handles each: -- **The APK is no longer in the project folder**, file manager or MTP. Add an **Export APK** action that copies the built APK to `Download/` or opens the share sheet. The survey found no existing export or share action. +- **The APK is no longer in the project folder**, file manager or MTP. Add an **Export APK** toolbar action, shown while relocation is available, that offers the share sheet or a copy in `Download/` (`[project]-[variant].apk`, numbered when taken). The survey found no existing export action. - **Build output no longer travels with a copied project folder.** That's acceptable: it can be rebuilt, and copying `build/` is rarely wanted. - **Terminal builds** (`./gradlew` in the IDE's terminal) don't get the redirect, so they still build into the project folder, separately from the IDE's tree. Document this. Changing it is a follow-up if users trip over it. diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index 202c545561..5d58809319 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -225,6 +225,7 @@ object TooltipTag { const val EDITOR_TOOLBAR_FIND_IN_FILE = "editor.find.in.file" const val EDITOR_TOOLBAR_SYNC = "project.sync" const val EDITOR_TOOLBAR_LAUNCH_APP = "project.launch.app" + const val EDITOR_TOOLBAR_EXPORT_APK = "project.export.apk" const val EDITOR_TOOLBAR_UNDO = "editor.undo" const val EDITOR_TOOLBAR_REDO = "editor.redo" const val EDITOR_TOOLBAR_QUICK_SAVE = "project.save" diff --git a/quickbuild/docs/debugging.md b/quickbuild/docs/debugging.md index ae375fdf40..82d36b0a6f 100644 --- a/quickbuild/docs/debugging.md +++ b/quickbuild/docs/debugging.md @@ -184,7 +184,8 @@ to CoGo. | `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 | | `/.androidide/quickbuild/generation` | the generation counter | plain `adb shell cat` | -| `//build/quickbuild/setup.json` | the proxy app build's handshake with CoGo | plain `adb shell cat` | +| `//build/quickbuild//setup.json` | the proxy app build's handshake with CoGo | plain `adb shell cat` | +| `no_backup/gradle-build/-<16 hex>/main/-<16 hex>/quickbuild//setup.json` | the same, when "Relocate build output" is on (ADFA-4930) | `run-as com.itsaky.androidide` | | `/data/data//files/quickbuild/payload/` | persisted payload: `payload.dex`, `resources.arsc`, `assets.zip`, `meta.json` | `run-as ` | Projects live under `/storage/emulated/0/CodeOnTheGoProjects/`. diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index bfd9f60bb6..aaf7c23446 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -879,6 +879,14 @@ Cannot run application. Unable to determine package name. Cannot run application. No application modules found in project. Launch app + Export APK + Share + Copy to Download + No APK yet. Run the app once to build it. + Saved to Download as %1$s + Could not copy the APK to Download + The latest APK is Quick Build\'s live-reload app. Run the app once to build a regular APK, then export it. + A build is running. Export the APK when it finishes. Experimental Experiments on You are using %1$s variant of Code On The Go on a %2$s device. This configuration may work, but it\'ll result in reduced performance and possible security vulnerabilities. Please avoid using this configuration whenever possible.