Skip to content

ADFA-4128: Quick Build - on-device live reload - #1669

Closed
fryanpan wants to merge 10 commits into
stagefrom
feature/ADFA-4128-quick-build
Closed

ADFA-4128: Quick Build - on-device live reload#1669
fryanpan wants to merge 10 commits into
stagefrom
feature/ADFA-4128-quick-build

Conversation

@fryanpan

@fryanpan fryanpan commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Quick Build lets the user run a live reload loop that updates their already running test app in seconds -- instead of going through an incremental Gradle build and reinstall. This opens as a draft so the team can start playing with it now; the remaining known fixes land on this PR as batched commits rather than followup PRs.

1. Overview

Please read the quickbuild/README.md first -- it's a good overview map for understanding the rest of this PR -- how the feature works, key components, decisions, and limitations.

It also links to more detailed docs in the docs/ folder where pipeline.md (more detailed notes + diagrams per component) and concurrency.md might be useful reading.

Refactoring is not too hard -- so if the PR would benefit from major changes, please ask!

2. Review by Commit

This PR is organized by commit. It's probably easiest to review by commit, and I'll stop rewriting history now that the PR is open and any fixes will be batched into future commits.

Unit What it is
docs The docs above, and more
shared plumbing Additions to common, logger, resources, settings.
protocol The interface between core and the compile daemon — the wire format, a codec with its own tests. Start from DaemonProtocol.kt.
core The orchestration layer, and the heart of the design: watches file changes, classifies each one, and routes it to a live reload via the daemon or a proxy-app rebuild via Gradle — every change must end in a consistent proxy app or a clear error. Start from LiveReloadOrchestrator.kt, ChangeClassifier.kt, SessionReducer.kt.
daemon A JVM child process of CoGo that does the per-save work: incremental Kotlin compile via the Build Tools API, javac, d8 dexing, aapt2 resource updates. Start from DaemonMain.kt, IncrementalCompiler.kt.
gradle-plugin Gradle plugin that minimally wraps the user's app to generate the proxy app. Start from QuickBuildPlugin.kt, ProxySourceGenerator.kt, QuickBuildManifestTransformer.kt — manifest rewriting hides subtle breaks.
runtime Java-only AAR that runs inside the proxy app: connects securely back to CoGo over AIDL, applies each reload, owns the connection lifecycle. Where the risk is: it runs inside the user's app. Start from QuickBuildRuntime.java, QuickBuildAppComponentFactory.java, PayloadStore.java.
app wiring The only unit touching existing code — the IDE integration: the toolbar button, the Koin module binding every core interface to its Android implementation, provisioning, the Gradle heap strategies, AndroidManifest.xml. Start from QuickBuildAction.kt, QuickBuildModule.kt, GradleQuickBuildProvisioner.kt.
benchmark levers Measurement modes used only by the benchmarking harness to run and time live reloads and incremental Gradle builds. This is only supported in debug builds of Code on the Go and not included in the production APK.

3. How this was tested

  • Automated tests. All non-UI code overall: 85% line / 85% branch as JaCoCo measures it; 91% line / 89% branch excluding the gradle-plugin task classes JaCoCo cannot attach to (they run in a child Gradle process; TestKit fixture builds cover them). The tests prove the session state machine, edit classification and routing, the error and recovery paths, and the daemon wire protocol.
  • E2E benchmark Used open source Android apps and actual edits from commit history. With each edit in the corpus, we ran Quick Build's live reload vs. standard incremental Gradle build. Full analysis report to be attached separately.
  • Manual QA See the quickbuild/docs/manual-qa.md for the test plan.So far we've run Block A + B and currently there are a few minor bugs left to fix, but it mostly works!See these two Loom videos:
  • Architecture review.
    • Claude checked rule-by-rule against ARCHITECTURE.md and the ADRs — including layering and dependency direction, Koin DI, ABI flavors, persistence choices, and strings living in :resources.
    • No violations; one warning under ADR 0006: the Android-instantiated host service shares a process-wide connection registry bound into Koin as a singleton (ProxyAppConnections) — justified and documented in code.

4. Known limitations and next steps

This highlights some of the more important next steps and known limitations. Please see the limitations section of quickbuild/README.md for a more complete list.

Next steps to get Quick Build done

  • Use unified tools (e.g. Kotlin 2.3.x) when Daniel's PRs merge (will probably do this in a separate PR just to keep this PR from growing even more!!)
  • Fix a few remaining minor bugs (nothing critical remains) -- please let me know if you find more!
  • Documentation DB entry for the Quick Build button long press — planned before launch

