Android dev builds: a 10 s receipt walk on every build, and full R8 + lint vital on a development APK (measured, tested diff)

Open
#693 1 comment 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
28/100
Issue type
Refactor
Clarity
Clearly specified
Activity status
Active
Tech stack
android, javascript, kotlin, rust

Research direction

Start with diff 1 in scripts/cargo-dep-info.mjs: outputPath/watchOutput/watchedTree and buildInputCollector.add, then the callers in scripts/app.mjs (completeBuild) and scripts/agent-launch.mjs (watchedNewer); run scripts/linked-plan.test.mjs. For diff 2 read buildApk/developmentApkArgs in scripts/agent-android.mjs and host/android/app/build.gradle.kts, checked by scripts/agent-android.test.mjs. Done means one-time output resolution, a dev-trust APK skipping lintVitalRelease with shrink-only R8, and a byte-identical production APK. A Bun CPU profile reproduces the realpath cost.

Written by the indexing model from the issue text.

Description

What's slow

We timed bun exact.mjs android --no-install with our app: TypeScript data, a Contract UI, and four @exact packages (camera, barcode detection, media capture, notifications).

  • Setup: a fresh app worktree over a fresh exact2 worktree at origin/main bd006e885, on an Apple Silicon Mac with 10 cores and JDK 21.
  • Conditions: one build at a time, with no other cargo or Gradle process running.
  • Excluded: install.

We timed three cases:

  • A: change one string in app.ts.
  • B: add one line to a reader Kotlin file (canvas/.../Refresh.kt). Every app pays this Gradle cost after pulling a main that touched the reader.
  • C: the first build in a new worktree.

Where the time goes on origin/main:

Rust (cargo) after cargo, before Gradle Gradle total
A, app.ts edit (5 runs) 21–23 s, almost all of it the app crate (bake 3.8 s, rustc ~17 s) 9.6–11.6 s 4 s 35.0–38.7 s
B, Kotlin edit (4 runs) 1.2–1.5 s (nothing to compile) 9.5–12.7 s 39–64 s: R8 ~27 s, with lintVitalAnalyzeRelease for :canvas and :app running alongside 52.6–76.0 s
C, new worktree (1 run) 189 s ~16 s 49 s (R8 27 s) 254 s

Two of these costs are not compilation.

  1. The bake receipt takes ~10 s on every build, even a no-op. completeBuild (scripts/app.mjs) adds each of the ~6,000 compiler inputs through buildInputCollector's add. Each add calls watchedTree([path], {outputs}), which calls watchOutput(outputs, [path]), which runs realpathSync again on each of the ~90 outputs. A Bun CPU profile of one A build (bun --cpu-prof) puts 8.5 s of the receipt's 10.5 s in realpathSync under outputPath, called from watchOutput's roots. The outputs don't change while a receipt is written. agent-launch.mjs's watchedNewer repeats the same per-root resolution.
  2. A development APK runs R8 and lint vital at full strength. Every dev build is :app:assembleRelease. So a change to the reader's Kotlin, or the first build after pulling main, runs minifyReleaseWithR8 (~27 s) and lint vital. A development-trust APK doesn't need either at full strength.

Proposed changes (tested)

1. Resolve the outputs once per walk (scripts/cargo-dep-info.mjs)

outputRoots(outputs) is the real-path list that watchOutput used to build inline. watchOutput and watchedTree take it as an optional roots, and their defaults are unchanged. The roots are now resolved once:

  • buildInputCollector: at its first add.
  • watchedTree: once per call, not once per path.
  • watchedNewer in agent-launch.mjs: hoisted out of its per-root loop.

Saving: 7–9 s on every Android build. The post-cargo gap goes from 9.5–12.7 s to 2.2–5.8 s.

The receipts are origin/main's. We compared a no-op build on each side, at development and at production trust. Both had the same binary.sha256 and the same 6,001 inputs, and the same watchedDirectories and watchOutputs apart from list order, which follows Cargo's message order. The collector serves every platform's receipt, but we only timed Android.

2. A development APK: shrink-only R8, no lint vital (host/android/app, scripts/agent-android.mjs)

buildApk passes -PdevelopmentApk=1 (developmentApkArgs) only when all three hold:

  • the bake's trust is development;
  • the profile isn't release;
  • the build isn't a bundle.

So --release, --bundle and EXACT_UPDATE_TRUST=production builds get today's Gradle invocation.

