Skip to content

ADFA-4128 (11/11): app + bench — wiring Quick Build into the IDE and the benchmark harness - #1723

Merged
fryanpan merged 85 commits into
feature/ADFA-4128-qb-10-gradle-pluginfrom
feature/ADFA-4128-qb-11-app
Oct 2, 2026
Merged

fryanpan merged 85 commits into
feature/ADFA-4128-qb-10-gradle-pluginfrom
feature/ADFA-4128-qb-11-app

Conversation

@fryanpan

@fryanpan fryanpan commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

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 inPr

Loading

What 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 new variantSourceProviders field 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.isExperimentsEnabled is 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.Insets cuts 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 — relaunchOk is recorded only when a relaunch was attempted, and toRunningMillis only 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 stage c263653bcf on 2026-09-24; head now 7715c40548, 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: spotlessCheck green (34 of 34 tasks executed, --rerun-tasks) and :app:assembleV8Debug green (254.1 MB APK). The A56 walk ran on 2026-09-24/25 at f6ef914653, 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 is origin/stage's current head, so there is no stage drift. All six unit suites were run here on 2026-09-25: :quickbuild:core 1,234, :quickbuild:daemon 234, :quickbuild:protocol 22, :quickbuild:runtime 307, :gradle-plugin 155 and :app 1,312 — 3,264 tests, 1 failure and 5 skips (see below; the other four skips are :gradle-plugin's documented @Disabled cases).

Module Tests Line Branch
:app 1,312 (1 failed, 1 skipped) 78.0 % 75.6 %
:quickbuild:runtime 307 (0 failed) 93.5 % 95.0 %
:quickbuild:core 1,234 (0 failed) 96.7 % 91.6 %

: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:projects and :subprojects:tooling-api-impl have no coverage task and were not run in this pass [unmeasured at this head].

The :app skip is MemoryUsageWatcherLivenessTest :: the default check really reads proc, which self-skips on a macOS host and runs on Linux CI.

The :app failure is QuickBuildActionSaveOrderTest :: the sample happens exactly once and strictly before the save, reported as UncaughtExceptionsBeforeTest — 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 reached Dispatchers.Main asynchronously after the test that started it had finished. The victim rotates: across six runs it has been GitBottomSheetViewModelTest, TemplateManagerViewModelTest twice, and now this one, which is one of this stack's own tests. The same failure mode reproduces on origin/stage with 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 unbalanced setMain/resetMain in the app tests: over all of app/src/test, 2 files call setMain and both call resetMain.

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.md test 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 at f6ef914653, 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 reads Quick Build: built - tap to start app (T7), and T3's failure wording deviates from the plan's text. The earlier walk at f75df5a573 on 2026-09-17/18/19 read the same 24 pass and the same one block

  • Dialog 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 after 0058277ed7 relabelled 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 DialogUtils dialog. 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's Displayed on 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 no MODE_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:

  • GradleBuildTuner. Benefit: builds stop dying with out-of-memory errors on ordinary phones, and a rebuild shortly after a build is much faster. Two settings on every Gradle build while the Experiments flag is on (with it off, builds use the defaults, as before): (a) the Metaspace cap (JVM memory for loaded class definitions) goes 192 MB -> 384 MB -- real Android-plugin builds exceed 192 and were being killed mid-build; it's a cap, not a reservation, so no extra memory is used unless the build needs it. (b) The Gradle daemon (the background process that keeps builds warm) stays alive for a time matched to the phone's tier -- 15 min low-memory, 30 min balanced, 2 h high-performance -- so a quick rebuild skips the cold start while weak phones don't host a resident daemon for hours.
  • generateSources narrowing. Benefit: fewer surprise build stalls while editing, less battery and CPU burned. The IDE used to launch a Gradle generate-sources step after every save-all and any XML save, even when nothing that step produces could have changed. Now it runs only when the saved file can actually affect generated sources. Same results, far fewer builds. A manifest save still runs it.

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

@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch from 5b48f90 to c69d8ef Compare August 22, 2026 06:41
@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch from c69d8ef to 5a3d5eb Compare August 22, 2026 07:06
@fryanpan
fryanpan marked this pull request as ready for review August 23, 2026 02:31

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch from 5a3d5eb to ac3ab4e Compare August 24, 2026 14:44
@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch from ac3ab4e to a45a359 Compare August 24, 2026 14:48
@fryanpan

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review skipped: 106 files exceed the limit of 100.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch from a45a359 to 7c72105 Compare August 27, 2026 17:32
@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch 2 times, most recently from 3e8d9d2 to 6254159 Compare August 29, 2026 23:17

@jatezzz jatezzz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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 — the reArmInstall safety 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, with onlyIfOwned = 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 } == true

With 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.

Comment thread app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt Outdated
Comment thread app/src/main/res/layout/layout_editor_build_status.xml
@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch from 6254159 to 7e90fff Compare September 1, 2026 02:06
@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch from 7e90fff to 1f82366 Compare September 1, 2026 06:59
fryanpan and others added 30 commits October 1, 2026 21:19
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants