Skip to content

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

Draft
fryanpan wants to merge 9 commits into
stagefrom
feature/ADFA-4128-quick-build
Draft

ADFA-4128: Quick Build - on-device live reload#1669
fryanpan wants to merge 9 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 port to Android, 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 and others added 9 commits August 13, 2026 01:56
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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
…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
@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?

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.

2 participants