With the flag, build.gradle.kts sets lint { checkReleaseBuilds = false } and puts proguard-development.pro (-dontoptimize, -dontobfuscate) in front of the release rules. R8 still shrinks with the same keep rules, all of which are -keep.

Saving on B: Gradle goes from 39–64 s to 17–28 s, and R8 from 27 s to 12–24 s.

Measured but not picked: no R8 at all (isMinifyEnabled = false for a dev APK). B takes 9.4–10.0 s, but the APK grows from 46.4 MB to 74.4 MB, because the dex is 32 MB instead of 4.4 MB. That extra goes over adb on every install. Shrink-only R8 makes the APK 50.7 MB, 4.4 MB more than today. If install size doesn't matter to you, no-R8 is the faster choice.

Before / after

Same machine and app worktree. We alternated stashed origin/main and the diff, with a warm-up build after each switch. Each build was timed on its own.

Case origin/main with the diff
A, app.ts edit 35.0–38.7 s, median 36.5 (5 runs) 26.6–32.2 s, median 29.4 (3 runs)
… of which after cargo, before Gradle 9.6–11.6 s 2.2–3.1 s
B, Kotlin edit 52.6–76.0 s, median 68.4 (4 runs) 21.2–31.6 s, median 25.6 (5 runs)
… of which Gradle 39–64 s, median 57 17–28 s, median 22
C, new worktree (1 run each) 254 s: Rust 189, post-cargo ~16, Gradle 49 201 s: Rust 193, post-cargo ~4, Gradle 4
  • C's Gradle number isn't comparable. Gradle's build cache already held the shrink-only R8 output from earlier runs, so the 4 s is a cache hit. B's Gradle numbers are what a new worktree pays after a reader change.
  • A's saving is the post-cargo gap, 7–9 s. Cargo itself varied from 20.7 to 25.2 s across runs.

Tested

New tests:

  • a collector resolves its outputs once, at its first input, and a walk takes resolved roots (scripts/linked-plan.test.mjs). It covers:

    • an output reached through a link;
    • a link retargeted inside one collector, where the first resolution holds;
    • the same retargeted link in a new walk, where it is resolved again;
    • watchedTree with explicit roots.

    linked-plan.test.mjs: 69 pass.

  • a development APK at the dev profile is packaged quickly; --release, a bundle and production trust are not (developmentApkArgs).

  • -PdevelopmentApk=1 keeps R8 shrinking, adds only its development rules and drops lint vital. This one runs the real Gradle (-m :app:assembleRelease, JDK 21, SDK present).

    • Without the flag, the release graph has :app:lintVitalRelease and :app:minifyReleaseWithR8 and no development rules.
    • With the flag, proguard-development.pro comes first in the rules, R8 stays, and no lintVital task is left.

