Repository navigation
ADFA-4930: find Quick Build and plugin output via the project model - #2150
Conversation
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
davidschachterADFA
left a comment
There was a problem hiding this comment.
IMPORTANT
- GradleQuickBuildProvisioner.kt:465 - a failed or cancelled sync leaves the model's build dir disagreeing with the builds' (defect in #2149)
MINOR
- BuildViewModel.kt:158 - the .cgp lookup change has no test pinning it
NITPICK - 2 inline, not listed
Verdict: computed REQUEST_CHANGES (one confirmed IMPORTANT). Posted as COMMENT because GitHub refuses REQUEST_CHANGES on your own PR. REVIEW.md has no approve/request-changes rule (it calls itself a coaching doc), so the pr-review default table applied; CLAUDE.md ties the move to QA to no outstanding critical, high or medium findings.
Stack: layer 2 of 2. #2149 (base stage) <- #2150 (this PR, the tip). Every claim was checked at the tip, d29e9c8. The IMPORTANT's real site is in #2149; it is anchored here because this PR's lookups depend on it.
Earlier rounds: the only prior review is the claude[bot] manual-review notice, with no findings, so there was nothing to re-check.
Evidence
- Ticket (plan, PR 2):
setup.jsonand.cgplookups from the model, with tests for both.setup.json: done, two new tests (the PR body says both fail against the old lookup; not re-run here)..cgp: done, no test (MINOR inline). - Section 1, exceptions: no new throw paths. A missing workspace ends in
BuildState.Error, not a crash. - Section 3, threading: the
.cgplookup runs onDispatchers.IO; thesetup.jsoncheck runs where the old one did. - Section 5, coverage: not measured; this review did not build or run tests.
- Section 7, sweep:
git grepat the tip finds no other IDE code that builds a<project>/buildpath.buildDirPathis absolute (tooling-api-impl/.../serial/GradleProjectExts.kt:20). - Section 13, plugins: no plugin-API surface touched.
.cgpformat unchanged;PluginBuilderalready writes underlayout.buildDirectory. - Sections 8-9: no UI change.
Leads checked and not posted
- A stale
setup.jsonleft in the same build dir after a variant stops being debuggable is read as current. Pre-existing: the old lookup also only checkedisFile. - No fallback when the workspace is null. Not reachable in practice (a plugin build starts from the model), and a
<project>/buildfallback would reintroduce the stale read this PR removes. - The
.cgperror no longer namesbuild/plugin. A judgment call: the path is now app-private. docs/PLUGIN_AUTHORING.mdstill givesmy-plugin/build/plugin/*.cgp. Still true: it describes./gradlewbuilds from the repo, which do not run the IDE's init script.ProxyAppInfo.kt:14KDoc saysbuild/quickbuild/setup.json. Therebuild/means the build directory; the PR body already notes these KDocs.
Quick Build's setup.json and a plugin project's .cgp were looked up under `<project>/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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7
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 `<project>/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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7
d29e9c8 to
b0648b8
Compare
…xport 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 <noreply@anthropic.com> 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: `<project>-<variant>.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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
adeeb20
into
perf/ADFA-4930-fuse-storage
#2150 and #2151 reached #2149's branch as squash commits, while this branch carries the same changes as the original commits. The tree is unchanged: this merge only makes #2149's new head an ancestor, so #2155 merges cleanly into it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7
…ents) (#2149) * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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/<projectKey>/ 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 `<build>/<name>-<hash>`: 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 `<project>/.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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 `<project>/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 <noreply@anthropic.com> 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 `<project>/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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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: `<project>-<variant>.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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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/<projectKey>/ 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 `<build>/<name>-<hash>`: 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 `<project>/.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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 `<project>/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 <noreply@anthropic.com> 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 `<project>/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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> 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: `<project>-<variant>.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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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/<variant>/ 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * ADFA-4930: take old build/ folders only from relocated projects Hal Eisen's review of #2155: OldBuildOutput.find made <dir>/build a candidate for every project in the model. A subproject whose build script sets its own build directory (layout.buildDirectory = file("out")) keeps <sub>/out in the model, so inUse protected only that, and a user's own <sub>/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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 * 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 `<project>/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 <noreply@anthropic.com> 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 `<project>/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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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: `<project>-<variant>.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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7 --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ADFA-4930
PR 2 of 4 in the stack from
docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md, based on #2149. Merge #2149 first; this PR's diff is two commits.Why. With "Relocate build output" on (#2149), Quick Build's
setup.jsonand a plugin project's.cgpare no longer under<project>/build, which is where the IDE looked for them. That folder holds either nothing, so Quick Build fails with "variant setup missing" and plugin builds with ".cgpnot found", or a stale report from before the relocation, which Quick Build would then read as current.What it does. Both lookups resolve against the build directory in the synced project model: the module's for
setup.json, the root project's for the.cgp. The model and the output agree because, since #2149, builds follow the synced model rather than the live switch.QuickBuildTaskPaths.setupJson(buildDir, variant)returns the report under the build directory it is given. The provisioner passesmodule.buildDirand drops its guess between<module>/buildand<project>/build.findPluginCgpFile(workspace, variant)looks for the.cgpunderworkspace.rootProject.buildDir/plugin.quickbuild/docs/debugging.mdlists the relocatedsetup.jsonlocation.Verified
GradleQuickBuildProvisioner*suites,QuickBuildTaskPathsTest,PluginCgpLookupTestand theBuildViewModel*suites, 68/68. The newGradleQuickBuildProvisionerBuildDirTestfails against the old lookup on both cases: a report only under a relocated build directory (provisioning fails), and a stale report left in the project folder (the stale one is read).PluginCgpLookupTestfails against the old<project>/build/pluginlookup the same two ways.setup.jsoninapp/buildfrom an earlier, unrelocated build reachedReadyand launched the proxy app. It read the tree's report, written at 16:27, not the project folder's from 15:57.My Plugin1(single-module plugin project): the build putmyplugin1-debug.cgpunder the tree'smain/root-<hash>/plugin/, the project had nobuild/folder, and the IDE offered "Install Plugin".Readyreading the tree'ssetup.json, not a stale project-folder copy, and a new plugin project's.cgpwas found in app storage with nobuild/in the project."build/paths finds only these two lookups. The Gradle plugin's KDocs still saybuild/quickbuild/<variant>/setup.json, meaning the project's build directory, and are left as is.Not in this PR. Deleting output with its project, the orphan sweep and Export APK are PR 3. Turning relocation on by default is PR 4.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VKUthHVokuWKgP3ACLBii7