Repository navigation
ADFA-4128 (11/11): app + bench — wiring Quick Build into the IDE and the benchmark harness - #1723
Conversation
5b48f90 to
c69d8ef
Compare
c69d8ef to
5a3d5eb
Compare
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.
5a3d5eb to
ac3ab4e
Compare
ac3ab4e to
a45a359
Compare
|
@coderabbitai review |
|
a45a359 to
7c72105
Compare
3e8d9d2 to
6254159
Compare
jatezzz
left a comment
There was a problem hiding this comment.
@fryanpan — review of the Quick Build app wiring. Seven findings; two are worth fixing before merge and are left as inline comments:
ProjectHandlerActivity.kt:549— thereArmInstallsafety net does not cover the clobber dialog, so a rotation while it is up loses the install silently.QuickBuildStatusBar.kt:151— a landed build is re-announced on every re-subscribe and, withonlyIfOwned = false, stomps project-init / plugin-install status.
The remaining five are low: the session teardown on a transient APK parse failure, the null-activity path that builds stale content, the ellipsized actionable status copy, and three unused imports that should fail spotlessCheck.
One more (low), which could not be left inline because the file is not in this diff:
app/src/main/java/com/itsaky/androidide/actions/BaseBuildAction.kt:43 — a missed sibling of this PR's raw-vs-user-visible split.
This PR moved every UI decider over to isUserVisibleBuildInProgress — AbstractCancellableRunAction, ProjectHandlerActivity.onResume, the progress bar, BuildVariantsFragment — but BaseBuildAction.prepare still reads the raw flag:
enabled = buildService?.let { !it.isBuildInProgress } == trueWith the experiments flag on, Quick Build's eager prebuild now runs on every project open, so RunTasksAction (and any other direct BaseBuildAction) is silently greyed out for its whole duration with no explanation — unlike QuickBuildAction, which relabels, or QuickRunAction, which flashes msg_build_slot_busy. Note that AbstractCancellableRunAction.prepare unconditionally re-sets enabled = true, which is why its new slot-busy flash is reachable and these are not.
Checked and cleared: all new string/drawable/menu resources resolve on the head branch; ProjectManagerImpl.generateSources() returns Boolean, so GenerateSourcesDeferral's refusal-retry contract holds and a throw is treated as a refusal rather than cancelling the scope; InternalBuildBracket releases strictly after isBuildInProgress clears, so there is no window where isUserVisibleBuildInProgress reads true for an internal build; QuickBuildOutputNarrator confines its mutable state to one Dispatchers.Main.immediate scope and compares sink identity correctly; QuickBuildReloadTimingMetric.asBundle() is 25 params worst case, within the Firebase cap; ThermalSafeStrategy copies GradleDaemonConfig, so the new non-defaulted daemonIdleTimeoutMs is safe and 2h fits in Int; and InstallationResultHandler.onResult returning null is handled as "do nothing" by its only caller.
6254159 to
7e90fff
Compare
7e90fff to
1f82366
Compare
withInternalBuild kept the listener in a single field and nulled it in its finally, while the bracket around it counts depth. A nested internal build therefore blinded the outer one for the rest of its run. The listener now lives on the bracket, saved on entry and restored on exit. Unreachable today (one caller, behind its own isBuildInProgress check). Test: InternalBuildBracketTest "a nested hold hands the outer build its progress listener back", watched red with the null restored (expected specific instance: ...outer / but was: null). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153YfwDnqn7ktHcVgXSNNe8
The post-save resource check re-read frag.file off the main thread. That goes through the view binding, which a tab closed after the write has released, so the flag came back false and a layout save left the Java LSP with stale R symbols. savedFile was captured on Main for exactly this. No JVM test: activity code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153YfwDnqn7ktHcVgXSNNe8
…ollows The reset ran in onPause beside restartSession(), before the teardown narrated anything and while the pane was still bound, so it cleared an empty queue; the lines that arrived after onDestroy unbound the pane queued for the next project. The reset now runs from the same lifecycle observer, after the unbind, when the project is closing - and until the next pane binds the narrator drops lines instead of queueing them, since the teardown narrates asynchronously after the reset. Test: QuickBuildOutputNarratorTest "lines narrated after a reset with no pane are dropped", watched red with the discard removed (expected to be empty / but was: [Quick Build: :app:mergeV8DebugResources]). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153YfwDnqn7ktHcVgXSNNe8
… APK With no proxy app installed the tap settled NotNeeded. When the built APK then failed to parse the install-time check came back NeededForUnknownAppId and the unknown-occupant dialog was shown after a successful build; a decline dropped the install with no message. NotNeeded at the tap now covers an unknown id at install time. Test: QuickBuildClobberConfirmationTest "an empty slot at tap time is not widened by an APK that fails to parse", watched red with the branch removed (expected: NotNeeded / but was: NeededForUnknownAppId). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153YfwDnqn7ktHcVgXSNNe8
The activity's fire body only launched a coroutine, so the try/catch the stagger wrapped around fire() saw nothing and the await/onProjectSynced work ran as a bare child of the editor scope - a plain Job with no handler, where a throw cancels the scope and reaches the uncaught handler. fire is now a suspend function the stagger launches on its scope, on both arms, inside the catch. Test: QuickBuildPrebuildStaggerTest "an immediate prebuild that throws does not take the scope down with it", watched red with the guard removed from the immediate arm (IllegalStateException: onProjectSynced blew up). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153YfwDnqn7ktHcVgXSNNe8
The one call site still writing the "PROJECT_PATH" literal is the one the same-project autostart guard reads back through EditorIntentExtras.EXTRA_PROJECT_PATH. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153YfwDnqn7ktHcVgXSNNe8
trackFeatureUsed sat above the save-all and the clobber confirmation, so a failed save and a declined clobber both emitted a quick_build use with no build behind them. It now runs next to onQuickBuildTapped. No JVM test: the action needs an activity. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153YfwDnqn7ktHcVgXSNNe8
The class doc claimed the tuner applies to every Gradle build and was deliberately not gated behind the Experiments flag. Its only caller, GradleBuildService.prepareBuild, calls it inside `if (FeatureFlags.isExperimentsEnabled)` and otherwise passes Gradle only the IDE's extra arguments - so with the switch off Gradle runs on its own defaults and neither the Metaspace floor nor the daemon idle timeouts apply. Row 20 of the review round chose to fix the sentence rather than the gate; this is that sentence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153YfwDnqn7ktHcVgXSNNe8
The dialog offers Restart as the one action that replaces a proxy app which will not stay open, but during a standard Gradle build that restart tears the warm session down and the reprovision behind it is refused as slot-busy - the user loses the session and is told to wait. Same QuickBuildAction.isBlockedByStandardBuild() gate the toolbar button and the Restart session row (d28cf1e6c) already use; Dismiss stays live and the notice is raised again if the streak continues. No JVM test: the dialog is built inside the activity, as with d28cf1e6c. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153YfwDnqn7ktHcVgXSNNe8
Closing a project cleared the narrator's queue and set discardUntilBound, which bind() cleared. The next project's activity binds its Build Output pane in onCreate, while the closing session's Gradle cancel is still fire-and-forget (QuickBuildSessionManager.teardown -> GradleQuickBuildProvisioner.cancelProxyAppBuild) and the build's progress listener keeps firing - so late "Quick Build:" lines from the dying project were written into the new project's pane, attributed to a build it never ran. reset() now takes an awaitable for the closing session's teardown, and lines are dropped until that returns whether or not a pane has bound in the meantime. QuickBuildSessionManager.awaitTeardown() exposes the existing teardownWork join; it hops onto the session dispatcher, so called after restartSession() it lands behind that teardown rather than reading the previous one. The wait is capped at 30s so a teardown that never reports quiet cannot silence the pane for the rest of the process, and the call is off the UI thread on the narrator's own scope. The new project's own narration is dropped inside that window too. The window ends at the daemon shutdown plus the scratch-tree removal, and the new project's eager prebuild is staggered well past it, so in practice nothing of the new session lands in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153YfwDnqn7ktHcVgXSNNe8
…er tests Restack fallout from the PendingAsk work on the core branch (0e8d15a78): a user-initiated QuickBuildTapped now carries SessionEffect.RecordAsk alongside whatever it starts. Both tests here assert a reducer transition directly, so they saw the extra effect. Their subject is unchanged - one pins that a tap in the stagger window provisions in the same transition rather than queueing, the other that a tap mid-prebuild starts no build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153YfwDnqn7ktHcVgXSNNe8
The drop reset() arms was global: every line was dropped until the closing project's teardown reported quiet, or for 30 s. But the next project's activity binds its pane in onCreate, so a Quick Build tapped in the project the user just opened narrated "running the initial full build", its Gradle task lines and its timings into nothing - for as long as a daemon shutdown and a scratch-tree removal take. The leak that drop exists to stop is a specific one: the closing project's proxy app build is cancelled fire-and-forget, so its Gradle progress listener keeps firing - and its failure is quoted - after the new pane is bound. That is now decided by naming the build rather than by timing it. A build takes a ProxyAppBuildNarration before it starts; reset() retires every handle taken before it, so the closed project's build is silenced for as long as it keeps talking, while the build the project on screen started narrates throughout. Status and timing lines carry no build of their own, so they keep the drop - but it now also ends when a session announces its own full build. That stream is ordered, so everything the closed session had left to say arrived before that announcement, and the project on screen is never muted by it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015WMhaGYg4sSCcAzQEdNtLa
…the show The button's enabled state was sampled once, at show(). The dialog outlives that reading, so the gate was wrong in both directions: raised during a standard Gradle build it kept a dead Restart for the whole life of the dialog - the build finishing never re-enabled it - and raised before one it kept a live Restart that tore the warm session down into a reprovision the build then refused as slot-busy. A device pass on 2026-09-10 hit the second case 5 times out of 5. Worse, a disabled dialog button says nothing about why. The toolbar button at least carries "Standard build in progress" as its content description; this had no such channel, while the message body went on promising restart as the remedy. So the gate is read on every tap. A tap that cannot act flashes the same "Standard build in progress" string and leaves the notice up, so the user can tap it again when the build ends - the only other route back to it is another Quick Build failure streak. The rule lives in QuickBuildWontStayUpRestart, which is JVM-testable; the activity keeps the wiring, and sets the listener on the button rather than through setPositiveButton, since Material dismisses before the builder's listener runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015WMhaGYg4sSCcAzQEdNtLa
Material's alertDialogStyle defaults backgroundInsetTop and backgroundInsetBottom to 80dp each. On the A56 (1080x2340, density 450) that is 450px of the 2340 spent on margin, leaving the panel 1654px for content that needs about 1720px at 2x font scale -- so the message and the "Not now" button are clipped. 24dp each returns 224px to the panel, which clears the shortfall with room left. Verified in the built APK with aapt2: the style resolves and alertDialogStyle points at it. WIP: kept as its own commit so the stack can be restacked without carrying an uncommitted worktree. Still open with Bryan: whether this lands app-wide or stays contained to the dialogs that clip (39 of 57 alert dialogs inherit this theme). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HZEZKHy8dkDGJM3nUut6hJ
…rmed classes :app registered no JaCoCo report task, so every coverage number for this module has come from a one-off invocation that left nothing behind to re-run. The obvious way to write one is also wrong here, and wrong silently: :app runs transformV8DebugClassesWithAsm, so a report pointed at tmp/kotlin-classes -- correct for the quickbuild modules, which run no transform -- makes 138 classes report "Execution data ... does not match" and counts them as fully uncovered. That renders as a coverage hole rather than as an error. Both traps are recorded in the comment above the task rather than in a doc, because the doc is what was lost the last time this was worked out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HZEZKHy8dkDGJM3nUut6hJ
Nineteen tests over the six smallest untested surfaces in this PR. Each was watched red against a mutation of the production code it names, and each mutation was proven to apply exactly once and to compile before its result was read -- a mutant yielding invalid Kotlin prints no result line, and one whose edit did not apply prints a green one, and both read as "not caught". What each pins, and the bug it would catch: - QuickBuildOutputMetricsSink: only onReloadTimeline reaches Build Output; the other five callbacks are deliberately Unit. Catches a later hand wiring one of them to the narrator. - PreferencesQuickBuildHistoryStore: the key is namespaced by project path, so the first-run explanation follows the project. Catches an un-namespaced key, and a blank path that reads false but still writes. - EnvironmentQuickBuildPaths.d8Jar: catches the daemon-staged fallback being never or always taken. - AndroidProxyAppLauncher.launch: prefers the launcher intent over an explicit component, which is the regression that left the app dead in 2 of 8 restart deploys. The NEW_TASK assertion sits on the fallback intent, not the launcher one -- getLaunchIntentForPackage sets that flag itself, so asserting it on the launcher path stays green with the production line deleted. - ApkSigningCert: the SHA-256 both sides of the clobber refusal compare. Catches taking the oldest rotation entry instead of the newest. This check fails open, so a wrong answer silently permits an install over a third-party app. - InstallationEventFlow.onInstallationResult: the arms are order-dependent (STATUS_FAILURE_ABORTED is 3, STATUS_FAILURE is 1), so reordering them tells a user who cancelled that the install is broken. No production code changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HZEZKHy8dkDGJM3nUut6hJ
postExec read SaveResult.resourceXmlSaved and .gradleSaved inline, so the routing could only be exercised through an EditorActivity, a real GenerateSourcesDeferral and a view model - to observe a two-boolean decision. saveFollowUpFor is that decision as a pure function, following the precedent of accumulateSaveFlags in SaveResultFlags.kt. The call site now only performs the follow-ups; the flag rationale moved with the decision it explains rather than being duplicated. No behaviour change: same calls, same order, same conditions. Five tests, each watched red. Reading xmlSaved in place of resourceXmlSaved - the "every XML save triggers a Gradle run" regression this code exists to prevent - reds "a non-resource xml save asks for nothing" with `value of: getRegenerateSources() / expected to be false`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HZEZKHy8dkDGJM3nUut6hJ
The predicate is (isPluginProject, applicationId) -> confirm or not, but it sat between viewModels(), requireActivity() and runQuickBuild, so the one direction that matters could not be tested: an APK-producing Run skipping the confirm that stops an install replacing a third-party app. The blank-id normalisation came with it. Left inline, "did not resolve" would still be untestable, and it is part of the same decision - an unnameable occupant is still an occupant. No behaviour change: same two calls, same arguments, same order. The early return became the last statement of a when branch. applicationId is now read on the plugin path too; resolvedVariant is already resolved at the top of the function and mainArtifact is non-null, so this is a property read with no side effect. Three tests, each watched red. Inverting the predicate reds both directions at once, including `expected: AskFirst(applicationId=com.example.app) / but was: SkipConfirm`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HZEZKHy8dkDGJM3nUut6hJ
…ck arms No production change - the file is byte-identical to its parent. All four recovery arms and the live-session path are reachable through setSessionState plus a mocked Context, so nothing needed extracting. Six tests, each watched red, each asserting the resulting state and not only the return value. That matters for the stale-sessionId defect: deleting the Idle fall-back from the session == null branch still returns -1, and only the state shows the bug - `expected: Idle / but was: InProgress(sessionId=7, progress=40)`, the install indicator the user cannot dismiss. Robolectric because the view model's SingleSessionCallback field needs a real PackageInstaller.SessionCallback constructor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HZEZKHy8dkDGJM3nUut6hJ
…and baseline stamping GradleQuickBuildProvisioner reached its two collaborators through process-wide statics - Lookup.getDefault().lookup(KEY_BUILD_SERVICE) and IProjectManager.getInstance() - so none of its refusal paths could be driven without a live tooling server. Both become constructor parameters with defaults, appended after stage, so every existing call site compiles unchanged. Each default lambda body is byte-identical to the expression it replaced. quickBuildModule now takes the manager it should ask rather than fetching its own; in production that is the same object, since getInstance memoizes into a static field (IProjectManager.kt:44-51). 24 tests, 24 of 24 watched red for their named reason, 8 of 8 failure arms pinned. The stampBaseline test is written so that adding a ProxyAppBuildPurpose value without deciding its stamping behaviour fails rather than defaulting. The distinctness test - that no two refusals read alike - was mutated in its real subject: duplicating one <string> in resources/.../strings.xml, the copy-paste it exists to catch, reds it with `expected: 8 / but was: 7` while the other 12 tests in the class stay green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HZEZKHy8dkDGJM3nUut6hJ
…hange The Eclipse WTP XML formatter reflows the comment 12bc4b2 added to MaterialDialog.Insets. Wording unchanged; only the line breaks and the continuation indent move. Standalone so it stays out of the behavioural commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HZEZKHy8dkDGJM3nUut6hJ
…awaitTeardown CodeRabbit round 8 on #1723 asked for the three untested arms of the close-project mute: no production change, three tests. - QuickBuildOutputNarrator.reset: a teardown that never reports quiet stops muting after QUIET_TIMEOUT_MS; without the cap a hung teardown would silence Build Output for the rest of the process. The `narrating` helper now runs its body with the TestScope receiver so a test can move virtual time. - Two resets in quick succession: the first teardown completing must not lift the mute the second reset is guarding, hence the token identity check in the finally. - QuickBuildSessionManager.awaitTeardown returns only once the restarted session's teardown job has finished, not merely once the hop onto the session dispatcher has run. Mutation proofs: without withTimeoutOrNull the cap test fails `expected to contain: generation 3 / but was: ""`; with the finally clearing the mute unconditionally the token test fails `expected to be empty / but was: [Quick Build: generation 2 - ...]`; with awaitTeardown not joining teardownWork the manager test fails `expected to be false`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xc9hCQm29w7tLSUVnKbCjd
…on to abandon ApkInstallationViewModel.destroy returned as soon as reloadStatus reported no live session, before reaching unregisterSessionCallback. That is the common exit: the install has finished, so the session is gone, but the callback installApk registered is still held by the process-wide PackageInstaller and keeps firing into a view model that no longer exists. The unregister now runs first, on its own, and the session lookup and abandon follow as before. Mutation proof: with the old ordering the new test fails `Verification failed: call 1 of 1: PackageInstaller(#11).unregisterSessionCallback(any())) was not called`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xc9hCQm29w7tLSUVnKbCjd
The long-press dropdown row is one line, and "Standard build in progress" was cut to "Standard build in pro.." at 2x font scale on the A56. The row reads the action label, so the blocked label now reuses status_building. The content description and the blocked-tap flash keep the full reason. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xc9hCQm29w7tLSUVnKbCjd
… res dirs AndroidModule.getResourceDirectories() only listed src/main/res, so a resource saved under src/debug/res or src/<flavor>/res never counted as an Android resource and the save skipped the generate-sources build that refreshes R. The tooling side now serialises the source providers AGP layers over main for each variant (build type, product flavors, multi-flavor and the variant's own provider) into AndroidVariant.variantSourceProviders, and the module adds the selected variant's resDirs to the set. Only the selected variant's directories count: the other build type's res is not part of the build the user runs. The ProjectManagerImpl.androidBuildVariants setter opens to the module's tests so a test can select a variant without running a full project setup; a test on the resource-id split alone cannot tell two ids that carry the same text apart, so QuickBuildStatusStringsTest pins that the deploy-failed and build-failed strings differ. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDTsNnpDP3sCWUYtmrUY1D
BaseBuildAction.kt and RunTasksAction.kt were space-indented, and the next commit edits both, which enrols them in the Spotless ratchet. This is spotlessApply's output alone (tabs, ktlint wrapping), kept apart so the behavioural change reads on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XN91S41dghWHoXAoWhJAnm
BaseBuildAction.prepare() greyed Run tasks and Sync project out whenever
any build held the one Gradle slot, including Quick Build's eager prebuild
after every project open. The user had started nothing, so the disabled
icons gave no reason, and swapping in the user-visible flag alone would let
a tap reach the service and surface BuildInProgressException as a raw error.
prepare() now reads isUserVisibleBuildInProgress, so a build the user
started still greys both out, and a tap during an internal build is refused
by refuseWhileSlotBusy(), which reads the raw isBuildInProgress and flashes
why. AbstractCancellableRunAction's inline copy of that check now calls the
shared helper.
The message is generic ("Quick Build is setting up the app. Try again once
it finishes.") and deliberately does not offer cancellation: during the
eager prebuild the Quick Build button stays in the READY tone with no stop
affordance, so a "tap stop to cancel" sentence would be false.
Verified on the A56 (SM-A566B, Android 16): both actions stay enabled during
a 7-40 s prebuild, each tap flashes the message and starts nothing, and a
user-started sync still greys both out. Checked at font scale 1.0 and 2.0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XN91S41dghWHoXAoWhJAnm
Both were found by a full 25-case run on an A56 on 2026-09-18, and both had the run record a FAIL against working code. T7 criterion 4 asked the rebaseline to relaunch the app. It does not, by design: ProxyAppBuildRunner.rebuildProxyApp relaunches only when a user ask is outstanding, so a save-triggered rebuild cannot pull the user out of the editor. The case's steps are a save plus a reinstall approval, so there is never an ask. Criterion 5 rested on the same assumption. Both now describe what happens, and a note names the gate so the next reader does not re-derive this. T23 criterion 2 asked for Manifest.permission.MY_PERM to resolve after a manifest save. AGP 9.3.1 emits no generated Manifest class at all - measured, no Manifest.* anywhere under app/build - so the step failed for a reason unrelated to the save path. The step is dropped and the lost coverage is recorded rather than papered over: the editor-side symptom needs a proxy that works on AGP 9, and until then the case checks only that the Gradle step runs, and is skipped, in the right cases. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XN91S41dghWHoXAoWhJAnm
149617ff17 recorded the cause as "AGP 9.3.1 emits no generated Manifest class at all". The observation was right and the cause was wrong, which would have sent the next reader looking for an AGP 9 regression that does not exist. Measured on the host against bare projects with no CoGo involved: it is android.generateManifestClass, off by default. AGP 9.3.1 emits no Manifest.* under app/build over 70 files searched, and AGP 8.8.2 emits none either, so T23 could not have passed under either plugin version. Setting the flag true on the same project runs :app:generateDebugManifestClass and produces a Manifest.jar holding Manifest.class and Manifest$permission.class - which is also the positive control proving the search could find what it reported absent. That turns a dead criterion into a choice: set the flag in the fixture, or pick a proxy that does not depend on it. The note now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XN91S41dghWHoXAoWhJAnm
Bryan's call, on the recommendation left in the rerun report: drop it now. 549c8fe600 corrected the cause but kept fifteen lines arguing about it, plus two routes back that nobody had chosen. The criterion had already been removed from Expected, so all that remained was the apology. One sentence says what a later reader needs: the step asked for a Manifest class that AGP does not generate unless android.generateManifestClass=true, off by default on 9.3.1 and 8.8.2 alike, so it could never have passed. T23 is now a two-criterion case, and both already pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XN91S41dghWHoXAoWhJAnm
ADFA-4128
Part 11/11 of the stacked split of #1669 (requested by Akash). Base: feature/ADFA-4128-qb-10-gradle-plugin. Stack overview + review mechanics: PR 1 (#1713). Terms are defined in quickbuild/README.md (lands in PR 1).
Puts Quick Build in front of the user: a button next to Run, and enough narration to tell what it is doing and when it has finished. It also adds a harness to make it easier to run standard Gradle build and Quick Build benchmarks, and to gather key metrics about the stages of the build process.
flowchart TB subgraph appc["<b>This PR: inside app/ — wiring and bench</b>"] act["QuickBuildAction<br/>registered only when<br/>FeatureFlags.isExperimentsEnabled<br/><i>QuickBuildAction.kt</i>"] --> mgr["QuickBuildManager<br/>session lifecycle, provisioning,<br/>stop-tap cancellation"] mgr --> narr["QuickBuildOutputNarrator<br/>attached to the session manager;<br/>queues while no pane is bound<br/><i>QuickBuildOutputNarrator.kt</i>"] mgr --> sb["status bar collector<br/>lifecycle-scoped: state, not history<br/><i>QuickBuildStatusBar.kt</i>"] koin["QuickBuildModule (Koin)<br/>binds every core port;<br/>assetsLiveReloadable read once<br/>at the Android edge<br/><i>QuickBuildModule.kt</i>"] tr["bench trampoline activity<br/>debug-source-set manifest only<br/><i>QuickBuildBenchActivity.kt</i>"] --> mgr mgr --> hooks["QuickBuildBenchHooks<br/>inert release twin<br/><i>debug/QuickBuildBenchHooks.kt</i>"] hooks --> rec["event + metrics recorders"] rec --> log["bench-events.jsonl<br/><i>BenchEventsFile.kt</i>"] hooks --> e2e["MODE_STANDARD<br/>autostarts the standard Run and<br/>stops at the build result<br/><i>QuickBuildBenchAutostart.kt</i>"] end adb["adb shell am start<br/>gated on android.permission.DUMP"] --> tr mgr --> core[":quickbuild:core session manager (PRs 5-8)"] narr --> pane["Build Output pane (existing)"] sb --> bar["bottom status bar (existing)"] mgr -- "provisioning + rebuild builds" --> gbs["GradleBuildService (existing)"] classDef thisPrBox fill:#dbeafe,stroke:#93c5fd,color:#1e3a5f classDef inPr fill:#ffffff,stroke:#64748b,color:#000 class appc thisPrBox class act,mgr,narr,sb,koin,tr,hooks,rec,log,e2e inPrWhat to review
QuickBuildAction.kt— owns the session tap: start, stop-tap cancel, grey-out. Line-by-line.Gradle tuning: Metaspace 192 to 384 MB and daemon idle timeouts, Experiments flag on only.
QuickBuildOutputNarrator.kt,QuickBuildStatusBar.kt— queued narration; lifecycle-scoped status showing state, not history.QuickBuildModule.kt— binds every core port; reads assetsLiveReloadable at the Android edge.GenerateSourcesDeferral.kt— defers resource-XML generateSources until Quick Build goes idle.android.proto,AndroidProjectExts.kt,AndroidModule.kt— the selected variant's res dirs (src/debug/res,src/<flavor>/res) count as resources: a newvariantSourceProvidersfield on the variant model, filled by the tooling serializer. An upgraded install needs one project sync before the field is populated.John's items C15, C16, C17, C22, C23 folded in as fixes.
Rollback: without the flag there is no UI entry point.
Followup, not fixed: R8 emits kotlin.Metadata warning noise.
Release APK: the Quick Build daemon zip (62.0 MB) and runtime AAR (0.1 MB) are staged into every variant's assets, stored uncompressed, so the release APK grows by 62.1 MB. Not gated to debug because
FeatureFlags.isExperimentsEnabledis a runtime preference available in release; once the flag graduates the payload is the feature's cost, until the work to merge the Quick Build tools with the ones CoGo already ships is done.resources/src/main/res/values/styles.xml—MaterialDialog.Insetscuts the Material alert dialog's vertical insets to 24dp, so a tall dialog stops clipping its buttons against the navigation bar. 39 of 57 alert dialogs inherit this theme; it lands app-wide, checked on five inherited dialogs at font scale 1.0 and 2.0 (see How this PR Was Tested).Dialogs and the toolbar dropdown are Views, not composables, because they live in the existing View-based editor toolbar ADR 0009 keeps as-is, and the Help item routes into the 3-tier help that has no Compose entry point until ADFA-4381.
QuickBuildBenchAutostart.kt— MODE_STANDARD autostarts the standard Run and stops at the build result, so it measures the build only. Line-by-line.The standard arm's install dialog is suppressed, because an unattended run cannot answer it. Quick Build's arm measures build, deploy and reload, so the two are not like for like from in-app numbers alone.
QuickBuildBenchActivity.kt,QuickBuildBenchHooks.kt— DUMP-gated trampoline; inert release twin.BenchQuickBuildMetricsSink.kt—relaunchOkis recorded only when a relaunch was attempted, andtoRunningMillisonly on a relaunch that reconnected, so a skipped relaunch is absent rather than a measured failure or zero.How this PR Was Tested
Head
7715c40548, which is also the stack tip. All six modules' JaCoCo reports were regenerated here on 2026-09-25 and each was checked against the run's start timestamp: 6 fresh, 0 stale, 0 absent. The three modules this PR touches are below.Restacked onto
stagec263653bcfon 2026-09-24; head now7715c40548, which is also the stack tip. Re-verified at that tip on 2026-09-25, after the restack pulled roughly 6,970 lines of stage work under the stack:spotlessCheckgreen (34 of 34 tasks executed,--rerun-tasks) and:app:assembleV8Debuggreen (254.1 MB APK). The A56 walk ran on 2026-09-24/25 atf6ef914653, that tip plus ADFA-4931's 12 commits: 25 of 25 cases, 24 pass, 0 fail, 1 blocked (T19, Compose — no project on the device configures offline, so Quick Build is never reached). The base isorigin/stage's current head, so there is no stage drift. All six unit suites were run here on 2026-09-25::quickbuild:core1,234,:quickbuild:daemon234,:quickbuild:protocol22,:quickbuild:runtime307,:gradle-plugin155 and:app1,312 — 3,264 tests, 1 failure and 5 skips (see below; the other four skips are:gradle-plugin's documented@Disabledcases).:app:quickbuild:runtime:quickbuild:core:app's percentages are changed lines in non-UI files, the basis REVIEW.md section 5 names: 1338 of 1715 changed lines and 761 of 1007 changed branches, with Activities, Fragments and Composables excluded. That slice covers 56 of this PR's 61 source files; the exclusion removed 12 of the diff's 159 files, 7 of them source.:subprojects:projectsand:subprojects:tooling-api-implhave no coverage task and were not run in this pass [unmeasured at this head].The
:appskip isMemoryUsageWatcherLivenessTest :: the default check really reads proc, which self-skips on a macOS host and runs on Linux CI.The
:appfailure isQuickBuildActionSaveOrderTest :: the sample happens exactly once and strictly before the save, reported asUncaughtExceptionsBeforeTest— an exception escaped an earlier test in the same JVM fork and landed on whichever test started next. Suppressed cause:Dispatchers.Main was accessed when the platform dispatcher was absent and the test dispatcher was unset, so production code reachedDispatchers.Mainasynchronously after the test that started it had finished. The victim rotates: across six runs it has beenGitBottomSheetViewModelTest,TemplateManagerViewModelTesttwice, and now this one, which is one of this stack's own tests. The same failure mode reproduces onorigin/stagewith none of the stack applied, so the leak is pre-existing rather than introduced here. Which production launch site leaks is unmeasured. It is not an unbalancedsetMain/resetMainin the app tests: over all ofapp/src/test, 2 files callsetMainand both callresetMain.The failure aborts the build, so the coverage report came from a second Gradle invocation that skips the failing task and reuses the first run's exec data.
The 2026-09-24 restack pulled about 6,970 lines of stage work under the stack. The manual-QA walk below was re-run after it; the other device readings were not, and each names the head it was green at. The three commits they cite have been re-pointed to their equivalents on this branch.
Manual QA — walked the
manual-qa.mdtest plan on the A56, all 25 cases, at font scale 1.0 and 2.0: 24 pass, 0 fail, 1 blocked [measured on a56 2026-09-24/25 atf6ef914653, the stack tip plus ADFA-4931's 12 commits]. The block is T19 (Compose), denominator 3 of 3: every Compose project on the device fails to configure offline on ADFA-5727, so Quick Build is never reached and the case has no verdict on the product. Two defects were seen alongside criteria that passed, both written up in the walk: the status bolt renders the ERROR tone while the status bar readsQuick Build: built - tap to start app(T7), and T3's failure wording deviates from the plan's text. The earlier walk atf75df5a573on 2026-09-17/18/19 read the same 24 pass and the same one blockDialog insets, device-verified on the A56 at font scale 1.0 and 2.0 [measured on a56 2026-09-22, before the 2026-09-24 restack - 8 dialogs at 1.0 and 2.0, 0 status-bar or navigation-bar intrusions]. At 2.0 the won't-stay-up dialog's two buttons both render 140px tall against 148px of navigation-bar clearance (ratio 1.00); before the fix the second button was 73px against the first's 140px (ratio 0.52). Screenshots read by eye at both scales; the build under test was proven byte-identical to the installed APK by md5.
Font scale [measured on a56, pre-restack]: every Quick Build surface was walked at 1.0 and 2.0 on the A56 on a build whose layouts match
e7fda817bd: the collapsed status header with all 15 status strings (the deploy-failed text re-measured at both scales after its 2026-09-14 rewording: one line at 1.0, two complete lines at 2.0), the toolbar button, the long-press dropdown, Build Output, the Help tooltip, and every Quick Build dialog and flash that could be provoked (not reached: the unknown-app confirm, three reinstall flashes, the live-reload-crashed flash). All read in full at both scales after0058277ed7relabelled the dropdown's disabled row "Building…"; its old label was cut at 2.0. Two 2x issues remain by decision: the floating feedback button can cover the first letter of a wrapped status line (ADFA-5736), and the header's swipe hint, pinned to one line by this PR (layout_editor_build_status.xml:44) so the collapsed header keeps a stable height, is cut. Screenshots at both scales are attached to ADFA-4128.Inherited dialogs [measured on a56 2026-09-22, before the 2026-09-24 restack, including Close project's cut labels]. The dialog inset change reaches every
DialogUtilsdialog. Checked on the A56 at 1.0 and 2.0 on Send feedback, Close project, Git user name, Terminal log level and the plugin external-link prompt: full text and buttons, clear of both system bars, except Close project's two long button labels, which Material's single-line dialog buttons cut at 2.0.StrictMode [measured on a56, both sessions before the 2026-09-24 restack]: a 97-minute session covering project open, Quick Build loops, a standard Run and both font scales logged 552 violations, 1 with a Quick Build frame: the first tooltip shown triggers TooltipManager's one-time main-thread read of the docs DB timestamp (ToolTipManager.kt:52, unchanged by this stack; filed as ADFA-5737). None from the graph warm-up. A shorter re-run on 2026-09-22 read 96 violations over 37,750 logcat lines at font scale 1.0 and 97 over 39,667 at 2.0 — the same single Quick Build frame, the same cause, no new source. The counts differ because the two sessions differ in length and coverage, not in what they found.
LeakCanary [measured on a56 2026-09-22, before the 2026-09-24 restack, where the ApkInstallationViewModel leak did not recur and the NsdManager one did], 3 analyses over the same session: no leak through a Quick Build class. Two older leaks: shizuku's NsdManager holds a destroyed editor (existing ADFA-3418), and ApkInstallationViewModel kept its install callback registered after a finished install, fixed in this PR at
89a3a9a4f1.Benchmark [measured on real devices, 2026-09-05 to 09-07, pre-restack] — both arms: with Quick Build the edit loop is 4-6x faster than the same app's standard build-and-deploy loop on the same device. The Quick Build arm ran the initial experimental release.
How the two arms were made comparable, since they are not like for like from in-app numbers: the in-app standard arm (
MODE_STANDARD) suppresses the install dialog and stops at the build result, so it measures the build only. The benchmark harness adds install and launch from outside the process — it drives the install dialog and takes the span from the save to the app'sDisplayedon the device clock — and the published basis subtracts the harness's own time from that span. Quick Build's arm measures build, deploy and reload in-app. There is noMODE_STANDARD_E2E; earlier drafts of this description named one that was never in the tree.Still open — the API 28/29 resource-swap success path is not device-verified; no phone we have runs those API levels. The rebaseline relaunch path is device-verified on the A56 (see ADFA-4128 (8/11): quickbuild:core — session orchestration #1720).
Review fixes (2026-08-22)
A review-fixes commit addresses the code-review findings. One change here ships to all users with the Experiments flag off; the other runs only with the flag on:
One candidate followup from review (orchestrator forcing a full-changed compile after a failed dex/deploy) was re-checked and refuted at this tip: the forced flag re-arms and a forced no-op already performs the full rebuild. The daemon-side recovery lever stays in as defense in depth.
🤖 Generated with Claude Code
https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2