Existing tickets:

  • ADFA-4929 — devices at 1.9 GB and under fail, and the wall is the provisioning Gradle build, not Quick Build. Decision: accept 3.6 GB as the floor, or fund a Gradle-free provisioning spike.
  • ADFA-4930 — the project folder's FUSE storage is ~52x slower per file than app-private f2fs; moving just the scratch tree saved ~45% per edit on an A56. Decision: which trees move, given private storage is invisible over MTP.
  • ADFA-4931 — this adds a third bundled Kotlin compiler (2.3.0, 53 MB) beside 2.0.21 and 1.9.22. Unifying also means upgrading the offline template set.

Known Limitations

  • An app that spawns a native process cannot Quick Build. We do not support manifests that use the android:process attribute yet
  • **A crashing proxy app is not cleanly handled **A reload crash repeats on every reload until the session is reset. Also if the proxy app crashes between reloads, Code on the Go does not detect it yet.
  • A library-module edit takes a full rebuild and an install tap. We do not support live reload in multi-module apps yet, but could in the future
  • **Debugging not tested **It may very well work, but we haven't tested this yet!
  • API 28 / 29 untested We built support for devices on older API versions, but haven't yet had enough time (and access to a working device) to test these pathways on device
  • Java apps rebuild all source files in live reload Java compiles are faster, so we have not spent much time optimizing this yet.

🤖 Generated with Claude Code, edited heavily by Bryan

https://claude.ai/code/session_01CsRt7FJyQtTkkJoCcEURA9

@fryanpan
fryanpan force-pushed the feature/ADFA-4128-quick-build branch from 59396fa to 768b8b1 Compare August 13, 2026 09:37
@davidschachterADFA

Copy link
Copy Markdown
Collaborator

What does "every core port" mean?

@davidschachterADFA

Copy link
Copy Markdown
Collaborator

In the first table, the row "app wiring" says "The only unit touching existing code — the IDE integration: the toolbar button, the Koin module binding every core port to Android, provisioning, the Gradle heap strategies, AndroidManifest.xml." What does "core port" mean? Is it about networking?

@fryanpan

Copy link
Copy Markdown
Contributor Author

Good catch — that was jargon, and not even the useful kind. It's nothing to do with networking.

I've reworded the row to say what it actually means:

the Koin module binding every core interface to its Android implementation

:quickbuild:core declares an interface for each thing it needs from the outside world, :app supplies the Android implementation, and Koin connects the two at startup (app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt). For example QuickBuildProvisioner -> GradleQuickBuildProvisioner, QuickBuildMetricsSink -> QuickBuildOutputMetricsSink (the class that writes the Build Output lines), QuickBuildHistoryStore -> PreferencesQuickBuildHistoryStore.

The reason it's built that way: core never names an Android class, so its logic is unit-testable on the JVM. The domain/ package has zero android.* imports — only the small service layer that genuinely needs binder IPC or FileObserver touches Android directly.

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

Code review: Quick Build (ADFA-4128)

Reviewed the full diff (~40k lines of new production code, 414 files) across seven parallel passes - core domain, core data+deploy, core session+provision, daemon+protocol, runtime AAR, gradle-plugin, app wiring - plus every modified pre-existing file. 23 findings are posted inline below, each verified against the branch.

The three that break users outright:

  1. LiveSession.kt:118 - SwitchableExecutor silently swallows markCurrentBuildUserInitiated(), so a bolt tap during an in-flight save-build never relaunches a closed proxy app.
  2. ActivityTracker.java:49 - the first activity after any cold start never receives the resource loader: persisted-generation dex against the baseline resource table, so stale strings/layouts and NotFoundException for new IDs.
  3. PayloadStore.java:112 - sibling payload classloaders mean (MyApp) getApplication() throws ClassCastException after an activity-only edit.

Also worth blocking on: MIN_API is decoded by the daemon but never sent by the host, so every hot payload dexes at API 30 while the baseline uses the project's minSdk - that contradicts this PR's API 28/29 claim.

Merge hazard, not a bug. app/src/main/AndroidManifest.xml is reformatted in full (518 lines) with zero content change versus the merge base, and the branch is 10 commits behind stage - including stage's own manifest changes (ExternalFileInstallActivity, the configChanges axes from ADFA-4934). That guarantees a whole-file conflict where a careless resolution silently drops those. Drop the reformat or rebase before merge.

One thing to ack deliberately. composite-builds/.../MavenPublishConf.kt goes from an empty stub back to a full implementation (needed for the gradle-plugin functional tests). It looks safe - no workflow runs a publish task and all three consuming modules have descriptions - but it is a real re-enablement of Maven Central publishing config, worth an explicit nod rather than riding along in this PR.

editor/.../ContentReadWrite.kt is pure Spotless reformatting, no behavior change.