Existing suites and checks:

  • scripts/agent-android.test.mjs, scripts/agent-launch.test.mjs, bun scripts/caps.mjs, bun scripts/boot.mjs and gate's script tests pass, apart from failures that happen the same way on a clean origin/main:
    • agent-launch.test.mjs "another host's crate or Android's Gradle files are no input of the Apple, web or plan check" (refuseLoose, also noted in #651);
    • 8 tests in push.test.mjs that need cargo build -p exact-js-bake -p exact-bake first.
  • Production is unchanged. We built an EXACT_UPDATE_TRUST=production APK on origin/main and another with the diff. Every zip entry is byte-identical, including classes.dex, libexactcanvas.so, AndroidManifest.xml and resources.arsc. origin/main's R8 output came from the build cache that the diff's build had written, so R8's inputs and rules matched too.

Known limits

  • The dev APK was built but never launched. We left the phone and emulator alone while another session used adb. It only adds -dontoptimize/-dontobfuscate on top of keep-only rules, so its classes are a superset of a release's, but nobody has run it.
  • -dontobfuscate hides one class of bug. A dev build no longer catches a missing keep rule that only breaks under renaming, such as a class loaded by name. --release still does.
  • The dev APK is 4.4 MB bigger (50.7 vs 46.4 MB). We didn't measure install time.
  • Switching costs one slow build. The first build after the flag turns on or off redoes Gradle's dex and R8 work once; here that Gradle run took 24 s.

Measured, not proposed

  • A's main cost is the app crate. rustc takes ~17 s on every app.ts change (cargo --timings), and the bake 3.8 s. canvas_jni! instantiates the painted host inside the app's own crate, so new baked bytes recompile all of it.
    • Its incremental directory is ~300 MB per session. Three were left under target/aarch64-linux-android/host-dev/incremental/ss_control_android-*, about 900 MB in all.

    • Settings we tried on that crate alone:

      Setting rustc time
      (baseline) 17.1 s
      opt-level=1 14.1 s
      codegen-units=64 15.5 s
      incremental=false 27–30 s

      None is worth what it costs.

    • The likely lever is keeping the baked data out of the generic host so the host compiles once (in development, the bytecode and plan could come from a non-generic static or an asset). That's a design change.

  • Gradle configuration cache (-Dorg.gradle.configuration-cache=true) works with this build: the entry was stored and reused, with no problems reported. It saved nothing measurable, though: Gradle took 4 s on A either way.
  • ABI: already arm64-v8a only.

Not measured

  • C's ~190 s of Rust. CARGO_TARGET_DIR is per app, and path dependencies sit at different paths in each worktree, so worktrees share nothing. We didn't try sccache. If someone does: withHostDeployment keeps a user's RUSTC_WRAPPER, but then exact-rustc's proc-macro -no_fixup_chains is lost, so the two would need chaining.
  • The dev loop's own exact-js-bake. Its resident copy (cargo build --release in the tool target) compiles alongside the APK's host-dev copy.
  • --no-watch-fs.
  • adb install options (streamed, --incremental).

Related: #651 (development variants) is independent of this. A variant build has development trust and the dev profile, so it gets the faster packaging too.

Both diffs apply on origin/main 267d024a5 (git apply --check), each on its own or together.

Diff 1: resolve the outputs once (cargo-dep-info.mjs, agent-launch.mjs, test)
diff --git a/scripts/agent-launch.mjs b/scripts/agent-launch.mjs
index 585d7bbee..4811b1a51 100644
--- a/scripts/agent-launch.mjs
+++ b/scripts/agent-launch.mjs
@@ -20,7 +20,7 @@ import { besidePattern } from '../host/web/beside.mjs';
 import { chooseSimulator, phone, simulatorLaunch } from '../host/apple/devices.mjs';
 import { androidDevice } from './agent-android.mjs';
 import { bakeOutput, gpuModules, linuxBinary, moduleDirectory, pendingBuildInputs, readBuilds, resolveApp, windowsFile, shaderWatchRoots, webDist } from './app.mjs';
-import { directoryInputSnapshot, packageTests, watchedTree, watchOutput } from './cargo-dep-info.mjs';
+import { directoryInputSnapshot, outputRoots, packageTests, watchedTree, watchOutput } from './cargo-dep-info.mjs';
 import { HOST_CRATES } from './cargo-check.mjs';
 import { webSourceRoots } from '../host/web/build.mjs';
 import { CONFIG_JSON, DECLARATIONS, MODULE_FILE, moduleGraph } from './module-graph.mjs';
@@ -846,9 +846,9 @@ export function watchedNewer(since, paths, directories = [], products = new Map(
     }
     return skips.get(app);
   };
-  const changed = [];
+  const changed = [], roots = outputRoots(outputs);
   for (const root of paths) {
-    const output = watchOutput(outputs, [root]);
+    const output = watchOutput(outputs, [root], roots);
     const ignored = skipFor(root), skip = (path, directory) => products.has(path) || ignored(path, directory);
     const filtered = snapshot => ({...snapshot,
       entries:snapshot.entries?.filter(({name, directory}) => !skip(resolve(snapshot.path, name), directory)) ?? null});
@@ -857,7 +857,7 @@ export function watchedNewer(since, paths, directories = [], products = new Map(
     const previousRoot = inventory.get(root);
     let rootLink; try { rootLink = readlinkSync(root); } catch { /* not a link */ }
     if (previousRoot && previousRoot.link !== rootLink) changed.push(shown(root));
-    for (const node of watchedTree([root], {outputs, skip})) {
+    for (const node of watchedTree([root], {outputs, skip, roots})) {
       const {path, info, link, snapshot, error, cycle} = node;
       if (error) {
         const previous = inventory.get(dirname(path))?.entries?.find(entry => entry.name === basename(path));
diff --git a/scripts/cargo-dep-info.mjs b/scripts/cargo-dep-info.mjs
index f54264a11..2a106a675 100644
--- a/scripts/cargo-dep-info.mjs
+++ b/scripts/cargo-dep-info.mjs
@@ -25,9 +25,13 @@ function watchedPackage(path) {
   return existsSync(resolve(root, 'package.json')) ? root : null;
 }
 
+/** The outputs' real paths, which every watch excludes. A walk of many inputs resolves them once: on macOS each
+ * `realpathSync` is a syscall per path component, and a receipt that resolved them again for each of its thousands of
+ * compiler inputs spent 8.5 s of an Android rebuild's 10.5 s there. */
+export const outputRoots = (outputs = []) => [...new Set(outputs.filter(Boolean).map(path => outputPath(resolve(path))))];
+
 /** Explicit package watches keep installed sources; incidental tool trees and actual outputs stay excluded. */
-export function watchOutput(outputs = [], inputs = []) {
-  const roots = [...new Set(outputs.filter(Boolean).map(path => outputPath(resolve(path))))];
+export function watchOutput(outputs = [], inputs = [], roots = outputRoots(outputs)) {
   const packages = [...new Set(inputs.map(watchedPackage).filter(Boolean))]
     .map(path => ({path, real:outputPath(path)})).sort((a, b) => b.path.length - a.path.length);
   const toolDirectory = name => /^(target|node_modules|\.git|\.exact|\.gradle|\.build)$/.test(name);
@@ -89,7 +93,7 @@ export function directoryInputSnapshot(path, skip = () => false) {
 }
 
 /** The receipt and drive walk the same filtered entries and record links before stopping cycles. */
-export function* watchedTree(paths, {outputs = [], skip = () => false} = {}) {
+export function* watchedTree(paths, {outputs = [], skip = () => false, roots = outputRoots(outputs)} = {}) {
   const ancestors = new Set();
   function* visit(path, excluded, top = false) {
     let info, link;
@@ -108,7 +112,7 @@ export function* watchedTree(paths, {outputs = [], skip = () => false} = {}) {
     finally { ancestors.delete(real); }
   }
   for (const path of paths) {
-    const output = watchOutput(outputs, [path]);
+    const output = watchOutput(outputs, [path], roots);
     yield* visit(resolve(path), (path, directory) => output(path, directory) || skip(path, directory), true);
   }
 }
@@ -128,8 +132,9 @@ export function packageDirectories(paths, outputs = []) {
 /** Capture compiler inputs by bytes, directory membership and the targets written in links. */
 export function buildInputCollector({outputs, nameOf, hashes, replaced}) {
   const inputs = new Map(), absent = new Map(), directories = new Map(), links = new Map();
+  let roots;
   const add = (path, optional = false, declarer = null) => {
-    for (const node of watchedTree([path], {outputs})) {
+    for (const node of watchedTree([path], {outputs, roots: roots ??= outputRoots(outputs)})) {
       const {path:input, info, link, snapshot, error} = node;
       if (replaced.has(input)) continue;
       const name = nameOf(input, declarer);
diff --git a/scripts/linked-plan.test.mjs b/scripts/linked-plan.test.mjs
index a51297a40..87edc8a20 100644
--- a/scripts/linked-plan.test.mjs
+++ b/scripts/linked-plan.test.mjs
@@ -697,6 +697,33 @@ test('replacing a watched file with an old directory invalidates freshness', asy
   } finally { rmSync(dir, {recursive:true, force:true}); }
 });
 
+// A receipt adds each of thousands of compiler inputs on its own; resolving every output again for each was 8.5 s of an
+// Android rebuild's 10.5 s receipt on a Mac.
+test.skipIf(process.platform === 'win32')('a collector resolves its outputs once, at its first input, and a walk takes resolved roots', async () => {
+  const {buildInputCollector, outputRoots, watchedTree} = await import('./cargo-dep-info.mjs');
+  const dir = realpathSync(mkdtempSync(resolve(tmpdir(), 'watch-roots-')));
+  try {
+    const sources = resolve(dir, 'sources'), made = resolve(sources, 'made'), later = resolve(sources, 'later'), out = resolve(dir, 'out');
+    for (const path of [made, later]) { mkdirSync(path, {recursive:true}); writeFileSync(resolve(path, 'file'), path); }
+    writeFileSync(resolve(sources, 'input'), 'input');
+    symlinkSync(made, out, 'dir');
+    expect(outputRoots([out, out, undefined])).toEqual([made]);
+    const collector = buildInputCollector({outputs:[out], nameOf:path=>path, replaced:new Set(),
+      hashes:{of:path=>createHash('sha256').update(readFileSync(path)).digest('hex')}});
+    collector.add(sources);
+    expect([...collector.inputs.keys()].sort()).toEqual([resolve(sources, 'input'), resolve(later, 'file')]);
+    // The outputs are the ones the collector's first input saw: Cargo's outputs do not move while a receipt is written.
+    rmSync(out); symlinkSync(later, out, 'dir');
+    collector.add(resolve(made, 'file'));
+    collector.add(sources);
+    expect(collector.inputs.has(resolve(made, 'file'))).toBe(true);
+    expect(collector.directories.has(made)).toBe(false);
+    const walked = roots => [...watchedTree([sources], roots)].filter(node => node.info?.isFile()).map(node => node.path).sort();
+    expect(walked({outputs:[out]})).toEqual([resolve(sources, 'input'), resolve(made, 'file')]);
+    expect(walked({outputs:[out], roots:[made]})).toEqual([resolve(sources, 'input'), resolve(later, 'file')]);
+  } finally { rmSync(dir, {recursive:true, force:true}); }
+});
+
 test.skipIf(process.platform === 'win32').each(['.exact', 'node_modules', '.git', '.build', '.gradle', 'target', 'dist'])(
   'a watch omits aliases into %s descendants but keeps explicitly named files', async tool => {
     const {buildInputCollector, packageDirectories, watchOutput} = await import('./cargo-dep-info.mjs');
Diff 2: development APK packaging (build.gradle.kts, proguard-development.pro, agent-android.mjs, README, tests)
diff --git a/host/android/README.md b/host/android/README.md
index 07b9a9bb5..f35470af4 100644
--- a/host/android/README.md
+++ b/host/android/README.md
@@ -108,8 +108,10 @@ The build:
    `app.json` is resolved), with `-PappIcon` and `-PappBanner` naming the icon and banner it wrote, with
    `-PgpuModules=<file>,<file>…` naming the GPU modules: the APK then extracts its libraries and leaves
    the modules unstripped, since the host opens each by path and checks its digest; with `-Pcompose=1` when the plan's
-   uses name `dates` (a date or time control), which links Compose Material 3 for its pickers; and with the Kotlin
-   in the app's `modules/android`, its hatches and module roster, compiled in,
+   uses name `dates` (a date or time control), which links Compose Material 3 for its pickers; with
+   `-PdevelopmentApk=1` for a development-trust build at the dev profile (never `--release`, a bundle or production
+   trust), whose R8 shrinks without optimizing or renaming (`app/proguard-development.pro`) and which skips lint's
+   release checks; and with the Kotlin in the app's `modules/android`, its hatches and module roster, compiled in,
 
 and prints the APK's path. Each build has its own Gradle output and locks: the
 shell's build directory (`-PappBuildDir`), the reader's (`-PcanvasBuildDir`), Gradle's project cache
diff --git a/host/android/app/build.gradle.kts b/host/android/app/build.gradle.kts
index f3a1ba393..34e1a2336 100644
--- a/host/android/app/build.gradle.kts
+++ b/host/android/app/build.gradle.kts
@@ -30,6 +30,12 @@ val minimumOS = (androidHost?.get("minimumOS") as String?) ?: "12"
 val minimumApi = mapOf("10" to 29, "11" to 30, "12" to 31, "13" to 33, "14" to 34, "15" to 35, "16" to 36)[minimumOS]
     ?: throw GradleException("app.json host.android.minimumOS \"$minimumOS\": name an Android release from 10 to 16")
 
+// -PdevelopmentApk=1: a development build at the dev profile (agent-android.mjs `developmentApkArgs`; never `--release`,
+// a bundle or production trust), whose R8 shrinks without optimizing or renaming (proguard-development.pro) and which
+// skips lint's release checks: after one edit to the reader's Kotlin, Gradle took 17-28 s instead of 39-64 s. What a
+// release or production build packages is unchanged.
+val developmentApk = providers.gradleProperty("developmentApk").orNull == "1"
+
 // Each app builds into a directory of its own (`-PappBuildDir`, else build/<app>): two apps' libraries, assets and APKs
 // never mix, and two builds at once write nothing in common (agent-android.mjs gives the reader one too, `canvasBuildDir`).
 val out = providers.gradleProperty("appBuildDir").orNull?.let { file(it) } ?: projectDir.resolve("build/$app")
@@ -79,9 +85,11 @@ android {
             keyPassword = providers.environmentVariable("EXACT_KEY_PASSWORD").get()
         }
     }
+    lint { checkReleaseBuilds = !developmentApk }
     buildTypes {
         release {
             isMinifyEnabled = true
+            if (developmentApk) proguardFiles("proguard-development.pro")
             proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
             if (minimumApi < 31) proguardFiles("proguard-legacy.pro")
             signingConfig = upload ?: signingConfigs.getByName("debug")
diff --git a/host/android/app/proguard-development.pro b/host/android/app/proguard-development.pro
new file mode 100644
index 000000000..84ae0dd22
--- /dev/null
+++ b/host/android/app/proguard-development.pro
@@ -0,0 +1,5 @@
+# A development APK (`-PdevelopmentApk=1`, build.gradle.kts): R8 keeps only what is reachable, as a release's does, but
+# neither optimizes nor renames: after an edit to the reader's Kotlin R8 took 12-24 s instead of 27 s, and stack traces
+# name the reader's own classes. A release, a bundle and a production build never read this file.
+-dontoptimize
+-dontobfuscate
diff --git a/scripts/agent-android.mjs b/scripts/agent-android.mjs
index ccb3f2970..f73868ea7 100644
--- a/scripts/agent-android.mjs
+++ b/scripts/agent-android.mjs
@@ -993,6 +993,11 @@ export function stageSoundLibrary(jniLibs, env) {
   return library;
 }
 
+/** The Gradle arguments that package a development APK quickly (`-PdevelopmentApk=1`: no R8, no lint release checks,
+ * host/android/app/build.gradle.kts) for a development-trust build at the dev profile; a `--release`, bundle or
+ * production build is packaged as it always was. */
+export const developmentApkArgs = ({ development, profile, bundle }) => development && profile !== 'release' && !bundle ? ['-PdevelopmentApk=1'] : [];
+
 /** The app on the Canvas reader, as an APK: its `android/` crate (a cdylib over `exact_painted::canvas_jni!`; a game's
  * is generated under `.shells/`) built for the phone's ABI and put, with the app's assets and its icon and banner
  * (`androidIcons`), where the Gradle shell packages them (host/android/app), then `:app:assembleRelease`. The library
@@ -1071,7 +1076,7 @@ export function buildApk(app, env, profile, writeLocks = false, bundle = false)
     // shipped the reader's old classes once (LLP 1159.002 D1). A daemon started in the locks' own Gradle home would
     // outlive the home.
     assembled = runGradle(['--no-watch-fs', ...(env.CI || graph.env.GRADLE_USER_HOME ? ['--no-daemon'] : []), bundle ? ':app:bundleRelease' : ':app:assembleRelease', `-Papp=${app.name}`, `-PappDir=${app.manifest.game ? app.workspace : app.dir}`,
-      `-PexactDevelopment=${development ? '1' : '0'}`,
+      `-PexactDevelopment=${development ? '1' : '0'}`, ...developmentApkArgs({ development, profile, bundle }),
       `-PappBuildDir=${dirs.app}`, `-PcanvasBuildDir=${dirs.canvas}`, `-PndkPath=${env.ANDROID_NDK_HOME}`, '--project-cache-dir', dirs.cache, `-Pkotlin.project.persistent.dir=${dirs.kotlin}`,
       ...gpuArgs, ...(icons.icon ? [`-PappIcon=${icons.icon}`] : []), ...(icons.banner ? [`-PappBanner=${icons.banner}`] : []), ...(launch ? [`-PappTheme=${launch}`] : []), ...compose, ...extensionArgs(app, out, config, bound, firebase), ...graph.args], { env: { ...env, ANDROID_HOME: androidSdk(env), ...graph.env } }, process.platform, { lockAdvice: maven.length && !writeLocks ? `${app.name}: Gradle locks or verification metadata do not match its dependencies; for an intended graph change, run ${androidLockCommand(app)}, then review and commit gradle.lockfile and gradle-verification.xml` : null });
     if (assembled) graph.kept();
diff --git a/scripts/agent-android.test.mjs b/scripts/agent-android.test.mjs
index 100460e7e..40bc087a8 100644
--- a/scripts/agent-android.test.mjs
+++ b/scripts/agent-android.test.mjs
@@ -951,6 +951,48 @@ for (const [id, minimumOS] of [[undefined, '12'], ['com.example.android_app', '1
   } finally { rmSync(dir, { recursive: true, force: true }); }
 }, 200000);
 
+test('a development APK at the dev profile is packaged quickly; --release, a bundle and production trust are not', async () => {
+  const { developmentApkArgs } = await import('./agent-android.mjs');
+  expect(developmentApkArgs({ development: true, profile: 'host-dev', bundle: false })).toEqual(['-PdevelopmentApk=1']);
+  expect(developmentApkArgs({ development: true, profile: 'release', bundle: false })).toEqual([]);
+  expect(developmentApkArgs({ development: true, profile: 'host-dev', bundle: true })).toEqual([]);
+  expect(developmentApkArgs({ development: false, profile: 'host-dev', bundle: false })).toEqual([]);
+});
+
+// What `-PdevelopmentApk=1` changes in the shell's release build type: R8 shrinks without optimizing or renaming, and
+// lint's release checks leave the task graph; without it the graph and R8's rules are a release's.
+test.skipIf(process.platform !== 'darwin' || !releaseJavaEnv ||
+  !androidSdkPackages().every(pkg => existsSync(resolve(releaseSdk, pkg.path))))('-PdevelopmentApk=1 keeps R8 shrinking, adds only its development rules and drops lint vital', () => {
+  const dir = mkdtempSync(resolve(tmpdir(), 'exact-android-development-'));
+  try {
+    writeFileSync(resolve(dir, 'app.json'), JSON.stringify({ app: { id: 'dev.exact.ci', name: 'CI' } }));
+    const init = resolve(dir, 'inspect.gradle');
+    writeFileSync(init, `gradle.projectsEvaluated {
+      def release = gradle.rootProject.project(':app').extensions.getByName('android').buildTypes.release
+      println('EXACT_DEVELOPMENT=' + groovy.json.JsonOutput.toJson([minify: release.minifyEnabled,
+        rules: release.proguardFiles.collect { it.name }]))
+    }`);
+    const graph = (...flags) => {
+      const result = spawnSync(resolve(import.meta.dir, '../host/android/gradlew'), ['--no-daemon', '--console=plain', '-I', init, '-m',
+        ':app:assembleRelease', '-Papp=ci', `-PappDir=${dir}`, `-PappBuildDir=${dir}/app`, `-PcanvasBuildDir=${dir}/canvas`, '--project-cache-dir', `${dir}/cache`, ...flags],
+      { cwd: resolve(import.meta.dir, '../host/android'), encoding: 'utf8', timeout: 180000, env: { ...releaseJavaEnv, ANDROID_HOME: releaseSdk } });
+      expect(result.status, result.stdout + result.stderr).toBe(0);
+      const line = result.stdout.split('\n').find(line => line.startsWith('EXACT_DEVELOPMENT='));
+      return { ...JSON.parse(line.slice('EXACT_DEVELOPMENT='.length)), tasks: result.stdout.split('\n').filter(line => / SKIPPED$/.test(line)).map(line => line.split(' ')[0]) };
+    };
+    const release = graph(), development = graph('-PdevelopmentApk=1');
+    expect(release.minify).toBe(true);
+    expect(release.rules).not.toContain('proguard-development.pro');
+    expect(release.tasks).toContain(':app:lintVitalRelease');
+    expect(release.tasks).toContain(':app:minifyReleaseWithR8');
+    expect(development.minify).toBe(true);
+    expect(development.rules).toEqual(['proguard-development.pro', ...release.rules]);
+    expect(development.tasks.filter(task => /lintVital/i.test(task))).toEqual([]);
+    expect(development.tasks).toContain(':app:minifyReleaseWithR8');
+    expect(readFileSync(resolve(import.meta.dir, '../host/android/app/proguard-development.pro'), 'utf8').split('\n').filter(line => line && !line.startsWith('#'))).toEqual(['-dontoptimize', '-dontobfuscate']);
+  } finally { rmSync(dir, { recursive: true, force: true }); }
+}, 400000);
+
 // The package suites read the roster, manifest and APK from apkBuildDir; under the reader's own build directory an
 // outside app's were never found.
 test("apkBuildDir holds the APK build --apk makes: an outside app's own target, a checkout app's the reader's", async () => {
Dominant language
Rust
Stars
2.8k
Forks
62
Avg merge
2h 48m
Merged PRs (30d)
126

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from expo/exact

All issues in expo/exact

Similar issues

More Rust issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.