Android dev builds: a 10 s receipt walk on every build, and full R8 + lint vital on a development APK (measured, tested diff)
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
- Domain
- build-system, performance, tooling
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.
- 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 throughbuildInputCollector'sadd. EachaddcallswatchedTree([path], {outputs}), which callswatchOutput(outputs, [path]), which runsrealpathSyncagain 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 inrealpathSyncunderoutputPath, called fromwatchOutput'sroots. The outputs don't change while a receipt is written.agent-launch.mjs'swatchedNewerrepeats the same per-root resolution. - 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, runsminifyReleaseWithR8(~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 firstadd.watchedTree: once per call, not once per path.watchedNewerinagent-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;
watchedTreewith explicitroots.
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:lintVitalReleaseand:app:minifyReleaseWithR8and no development rules. - With the flag,
proguard-development.procomes first in the rules, R8 stays, and no lintVital task is left.
- Without the flag, the release graph has
Existing suites and checks:
scripts/agent-android.test.mjs,scripts/agent-launch.test.mjs,bun scripts/caps.mjs,bun scripts/boot.mjsand 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.mjsthat needcargo build -p exact-js-bake -p exact-bakefirst.
- Production is unchanged. We built an
EXACT_UPDATE_TRUST=productionAPK on origin/main and another with the diff. Every zip entry is byte-identical, includingclasses.dex,libexactcanvas.so,AndroidManifest.xmlandresources.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/-dontobfuscateon top of keep-only rules, so its classes are a superset of a release's, but nobody has run it. -dontobfuscatehides 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.--releasestill 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.tschange (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=114.1 s codegen-units=6415.5 s incremental=false27–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-v8aonly.
Not measured
- C's ~190 s of Rust.
CARGO_TARGET_DIRis per app, and path dependencies sit at different paths in each worktree, so worktrees share nothing. We didn't try sccache. If someone does:withHostDeploymentkeeps a user'sRUSTC_WRAPPER, but thenexact-rustc's proc-macro-no_fixup_chainsis lost, so the two would need chaining. - The dev loop's own
exact-js-bake. Its resident copy (cargo build --releasein the tool target) compiles alongside the APK's host-dev copy. --no-watch-fs.adb installoptions (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
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from expo/exact
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 1 day
-
verify's reload check reads a form refused for a duplicate as a saved value that came back differentOpen
Difficulty 3/5 Half a day Newbie friendliness 70/100
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 50/100
Maintainers usually reply within 1 day
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
storytold/photocraft#15232 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
suhina-daw/suhina-daw#1477 ·
Maintainers usually reply within 1 day
-
parity priority: low ui
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
storytold/pdfcraft#7483 · 1 comment ·
Maintainers usually reply within 2 days
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
IngvarConsulting/unica#3097 ·
Maintainers usually reply within 1 day
-
enhancement in-progress
Difficulty 1/5 Under an hour Newbie friendliness 64/100