internal class SwitchableExecutor(
@Volatile var delegate: LiveReloadExecutor,
) : LiveReloadExecutor {
override suspend fun execute(request: BuildRequest): BuildOutcome = delegate.execute(request)

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.

SwitchableExecutor swallows markCurrentBuildUserInitiated()

SwitchableExecutor overrides only execute. LiveReloadExecutor.markCurrentBuildUserInitiated() has a = Unit default, so the call is absorbed here and never reaches the delegate.

Every live session wraps the real executor in this class (LiveSessionFactory.kt:79), so a tap landing on an in-flight save-triggered build is reported as "handled" but LiveReloadExecutorImpl.currentBuildUserInitiated stays false. PayloadDeployer then refuses to relaunch a closed proxy app and the tap produces only a deploy-failure banner - exactly the case the interface KDoc describes.

Forward the override to the delegate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in b9d40b6. The override forwards to the delegate now, with a test covering both sides of a baseline swap.

}
ClassLoader loader = dex == null
? previous.classLoader
: new InMemoryDexClassLoader(dex, apkClassLoader);

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.

Sibling payload classloaders produce ClassCastException across a swap

Each code deploy builds new InMemoryDexClassLoader(dex, apkClassLoader) as a sibling of the previous payload loader, and the payload dex carries all user classes - so the same user class has a distinct identity per generation.

DeployPolicy only restarts the process when the changed set reaches the Application's closure. After an activity-only edit, the recreated activity resolves MyApp through loader N while getApplication() still returns a loader N-1 instance, so (MyApp) getApplication() throws ClassCastException. Same for any live service or user object held across the swap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed — and reproduced on device: a one-line activity edit crashes the app with this cast, twice (the retained payload re-sends and crashes again). Fixed on this branch in b9d40b6: a code deploy now restarts whenever the app declares its own restart-sensitive component, with a cooperative relaunch that puts the user back on the same screen and back stack (~0.4 s vs 13 ms for a hot swap). CoGo's injected logsender components are exempt by exact FQN — they ship only in the base APK dex, so no payload ever redefines them — which keeps hot swap for apps that declare nothing themselves; 5 of 29 corpus apps are affected. The rule, the exemption's safety argument, and the measured costs are documented in quickbuild/docs/live-reload-alternatives.md on this branch. Four follow-on fixes came out of the same device runs: a relaunch that Android swallowed is retried; quarantining a bad generation falls back to the last one that reached the screen instead of to install-time code; a crash on a restart-booted generation now quarantines it too; and the restart handoff waits until the system server has been told the saved state, closing a race that lost the screen and back stack on foreground saves. In-place redefinition via JVMTI (what Apply Changes uses) is drafted as a followup ticket. Decision record: quickbuild/docs/live-reload-alternatives.md on this branch.

