diff --git a/README.md b/README.md index 4f21f2a..370dcb0 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,12 @@ anything it replaces as `.bak`. | | | |---|---| -| `emu` | this checkout's emulator: `name`, `serial`, `list`, `up`, `down` | +| `emu` | this checkout's emulator: `name`, `serial`, `list`, `up`, `down`, `check` | | `adb` | adb, aimed at this checkout's emulator, with screenshots scaled | | `ui-trace` | records a screen as text at 60Hz, and drives it | | `lib/project-avd.sh` | the rules all three share | | `share/ui-trace/` | the device recorder (`UiTrace.java`) and its build | +| `share/gradle-init/` | the init script that stops Gradle installing on every device | ## One emulator per checkout, named after it @@ -40,6 +41,38 @@ about one device (`adb devices`) all turn the defaulting off, and nothing is guessed when this checkout's emulator is not running: adb's own error is better than a wrapper picking a stranger's device. +## Gradle is the hole the wrapper cannot cover + +`adb` can only aim a call that goes through it, and Gradle's Android tasks +do not: `installDebug`, `uninstallDebug` and `connectedAndroidTest` ask the +adb server for every attached device and act on all of them. On 2026-08-31 +one session ran `./gradlew :androidApp:installDebug` with two emulators up +and replaced the app on both. Gradle reported success; from the other +session it looked like its own build had never landed. + +So `install.sh` also links `share/gradle-init/emulator-tools.gradle` into +`~/.gradle/init.d`, where Gradle applies it to **every** build on this +machine — including checkouts nobody has adopted it in, which are exactly +the ones this is protecting. Before any of those tasks runs it calls + + emu check + +in the project's own directory. That is the same rule the `adb` wrapper +follows, in one place: what must not be touched is *another checkout's +emulator*. A physical phone is nobody's checkout, one emulator on its own is +nobody else's business, and a caller who named a device has already narrowed +the fan-out to it — so `check` passes all of those, and refuses only when a +stranger's emulator is in range. `ANDROID_SERIAL` pointing at one is refused +too, because that is what a serial exported into a long-lived shell decays +into once an emulator restarts and another checkout's takes the port. + +Say which device you mean at the moment you use it rather than exporting it: + + ANDROID_SERIAL=$(emu serial) ./gradlew :androidApp:installDebug + +`EMU_ANY_DEVICE=1` means every attached device anyway, for the case that is +genuinely what you want. + ## Rendering on the GPU with no display This machine has no screen, and for a long time that meant the emulator diff --git a/bin/emu b/bin/emu index 3dc1a59..231e4d3 100755 --- a/bin/emu +++ b/bin/emu @@ -46,9 +46,12 @@ usage: emu list every emulator attached, with its AVD and what it is costing up start it, refusing if the machine has no room down stop it + check refuse if a command that reaches every attached device would + reach another checkout's emulator from here Environment: AVD_NAME overrides the name, EMU_FORCE=1 overrides the memory -refusal, DEVICE_PROFILE and SYSTEM_IMAGE decide what `up` creates. +refusal, EMU_ANY_DEVICE=1 overrides `check`, DEVICE_PROFILE and SYSTEM_IMAGE +decide what `up` creates. USAGE exit 2 } @@ -266,11 +269,62 @@ cmd_down() { echo "emu: stopped '$avd' ($serial)" >&2 } +# Whether a command that talks to "every attached device" can safely run in +# this directory. +# +# Gradle's Android install, uninstall and connected-test tasks do exactly +# that: they ask the adb server for every device and act on all of them. With +# two sessions' emulators up, `./gradlew installDebug` replaces the app on +# both, reports success, and says nothing -- from the other session it reads +# as its own build never landing. That happened on 2026-08-31. +# +# The rule is the adb wrapper's, so that "which device does a command here +# mean" has one answer: what must not be touched is *another checkout's +# emulator*. A physical phone is nobody's checkout, and this checkout's own +# emulator is this checkout's business. +cmd_check() { + [ -n "${EMU_ANY_DEVICE:-}" ] && return 0 + + others=$(running_avds "$adb" | awk -F'\t' -v mine="$avd" 'NF && $2 != mine') + [ -n "$others" ] || return 0 + + # A caller that named a device has already narrowed the fan-out to one, + # and choosing a phone or their own emulator is a decision, not a + # mistake. The single bad case is naming somebody else's -- which is + # what a serial exported into a long-lived shell decays into, once that + # emulator restarts and another checkout's takes the port. + if [ -n "${ANDROID_SERIAL:-}" ]; then + clash=$(printf '%s\n' "$others" | + awk -F'\t' -v s="$ANDROID_SERIAL" '$1 == s {print $2; exit}') + [ -n "$clash" ] || return 0 + { + echo "emu: ANDROID_SERIAL=$ANDROID_SERIAL is the '$clash' checkout's emulator." + echo " This directory means '$avd'." + echo + echo " ANDROID_SERIAL=\$(emu serial) aim at '$avd' instead" + } >&2 + exit 1 + fi + + { + echo "emu: this reaches every attached device, and these belong to other checkouts:" + printf '%s\n' "$others" | while IFS=$'\t' read -r s n; do + printf ' %-16s %s\n' "$s" "$n" + done + echo + echo " ANDROID_SERIAL=\$(emu serial) aim at this checkout's '$avd'" + echo " emu up start '$avd', if it is not running" + echo " EMU_ANY_DEVICE=1 really mean every device" + } >&2 + exit 1 +} + case "${1:-}" in name) echo "$avd" ;; serial) avd_serial "$adb" "$avd" || { echo "emu: '$avd' is not running" >&2; exit 1; } ;; list) cmd_list ;; up) cmd_up ;; down) cmd_down ;; + check) cmd_check ;; *) usage ;; esac diff --git a/install.sh b/install.sh index 7ebec9c..50eee11 100755 --- a/install.sh +++ b/install.sh @@ -40,6 +40,20 @@ fi ln -sfn "$here/share/ui-trace" "$sharedir/ui-trace" echo "install: $sharedir/ui-trace -> $here/share/ui-trace" +# Gradle never goes through the adb wrapper -- its device tasks reach every +# attached device by themselves -- so the one place that can hold for a +# checkout nobody has adopted this in is an init script, which Gradle applies +# to every build on this machine. +gradledir=${GRADLE_INIT_DIR:-$HOME/.gradle/init.d} +mkdir -p "$gradledir" +init="$gradledir/emulator-tools.gradle" +if [ -e "$init" ] && [ ! -L "$init" ]; then + echo "install: keeping the existing emulator-tools.gradle as emulator-tools.gradle.bak" + mv "$init" "$init.bak" +fi +ln -sfn "$here/share/gradle-init/emulator-tools.gradle" "$init" +echo "install: $init -> $here/share/gradle-init/emulator-tools.gradle" + case ":$PATH:" in *":$bindir:"*) ;; *) echo "install: note -- $bindir is not on PATH" ;; diff --git a/share/gradle-init/emulator-tools.gradle b/share/gradle-init/emulator-tools.gradle new file mode 100644 index 0000000..4ae26b2 --- /dev/null +++ b/share/gradle-init/emulator-tools.gradle @@ -0,0 +1,55 @@ +// Nothing Gradle builds here may land on another checkout's emulator. +// +// Android's install, uninstall and connected-test tasks talk to the adb +// server directly and act on *every* attached device -- the wrapper `adb` in +// this repo never sees them, so none of its aiming applies. With two +// sessions' emulators up, `./gradlew installDebug` replaces the app on both, +// reports success, and says nothing; from the other session that reads as +// its own build never landing. It happened on 2026-08-31. +// +// This is an init script, applied to every build on this machine, rather +// than something each project opts into: a rule a checkout has to adopt is +// one the next checkout will not have, and the sessions this protects are +// working in checkouts nobody has visited yet. +// +// The decision itself is `emu check` (one rule for "which device does a +// command here mean", shared with the adb wrapper), run in the project's own +// directory because that is what names the AVD. It refuses only when another +// checkout's emulator is attached; EMU_ANY_DEVICE=1 means every device +// anyway. + +def emu = new File(System.getProperty("user.home"), ".local/bin/emu").absolutePath + +gradle.allprojects { project -> + def workingDir = project.rootDir + project.tasks.configureEach { task -> + // Matched on where the task class comes from as well as on its name: + // a build of any kind may have an `install`, and this must not fail + // a project that has nothing to do with a device. + if (!task.getClass().name.startsWith('com.android.')) return + if (!(task.name =~ /^(install|uninstall|connected)/)) return + + task.doFirst { + def said + def status + try { + def process = new ProcessBuilder(emu, 'check') + .directory(workingDir) + .redirectErrorStream(true) + .start() + said = process.inputStream.getText('UTF-8') + status = process.waitFor() + } catch (IOException e) { + // Failing closed: this script is installed by + // emulator-tools, so `emu` missing means a half-installation + // rather than a machine that never had the guard. + throw new org.gradle.api.GradleException( + "\ncannot check which emulator this build means: ${emu} is not runnable" + + "\n run ~/repos/emulator-tools/install.sh, or set EMU_ANY_DEVICE=1") + } + if (status != 0) { + throw new org.gradle.api.GradleException('\n' + said.trim()) + } + } + } +}