#!/bin/sh # Enrols the Android emulator against this machine's dev-updater, without a # camera and without a QR code. # # ./app/enroll-emulator.sh [--host 10.0.2.2] [--port 8090] [--avd dev-updater] # # Enrolling normally means scanning the QR the server prints. There is no # camera on a headless emulator, so this uses the other path the app already # supports: the `devupdater://enroll` intent that MainActivity handles for # camera apps that redirect a scanned URI. Firing it with `am start` is the # whole trick. # # It is idempotent. A token for the emulator is generated once and kept in # $XDG_DATA_HOME/dev-updater/emulator-token; later runs reuse it, so # re-enrolling after reinstalling the app costs nothing and never touches # the token belonging to a real phone. # # Two things that cost an afternoon each, written down so they don't again: # # 1. **`adb shell am start -d "...?a=1&b=2"` silently loses everything # after the first `&`.** The URI is handed to the *device's* shell, # which treats `&` as "run in background" no matter how carefully it was # quoted on this side. The symptom is an intent that starts the app and # enrols nothing, with no error anywhere. Escape them: `\&`. # # 2. **More than one emulator can be attached, and then bare `adb` fails.** # Every `adb` call here names a device, because `adb get-state` and # `adb shell` both exit 1 with "more than one device/emulator" the # moment a second AVD is running -- and the old version read that as # "no emulator is running", which is the opposite of what happened and # sends you off to start a third. The device is chosen by AVD *name* # rather than by taking the only one attached, so it cannot enrol # somebody else's emulator by accident. # # 3. **The server has to be listening somewhere the emulator can reach.** # Inside the emulator, 10.0.2.2 is this machine. dev-updater binds wg0 # and nothing else by default, which the emulator has no route to, so # the app reports the server as unreachable. Start it with # `--bind 0.0.0.0` for the duration of the test. The CA already covers # 10.0.2.2 -- `local_addresses` in main.rs puts it there deliberately -- # so TLS is not the problem, and a TLS error means something else. set -eu HOST=10.0.2.2 PORT=8090 # Defaulted from the environment the same way run-android.sh reads it, so # the two agree about which emulator "the emulator" means; the flag is here # because this script already takes its other settings that way. AVD_NAME="${AVD_NAME:-dev-updater}" while [ $# -gt 0 ]; do case "$1" in --host) HOST=$2; shift 2 ;; --port) PORT=$2; shift 2 ;; --avd) AVD_NAME=$2; shift 2 ;; *) echo "usage: $0 [--host H] [--port P] [--avd NAME]" >&2; exit 2 ;; esac done PACKAGE=com.example.devupdater CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/dev-updater/config.ron" STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dev-updater" TOKEN_FILE="$STATE_DIR/emulator-token" command -v adb >/dev/null 2>&1 || { echo "adb is not on PATH -- add \$HOME/Android/Sdk/platform-tools." >&2 exit 1 } # Prints the adb serial of a running instance of AVD "$1", or nothing. # Lifted from run-android.sh, which has always had to do this: `adb -e` # works only when exactly one emulator is attached and cannot tell ours # apart from somebody else's. avd_serial() { for s in $(adb devices | awk '$2 == "device" {print $1}'); do if [ "$(adb -s "$s" emu avd name 2>/dev/null | head -n1 | tr -d '\r')" = "$1" ]; then echo "$s" return 0 fi done } SERIAL=$(avd_serial "$AVD_NAME") if [ -z "$SERIAL" ]; then # Three different situations, and saying the wrong one costs an # afternoon: nothing running, something running that isn't this AVD, # or adb itself not answering. The list is what tells them apart, so # it is printed rather than described. attached=$(adb devices | awk '$2 == "device" {print $1}') if [ -z "$attached" ]; then echo "No emulator is running. Start one with app/run-android.sh first." >&2 else echo "No emulator named '$AVD_NAME' is running. These are attached:" >&2 for s in $attached; do echo " $s ($(adb -s "$s" emu avd name 2>/dev/null | head -n1 | tr -d '\r'))" >&2 done echo "Pass --avd NAME to pick one, or start '$AVD_NAME' with app/run-android.sh." >&2 fi exit 1 fi echo "==> Using $SERIAL (AVD '$AVD_NAME')" # Every adb call from here names the device: with a second emulator # attached, a bare one exits 1 rather than picking. adb() { command adb -s "$SERIAL" "$@"; } # Generated once and kept, so this script can be run again after a # reinstall without adding a second entry to the server's config every time. if [ ! -f "$TOKEN_FILE" ]; then mkdir -p "$STATE_DIR" # Alphanumeric only: the token goes in a URI, and anything needing # percent-encoding would have to survive two shells to get there. tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32 > "$TOKEN_FILE" chmod 600 "$TOKEN_FILE" echo "==> Generated an emulator token in $TOKEN_FILE" fi TOKEN=$(cat "$TOKEN_FILE") HASH=$(printf %s "$TOKEN" | sha256sum | cut -d' ' -f1) # Only the hash is stored server-side, so this is what the config needs. RESTART_NEEDED=no if [ ! -f "$CONFIG" ]; then echo "No config at $CONFIG -- start dev-updater once to create it." >&2 exit 1 elif grep -q "$HASH" "$CONFIG"; then echo "==> The server already knows this token" else # Inserted rather than replacing the token list: a real phone's # enrolment lives in the same list and must survive this. tmp=$(mktemp) awk -v hash="$HASH" ' { print } /^tokens: \[/ && !done { print " (" print " name: \"emulator\"," print " sha256: \"" hash "\"," print " )," done = 1 } ' "$CONFIG" > "$tmp" cp "$tmp" "$CONFIG" rm -f "$tmp" chmod 600 "$CONFIG" echo "==> Added an 'emulator' token to $CONFIG" RESTART_NEEDED=yes fi if [ "$RESTART_NEEDED" = yes ]; then # The server reads its config at startup, so a token added underneath a # running one is not yet a token it will accept. echo "==> Restart dev-updater now so it reads the new token, then re-run this." echo " (the running server, if any, still has the old list in memory)" exit 1 fi # force-stop first: an already-running activity receives this through # onNewIntent, and whether that path enrols is not something to depend on. adb shell am force-stop "$PACKAGE" >/dev/null 2>&1 || true before=$(adb shell run-as "$PACKAGE" cat shared_prefs/server.xml 2>/dev/null || echo none) # The backslashes are load-bearing -- see the note at the top. adb shell am start -a android.intent.action.VIEW \ -d "devupdater://enroll?host=$HOST\&port=$PORT\&token=$TOKEN" >/dev/null 2>&1 # The app seals the token under a Keystore key before writing it, so the # stored blob differs even for the same token. Changed is the signal; equal # means the intent never landed. attempt=0 while [ "$attempt" -lt 15 ]; do sleep 1 after=$(adb shell run-as "$PACKAGE" cat shared_prefs/server.xml 2>/dev/null || echo none) if [ "$after" != "$before" ]; then echo "==> Enrolled against $HOST:$PORT" echo " If the app still says the server is unreachable, it is listening" echo " on the wrong interface: restart it with --bind 0.0.0.0." exit 0 fi attempt=$((attempt + 1)) done echo "The app's stored settings did not change, so the intent did not land." >&2 echo "Check that $PACKAGE is installed (app/run-android.sh) and try again." >&2 exit 1