* watcher, and deploy invisibly behind an Idle UI, and the next tap would overwrite
* [live] leaving that watcher orphaned. Cancelling [sessionWork] from inside it is safe.
*/
private fun teardown() {

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.

teardown() never cancels an in-flight proxy-app build

teardown() cancels sessionWork but never calls provisioner.cancelProxyAppBuild; only reduceProvisioning's CancelRequested pairs the two, and SessionReducer.kt:35 emits TeardownAndProvision on its own.

So "Restart session" during the ~90s proxy-app build (long-press menu, or the won't-stay-up dialog) leaves Gradle running, and the immediate re-provision hits isBuildInProgress -> SlotBusy -> a generic "Quick Build setup failed". The user's explicit restart fails.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in b9d40b6. Cancelling the coroutine only abandoned the await — Gradle kept running and holding the build slot, so the restart's own reprovision came back SlotBusy. teardown() now cancels the in-flight build, guarded so the stop tap does not cancel twice; two tests.

return null
}

c == '\\' && i + 1 < n -> {

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.

Escaped newline desyncs code and mask, dropping the watcher batch

An escaped newline inside a string literal appends to code but MASKED to mask, desyncing the two line counts. scan then indexes bodyMask out of bounds on any source containing \n inside a literal, and the watcher batch is silently dropped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in b9d40b6 — prepare() returns null when the line counts disagree, with tests for the string- and char-literal cases. Worse than a dropped batch: on this path the throw was uncaught and took CoGo down.

return
}
// A pane that never comes back (the user left the editor) must not grow this forever.
if (pending.size >= MAX_PENDING) {

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.

Narrator and flash state outlive their project and activity

Narrator pending is a process singleton never cleared on project close, so project A's lines flush into project B's Build Output pane after a switch.

Related: QuickBuildFlashes.kt:49 keeps flashedFailure as an activity field against a process-lifetime status flow, so an activity recreation (a font-scale change, per CLAUDE.md's 2x check) re-raises an indefinite error flashbar the user already dismissed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed on both halves and fixed in b9d40b6. The flash history lives on the EditorViewModel now, so recreation cannot reset the repeat guard, and the narrator resets its queue on project close so one project's lines cannot flush into the next project's Build Output.

onFirstAcquire()
notifyHeldChanged(true)
}
try {

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.

onFirstAcquire() runs outside the try, so a throw strands the bracket

onFirstAcquire() runs after getAndIncrement() but outside the try, contradicting the KDoc's "nothing runs between the acquire and the try". A throw there leaves the counter incremented with no matching release: the bracket is held forever and every later build sees the slot busy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in b9d40b6. The increment is the last statement before the try now, and a failed acquire releases — the safe direction for a UI hint. Two tests, both watched go red with the old ordering restored.

fryanpan and others added 10 commits August 20, 2026 14:03
The pipeline in eight steps, the design contract each step honors, and the
decisions that are easy to get wrong on a second read: why the daemon is a
separate process, why generations only move forward, why a save must not steal
the screen.

- quickbuild/README.md is the entry point: terms, the eight-step pipeline,
  benchmark results, how to test, and the decision log.
- pipeline.md walks each step in depth (it absorbed the old architecture.md).
- component-proxying-design.md explains the proxy-app pattern and its limits.
- resource-updates.md and concurrency.md cover the two areas where the design
  is least obvious from the code.
- debugging.md is the triage runbook, reliability-gaps.md the honest list of
  known gaps with file:line citations, manual-qa.md the block-by-block device
  plan.
- incremental-javac-design.md, ksp-kapt-feasibility.md, low-spec-devices.md and
  why-not-android-jar.md record the roads not taken and why.
- ADR 0012 records the decision to compile outside Gradle, named to the pass and
  the build its numbers came from. ADR 0002 is scoped to match, since it no
  longer describes the only build path.

Read this first. Everything below it is new modules with no callers until the
app-wiring commit, so the branch is meant to be read bottom-up.

Two later edits fold in here, since every quickbuild/docs page lives in this
commit: debugging.md documents the compileOrdinal field on the e2e log line,
and resource-updates.md records why parking a generateSources request is not
enough on its own to stop it being dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
Claude-Session: https://claude.ai/code/session_01EMAzZxcPSpxqbYdTxHaZhx
Nothing here is Quick Build itself; it is the host-side surface the feature
needs and a few fixes found on the way.

- FeatureFlags gates Quick Build so it can ship dark. It tracks whether the
  sentinel files were actually readable rather than inferring absence from an
  all-false read, so a direct-boot read cannot latch the feature off.
- ToolsManager stages the daemon jar and runtime AAR out of assets.
- BuildService gains the hand-back hook a live session needs when an ordinary
  Gradle build rewrites build/ underneath it.
- FlashbarActivityUtils grows a keyed debouncing action so a burst of notices
  cannot stack banners; Flashbar and ContentReadWrite get the small changes
  that supports. SaveResult carries the flags the tap's save ordering needs.
- The Quick Build toolbar iconography (bolt, building, stop, error) and its
  strings; TooltipTag gains the Quick Build help entry.
- settings.gradle.kts registers the four quickbuild modules; version catalog,
  publishing and module config entries for them. ARCHITECTURE.md's module map
  gains quickbuild. analyze.yml sets REQUIRE_BUILD_TOOLCHAIN=1 so the daemon's
  toolchain tests cannot skip green on a runner with no SDK.

Also fixes four unrelated tests that were failing or flaky when this branch
started: LogUtils, corrupt-jar classpath reading, Termux shell-manager NPE, and
the debouncing-action cancel case.

ProjectManagerImpl.generateSources now reports whether it actually dispatched.
It early-returns silently when a Gradle build is already in progress, so a
caller that owes the request a retry previously had no way to tell a dispatch
from a refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
Claude-Session: https://claude.ai/code/session_01EMAzZxcPSpxqbYdTxHaZhx
A tiny standalone module so the IDE side and the daemon side cannot disagree
about the wire. Requests, results, diagnostics, and a codec, with a malformed
input taxonomy that distinguishes wrong types from wrong values - the daemon
reads whatever a broken client sends, so parsing has to fail precisely rather
than throw.

The types live in their own package rather than the module root, so a reader
can tell wire format from transport at a glance and neither side can widen the
contract by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
The session state machine and everything it drives: watching for edits,
classifying them, deciding a build route, running it, and deploying the result.
Laid out package-per-concern (domain/ split from service/), each package with
a README stating its contract.

- One build in flight; a newer edit supersedes an older one; generations only
  move forward.
- Change classification decides the cheapest route that is still correct -
  annotation-aware, because a Room @query edit cannot take the fast path.
- Trailing-debounce coalescing so a save burst is one build, with late echo
  batches absorbed rather than stranded.
- Deploy policy chooses reload versus component restart; the deployer owns
  relaunch, reconnect, and the retry when no app is connected. A forced no-op
  deploy still ships every asset, and a retained payload is re-sent rather than
  rebuilt.
- Daemon lifecycle with an epoch protocol, so a respawn cannot be adopted by
  the session that asked for the previous one, and a replaced daemon cannot
  report its own death as the live one's.
- A tap carries one bit: it supersedes the build it replaces and is consumed,
  so it neither forces a blind rebuild nor gets swallowed by a parked session.

Correctness is what picks the route, not speed, and one case where the two
disagree is worth naming here because it looks like a missing optimization.
The proxy app runtime serves deployed assets through a ResourcesLoader
AssetsProvider, which exists only on API 30+. Below that the runtime still
extracts an asset payload but nothing reads it, so an asset edit acked
"reloaded" would leave the app on stale assets - a silent never-stale
violation. ChangeClassifier therefore takes assetsLiveReloadable, and when it
is false any changed set carrying an asset routes to a full Gradle build. The
gate is on the flag rather than on the AssetsOnly arm because changed assets
ride in every route's deploy payload, so a code+asset edit would otherwise
still ship assets nothing serves. Resources are deliberately untouched: 28/29
have their own LegacyResourceSwap path. The value is threaded down from the
Android edge (see the app-wiring commit) rather than read here, keeping SDK
routing out of the pure classes. The route reports UNSUPPORTED_FILE_CHANGED,
whose meaning already covers a watched file the live reload path cannot
deliver.

Timing is charged to the save that earned it, not to a dead attempt's, and a
save that arrives while the compiler is down is answered and narrated rather
than dropped.

The e2e log line carries compileOrdinal. It had five stamps and no ordinal, so
a 3.0 s build and a 0.9 s build looked like variance rather than two ends of a
warm-up curve, and the ordinal was only recoverable by pulling the bench-events
feed afterwards. The five stamps still lead and do not move, so the harness's
unanchored parser keeps matching and historical runs parse unchanged. The field
is omitted rather than printed as 0 when no compile ran: a resources-only relink
genuinely has no ordinal, and a 0 there would read as the coldest possible
build - the precise misreading the field exists to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
Claude-Session: https://claude.ai/code/session_01EMAzZxcPSpxqbYdTxHaZhx
A long-lived JVM that keeps kotlinc's incremental caches warm between edits.
This is where the speedup comes from: a cold compiler per edit is most of what
makes an ordinary on-device build slow.

- Incremental Kotlin and Java compilation, with the ABI fingerprint that decides
  whether a .java edit forces a Kotlin recompile. The fingerprint hashes the
  file's imports, since an import change is an ABI change downstream.
- d8 dexing, aapt2 resource linking, and toolchain discovery over the messy SDK
  layouts real devices actually have. The aapt2 subprocess is bounded by a
  timeout that kills it, so a hung linker cannot wedge a session.
- final-stripping so a user class can be subclassed by a generated proxy.
- A changed-class set the deploy policy can trust, and a split payload that
  fails rather than deploying half of itself.
- An exception backstop on every op: the daemon exits on shutdown, EOF, or a
  fatal internal error, and never on a handler throwing. A compiler Error is
  answered as a failed build rather than taken as fatal.
- A session releases its tools only once its replacement exists, so a restart
  cannot leave a window with no compiler.
- An offline guard that fails the build if any production class in the module
  references a network API.

The aapt2/d8/Compose regression tests (ADFA-4128 bugs 5/6/8) are
assumption-guarded through TestSdk, so on a runner without an Android SDK they
would skip green and take that coverage with them. The analyze workflow sets
REQUIRE_BUILD_TOOLCHAIN=1 to turn an absent toolchain into a hard failure
instead. The runner does have an SDK - Assemble V8 Debug above could not run
otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
Quick Build cannot swap a class the manifest names directly, so the plugin
generates Proxy<N><Type> subclasses and rewrites the manifest to name those
instead. The manifest then points at something stable while the code behind it
changes.

- Decides which components can be proxied at all, and why each rejection
  happens.
- Walks a user class up to its framework supertype to pick the right proxy
  shape.
- Synthesizes an activity-alias under each real activity class name so in-app
  navigation by explicit class keeps resolving.
- Emits quickbuild.json, the contract the device side reads back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
The half that lives inside the proxy app. Receives a payload over a binder
channel and swaps code, resources, and assets into the running process.

- Classloader routing so freshly compiled classes win over the ones the app
  started with.
- Resource swap with three strategies by API level: ResourcesLoader on 30+, a
  reflective shim on 28/29, unsupported below.
- Payload persistence that survives the crash window between writing bytes and
  recording the generation they belong to. A deploy is all-or-nothing on disk,
  and a payload that fails to apply is quarantined rather than left to be read
  back as good.
- The app boots at a monotonic stamped baseline generation, so a restart cannot
  resurrect a payload older than the build the app was made from.
- Reload confirmation is render-proof when an activity is resumed; when the app
  is backgrounded the runtime acks at apply time instead, so a plain save never
  times out and never brings the app forward.
- A keep-alive service holds the proxy app out of Android's cached-app freezer,
  so a deploy to a backgrounded app is not silently stalled by the platform.
- A build-failure overlay while the app is foreground; a hand-rolled JSON
  parser rather than a dependency, since this AAR ships inside the user's app.
  The parser reports its own failure rather than the fallback's.
- Service-connect hardening: a RuntimeException while binding is caught like a
  dead binder, and the reconnect backoff only resets after a successful
  connect.

Assets ride the same loader as the resource table. They shipped end-to-end but
were never served: the overlay was built with a null AssetsProvider and the only
accessor (overrideAsset) had no callers, so modified assets silently served
stale content and new assets crashed on read. The extracted assets now go
through a DirectoryAssetsProvider (open-coded; the framework's is not public
API) over one cumulative override dir, wrapped in ResourcesProvider.empty for
assets-only payloads and installed alongside the table provider on API 30+.
Payloads carry only changed assets, so extraction merges into the cumulative dir
instead of per-generation dirs, keyed to the baseline fingerprint and cleared on
mismatch - the same trigger that discards a persisted payload - so assets never
outlive their baseline.

Two limits documented rather than fixed. The overlay can add and replace but
cannot hide, so a deleted asset stays readable until the next proxy app
rebuild. And below API 30 there is no loader to hang the provider on at all,
which is why the classifier routes asset-bearing edits to a full Gradle build
there instead of deploying assets nothing would read - never stale, at
full-build cost. The README limitations row and pipeline.md say both.

Two comments corrected against measurement on an A56 (Android 16), because both
asserted a timing property the code does not have. The recreate is not deferred
to the next resume - the tracker still holds a stopped-but-not-destroyed
activity, so the relaunch is scheduled immediately; the ack-at-apply is right
for a different reason, which is that there is no resumed activity to hang a
frame callback on. And onResume is not a rendered frame: it precedes the first
draw, so a resumed-path timing understates time-to-pixels by roughly 4 ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
Claude-Session: https://claude.ai/code/session_01EMAzZxcPSpxqbYdTxHaZhx
The IDE-side integration: a Quick Build action next to Run, session lifecycle
tied to the editor, provisioning the proxy app on first use, and narration of
every stage where the user already looks.

- QuickBuildManager owns the session; provisioning runs the proxy-app build
  through the normal Gradle path and reports progress, and a stop tap cancels
  it rather than leaving it orphaned.
- Build stages narrate into the Build Output pane and the bottom status bar,
  same as an ordinary Gradle build; failures show BUILD FAILED there, and the
  next landed build overwrites it. A delivery failure is narrated as a delivery
  failure, not a build failure, and a parked rebaseline as the failure it is.
- Stale standard-build error banners no longer replay on lifecycle re-collect
  (the error state is consumed after first display).
- The status bar reports "live reloaded in X ms" rather than a generation
  number - generations are internal bookkeeping; developers only care that the
  reload landed and how fast (Bryan, QA walk).
- A build-type switch is confirmed when the app id is unknown, and the switch
  is held until the rebaseline it depends on lands.
- The save-time generateSources is deferred until Quick Build goes idle and
  fires only for resource XML, and the eager prebuild is staggered off the
  project-open spike, so neither competes with the edit the user just made.
- The internal-build bracket is released on every exit path.
- Kaspresso e2e coverage: pipeline and smoke tests, a flag-off test, plus
  automation helpers.

Narration survives backgrounding. Narrating from a collector inside the editor
activity's repeatOnLifecycle(STARTED) loses builds twice over: a build the user
backgrounded CoGo to watch narrated into a cancelled collector, and the status
StateFlow's replay on return arrived as a first emission, which
quickBuildOutputLines rightly says nothing about. Manual QA saw the newest
generation's timing and nothing before it. The two collectors are split: the
status bar stays lifecycle-scoped - it shows state, not history, so a cancelled
collector costs it nothing - while narration moves to QuickBuildOutputNarrator,
attached to the session manager's status for as long as the session exists. The
editor activity now only binds the pane, and lines produced while none is bound
(backgrounded, or between two activities) queue in the narrator until one is.

Each landed build's stage timings also route into the pane. They ride the
metrics port rather than the session status - E2eTimeline is the only type
carrying the per-stage split - so QuickBuildOutputMetricsSink forwards them and
quickBuildTimingLine renders the stages that actually ran: "Quick Build:
generation 5 - compiled in 2.8s, dexed in 0.4s, relinked in 2.3s (total 6.0s)."

The Koin module is also where the classifier's assetsLiveReloadable flag is
read - Build.VERSION.SDK_INT >= R, evaluated once at the Android edge and
threaded down, so nothing in quickbuild:core has to know about SDK levels.

The Quick Build help row is a documentation.db hand-off rather than a host-side
write: the app declares the tooltip tag and the invocation site, and the row
itself is the documentation asset's to ship.

GenerateSourcesDeferral clears a parked request only once the build has actually
been dispatched. Parking it while a Quick Build session is live is not enough on
its own: generateSources refuses silently whenever any Gradle build holds the
single slot, including one this class cannot see from session state - a project
sync, or the user's own Run, while the session reads as settled. The release
then fired into a refusal and the request was cleared with nothing left to retry
it, so a resource save landing in that window left the Java LSP's R symbols
stale until the next save. A refused request now stays parked and retries on the
same grace window, bounded so a durable refusal gives up rather than burning
timers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
Claude-Session: https://claude.ai/code/session_01EMAzZxcPSpxqbYdTxHaZhx
…at they say

The debug-only benchmark surface: an adb-triggerable trampoline, the event and
metrics recorders behind it, and the comparator fix that decides what the
published speedup is a speedup over.

The standard-build number stopped early. `standard_build_finished` fires on
BuildState.AwaitingInstall, so every published comparison had counted a Gradle
build with no APK install and no app launch, against a Quick Build number that
includes its full deploy and reload. The comparison was biased against Quick
Build; the interesting part is that nothing said so. MODE_STANDARD_E2E runs the
same Run action and keeps measuring: it installs, launches, and stamps
`standard_install_started`, `standard_install_finished` and
`standard_e2e_finished` (carrying tapToRunningMs from the tap instant). The span
starts where MODE_STANDARD's does - the timestamp is taken on the line before
`runQuickBuild`, which is the call the toolbar Run action makes - so the two
modes share a start and differ only in where they stop. A separate mode rather
than a flag, because folding install into MODE_STANDARD would silently redefine
`standard_build_finished`, and a number whose meaning changed under a name that
did not is how two passes get compared as if they measured the same thing.

Two dialogs sit in that path and neither can be answered by an unattended
device, so a bench run takes the no-dialog route: the Quick-Build-clobber
confirmation is bypassed (the session is restarted directly, which is what
confirming does), and the launch skips `launchAppAfterInstall` and its prompt by
launching from an override of `onInstallationResult` instead of reaching super.
The human path is untouched - both bypasses are gated on the e2e latch. The
system installer's own confirmation is not ours to suppress; it needs the
REQUEST_INSTALL_PACKAGES appop pre-granted, which the harness already does. The
span ends when startActivity returns, which is the last instant this process can
observe; first frame is a further wait only the framework sees, so the number
UNDERSTATES tap-to-usable. Said in the code so nobody reads the field as more
than it is. Failure paths stamp too - a build that never reaches an installable
APK, an install that reports no package, and a failed launch each end the span
with a reason, so a missing measurement is a labelled terminal state rather than
an absent row, and the latch is released, which is what stops the next run's
collector attributing a human's install to the bench.

The whole surface is debug-only and cannot be reached in a release build: the
trampoline activity is declared in a debug-source-set manifest, and
QuickBuildBenchHooks has an inert release twin so the main sources can call it
unconditionally. The activity is exported by necessity - adb shell holds no
START_ANY_ACTIVITY, so a non-exported activity could not be driven at all - and
is therefore gated on android.permission.DUMP, which adb shell holds, root
bypasses, and no third-party app can obtain. Without that gate the feature flags
were the only protection, and those are files in the public Downloads directory
that any app with storage access can create.

The MODE_STANDARD_E2E path is host-compiled only, not yet verified on a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
…art rule

Everything since the reviewed head, as one commit. Three bodies of work:

1. All 26 findings from John's PR #1669 review addressed. Every mechanical
   defect fixed with tests (notably: C1 activity-attach ordering, C7 phantom
   deletions, C14 final-class cache, C16 stranded saves, C20 backslash-literal
   crash); the four product questions decided by Bryan and implemented:
   C5 notices queue for delivery on return; C6 test-source saves are ignored
   with a once-per-session notice; C15 the Quick Build action greys out while
   a standard build runs; C17 the clobber warning moved to tap time, and
   install re-checks against the applicationId parsed from the APK itself.

2. The C3 fix: a code deploy restarts the app whenever it declares a
   restart-sensitive component (custom Application, Service, or Provider),
   because a loader swap cannot update a held instance and the cross-generation
   ClassCastException follows. With it, the machinery that makes the restart
   liveable: a cooperative relaunch that restores screen, saved state, and back
   stack; a handoff that waits for every activity to stop and then drains the
   main looper, so the saved state demonstrably reaches system_server before
   the process dies; a retry for a relaunch Android swallowed; and crash
   safety - a crash on a hot-swapped OR restart-booted generation quarantines
   it (BootProbation), the next boot falls back to the last generation that
   reached the screen (good.json), and quarantine refuses the good generation
   so the fallback cannot loop.

3. The scope exemption: the components CoGo itself injects (logsender's
   service and provider, matched by exact FQN) do not trigger the restart
   rule, in both consumers - DeployPolicy and the stale-helpers notice.
   They ship only in the base APK dex, so no payload ever redefines them;
   without the exemption every app restarts on every save (logsender is
   injected into every debuggable build), and the measured cost of that is
   +477 ms median per save (A56, 2026-08-21, 42 paired saves, 3 apps).

quickbuild/docs/live-reload-alternatives.md now documents the shipped method
(rule, exemption and its safety argument, relaunch, crash safety, costs),
with the alternatives evaluation kept as history. pipeline.md and
component-proxying-design.md updated to match.

Tests: quickbuild:core 1093, app 534, 0 failures; exemption, probation,
handoff, clobber, test-source and grey-out changes each mutation-gated.
Device-verified on a Samsung A56 (two passes, 2026-08-20/21): foreground
saves keep screen and back stack, quarantine falls back and refuses the
good generation, C3 repro stays fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rHdmCyZY3MF6CJK7dS2U3
@fryanpan
fryanpan force-pushed the feature/ADFA-4128-quick-build branch from 80760f0 to b9d40b6 Compare August 21, 2026 07:58
@fryanpan

Copy link
Copy Markdown
Contributor Author

Thanks for this review — it was thorough and it held up: all 23 inline findings were verified against the branch and all 23 were real, zero false positives. Every one is fixed, and each inline comment now carries its reply.

What happened to the branch since you read it. The head you reviewed (80760f05d) was stale — my fault for leaving it unpushed while you read. The branch has since been rebased onto stage, and everything after the nine module commits you reviewed is squashed into exactly one commit, b9d40b69b: all 23 fixes, the C3 restart-rule work (see the C3 thread), an exemption for CoGo's own injected logsender components (with its safety argument in quickbuild/docs/live-reload-alternatives.md), and the decided halves of C5/C6/C15/C17. One SHA to read, one delta to re-review. Four files your comments point at moved in that delta (DaemonProtocol.kt, IncrementalCompiler.kt, QuickBuildTasks.kt, GenerateSourcesDeferral.kt) — worth a re-read there.

The manifest merge hazard: good catch. The whitespace-only reformat (it was exactly CRLF→LF normalization, 259 lines) is gone — the rebase onto stage made app/src/main/AndroidManifest.xml byte-identical to stage, so the whole-file conflict and the risk to ExternalFileInstallActivity / the ADFA-4934 configChanges axes are dissolved.

MavenPublishConf.kt: the premise is off — it is not an empty stub on stage; origin/stage and the merge base both carry the same full 119-line implementation, and publishToMavenCentral / signAllPublications are untouched from stage. Nothing about Maven Central was re-enabled. The branch's diff is three things, two of them needed by this PR's own test suite: the gradle-plugin functional tests resolve the plugin from the build-local repo, so (1) the signing onlyIf gate is required for that publish to work anywhere without the CI signing key — without it every off-CI test run fails with "no configured signatory"; (2) the delete-task ordering fix stops deleteBuildMavenLocal racing individual publishes under parallel execution, which wiped freshly staged artifacts before the tests resolved them; (3) createregister is a ride-along lazy-configuration modernization, declared as such — happy to split it out if you'd rather.

ContentReadWrite.kt: no behaviour change, but not quite pure reformatting — I added a KDoc on writeTo documenting that the editor writes in place (no temp-file-then-rename). That KDoc is the stated basis for the temp-sibling filtering your C8 comment is about.

Verification on the pushed head: quickbuild:core 1093 tests / app 534, zero failures; the riskier fixes mutation-gated (each mutant compiled, its red test named); two full device passes on a Samsung A56 covering the restart rule, quarantine/fallback, and foreground-save state preservation; and a complete manual QA walk of quickbuild/docs/manual-qa.md on the final build, including both arms of the component exemption (6 restart lines out of 63 code deploys, all six from a deliberately added user service).

@fryanpan

Copy link
Copy Markdown
Contributor Author

Superseded by the stacked split: PRs #1713-#1723 (stack overview and review mechanics in #1713). All 26 review findings raised here are mapped into the corresponding layers of the stack and addressed there. Closing without merging; further review happens on the stack.

@fryanpan fryanpan closed this Aug 23, 2026
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