aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commit7166ac47c58a4a8acccebb07ae9395773ad9958e (patch)
treea69e75c9e45f9d0d07cd631411694b791647356b
parent852a8a00273c9128efeed0217a8e5d3fcd8bf780 (diff)
downloadscrcpy-android-7166ac47c58a4a8acccebb07ae9395773ad9958e.tar.gz
test-rig: exercise release paths
-rwxr-xr-xtest41
-rw-r--r--test-rig/Dockerfile8
-rw-r--r--test-rig/Keygen.java92
-rw-r--r--test-rig/Pixhash.java68
-rwxr-xr-xtest-rig/build-apk.sh4
-rwxr-xr-xtest-rig/e2e.sh929
-rwxr-xr-xtest-rig/record.sh178
-rwxr-xr-xtest-rig/run.sh18
-rwxr-xr-xtest-rig/screenshots.sh178
-rw-r--r--test-rig/sdk-packages-e2e.txt2
-rw-r--r--test-rig/sdk-packages.txt4
11 files changed, 1050 insertions, 472 deletions
diff --git a/test b/test
index d5bf317..3bb8026 100755
--- a/test
+++ b/test
@@ -3,27 +3,33 @@
#
# ./test unit tests (JVM only, no KVM)
# ./test unit same
-# ./test e2e single-emulator self-mirror E2E (needs /dev/kvm)
+# ./test e2e real two-emulator pairing/mirroring E2E (needs /dev/kvm)
# ./test all unit + e2e
-# ./test record boot emulator, run a Mirror session, inject events,
-# capture screen recording to ./output.mp4 (needs /dev/kvm)
# ./test screenshots
# boot emulator, capture the fastlane store screenshots
# into fastlane/metadata/.../phoneScreenshots (needs /dev/kvm)
# ./test apk build a signed release APK with a throwaway keystore
# (exercises scripts/build-apk end-to-end, no KVM)
+# ./test server build and verify the pinned scrcpy server source
#
# The image is built on first run and cached as $IMG.
-# /dev/kvm must be readable by the invoking user for `./test e2e` and `record`.
+# /dev/kvm must be readable by the invoking user for e2e and screenshots.
set -eu
ROOT="$(cd "$(dirname "$0")" && pwd)"
cmd="${1:-unit}"
+mkdir -p "$ROOT/.tools"
+exec 9>"$ROOT/.tools/test.lock"
+if ! flock -n 9; then
+ echo "test: another repository test is running" >&2
+ exit 3
+fi
+
target=unit
case "$cmd" in
- e2e|all|record|screenshots) target=e2e ;;
+ e2e|all|screenshots) target=e2e ;;
esac
# The image tag is a hash of the rig inputs, so editing the Dockerfile
@@ -33,20 +39,21 @@ IMG="scrcpy-android-test-$target:$(cat "$ROOT/test-rig/Dockerfile" \
| sha256sum | cut -c1-12)"
case "$cmd" in
- unit|e2e|all|record|screenshots|apk) ;;
+ unit|server|e2e|all|screenshots|apk) ;;
-h|--help)
# Usage is the header comment block: print from line 2 down to
# the first non-comment line.
awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0" >&2
exit 0 ;;
*)
- echo "usage: $0 [unit|e2e|all|record|screenshots|apk]" >&2
+ echo "usage: $0 [unit|server|e2e|all|screenshots|apk]" >&2
exit 2 ;;
esac
# Cheap supply-chain check: the gradle wrapper jar is a small binary
# we check into the tree; verify its sha before letting it run.
"$ROOT/scripts/check-wrapper"
+"$ROOT/scripts/check-server"
# Build the image if needed. --network=host so RUN apt/curl can reach
# the outside on hosts where the default docker bridge has no DNS.
@@ -64,11 +71,11 @@ fi
# KVM check for tiers that boot the emulator.
needs_kvm=
+KVM_GID=
case "$cmd" in
- e2e|all|record|screenshots) needs_kvm=1 ;;
+ e2e|all|screenshots) needs_kvm=1 ;;
esac
-kvm_args=
if [ -n "$needs_kvm" ]; then
if [ ! -e /dev/kvm ]; then
echo "test: /dev/kvm not present on this host - e2e cannot run." >&2
@@ -77,27 +84,29 @@ if [ -n "$needs_kvm" ]; then
if [ ! -r /dev/kvm ] || [ ! -w /dev/kvm ]; then
echo "test: /dev/kvm exists but is not rw for uid $(id -u)." >&2
echo " one-time fix:" >&2
- echo " sudo chmod 666 /dev/kvm" >&2
- echo " or add yourself to the kvm group and re-login:" >&2
echo " sudo usermod -aG kvm $(id -un)" >&2
+ echo " then log out and back in" >&2
exit 3
fi
# Pass through /dev/kvm's owning gid (numeric - group names from the
# host don't exist inside the container) so the container user
# (mapped to host uid:gid) can actually open it.
- kvm_args="--device /dev/kvm --group-add $(stat -c %g /dev/kvm)"
+ KVM_GID=$(stat -c %g /dev/kvm)
fi
# Forward Ctrl-C cleanly, run as the host user so files in .tools/ stay
# owned by the invoker (avoids root-owned gradle cache on the host).
# --network=host so the gradle wrapper can fetch the distribution and
# maven deps in environments where the docker bridge DNS misbehaves.
-exec docker run --rm -i \
- --network=host \
- $kvm_args \
+set -- docker run --rm -i --network=host
+if [ -n "$needs_kvm" ]; then
+ set -- "$@" --device /dev/kvm --group-add "$KVM_GID" \
+ --tmpfs /work/.tools/avd:rw,size=10g,mode=1777
+fi
+exec "$@" \
-u "$(id -u):$(id -g)" \
-v "$ROOT":/work \
-w /work \
- -e HOME=/work/.tools/home \
+ -e ANDROID_USER_HOME=/work/.tools/android-home \
"$IMG" \
/work/test-rig/run.sh "$cmd"
diff --git a/test-rig/Dockerfile b/test-rig/Dockerfile
index c43abb5..b735d91 100644
--- a/test-rig/Dockerfile
+++ b/test-rig/Dockerfile
@@ -1,10 +1,8 @@
# scrcpy-android test image.
#
-# The unit target bundles JDK 17, Android cmdline-tools, platform 35, and
-# build-tools 34+35. The e2e target adds the emulator, system image, and
+# The unit target bundles JDK 17, Android cmdline-tools, platform 36, and
+# build-tools 35 and 36. The e2e target adds the API 36 emulator image and
# runtime libraries. JVM-only tests therefore do not download an emulator.
-# build-tools 34 is AGP 8.7's internal pin; pre-installing it keeps
-# gradle from downloading it into the SDK at test time.
# Project is mounted at /work; runs in --rm mode.
FROM debian:bookworm-slim@sha256:60eac759739651111db372c07be67863818726f754804b8707c90979bda511df AS unit
@@ -16,7 +14,7 @@ ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
openjdk-17-jdk-headless \
- curl unzip ca-certificates \
+ curl unzip ca-certificates git util-linux \
&& rm -rf /var/lib/apt/lists/*
ENV ANDROID_SDK_ROOT=/opt/android-sdk
diff --git a/test-rig/Keygen.java b/test-rig/Keygen.java
deleted file mode 100644
index 8cb90a6..0000000
--- a/test-rig/Keygen.java
+++ /dev/null
@@ -1,92 +0,0 @@
-// Read a DER-encoded PKCS#8 RSA private key, derive the public key,
-// emit the Android adb_keys line on stdout.
-//
-// java test-rig/Keygen.java <path/to/adbkey.der>
-//
-// The Android pubkey blob format (from system/core/libcrypto_utils):
-// uint32_t modulus_size_words; // = 64 for RSA-2048
-// uint32_t n0inv; // -1 / N[0] mod 2^32
-// uint8_t modulus[256]; // little-endian
-// uint8_t rr[256]; // (2^2048)^2 mod N, little-endian
-// uint32_t exponent; // typically 65537
-// = 524 bytes, then base64-encoded, then " scrcpy-android@test".
-
-import java.io.IOException;
-import java.math.BigInteger;
-import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.security.KeyFactory;
-import java.security.PrivateKey;
-import java.security.interfaces.RSAPrivateCrtKey;
-import java.security.interfaces.RSAPublicKey;
-import java.security.spec.PKCS8EncodedKeySpec;
-import java.security.spec.RSAPublicKeySpec;
-import java.util.Base64;
-
-public final class Keygen {
-
- private static final int MODULUS_SIZE = 256; // bytes (2048 / 8)
- private static final int MODULUS_SIZE_WORDS = MODULUS_SIZE / 4;
- private static final int ENCODED_SIZE = 3 * 4 + 2 * MODULUS_SIZE;
-
- public static void main(String[] args) throws Exception {
- if (args.length != 1) {
- System.err.println("usage: java Keygen.java <der private key path>");
- System.exit(2);
- }
- byte[] der = Files.readAllBytes(Path.of(args[0]));
- PrivateKey priv = KeyFactory.getInstance("RSA")
- .generatePrivate(new PKCS8EncodedKeySpec(der));
- RSAPublicKey pub = derivePublic(priv);
-
- byte[] blob = encode(pub);
- String b64 = Base64.getEncoder().encodeToString(blob);
- System.out.print(b64);
- System.out.println(" scrcpy-android@test");
- }
-
- private static RSAPublicKey derivePublic(PrivateKey priv) throws Exception {
- if (priv instanceof RSAPrivateCrtKey crt) {
- RSAPublicKeySpec spec = new RSAPublicKeySpec(crt.getModulus(), crt.getPublicExponent());
- return (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(spec);
- }
- throw new IOException("private key is not an RSAPrivateCrtKey");
- }
-
- private static byte[] encode(RSAPublicKey pub) {
- ByteBuffer buf = ByteBuffer.allocate(ENCODED_SIZE).order(ByteOrder.LITTLE_ENDIAN);
- BigInteger n = pub.getModulus();
-
- buf.putInt(MODULUS_SIZE_WORDS);
-
- // n0inv = (2^32) - (N[0] mod 2^32)^-1 mod 2^32
- BigInteger r32 = BigInteger.ZERO.setBit(32);
- BigInteger n0inv = n.mod(r32).modInverse(r32);
- n0inv = r32.subtract(n0inv);
- buf.putInt(n0inv.intValue());
-
- buf.put(beToLe(MODULUS_SIZE, n));
-
- // rr = (2^(MODULUS_SIZE*8))^2 mod N
- BigInteger rr = BigInteger.ZERO.setBit(MODULUS_SIZE * 8).modPow(BigInteger.TWO, n);
- buf.put(beToLe(MODULUS_SIZE, rr));
-
- buf.putInt(pub.getPublicExponent().intValue());
- return buf.array();
- }
-
- private static byte[] beToLe(int len, BigInteger v) {
- byte[] be = v.toByteArray();
- // strip leading sign byte if present
- int start = 0;
- if (be.length > len) start = be.length - len;
- byte[] out = new byte[len];
- for (int i = 0; i < len; i++) {
- int srcIdx = be.length - 1 - i;
- if (srcIdx >= start) out[i] = be[srcIdx];
- }
- return out;
- }
-}
diff --git a/test-rig/Pixhash.java b/test-rig/Pixhash.java
index b35a560..3bd8c8a 100644
--- a/test-rig/Pixhash.java
+++ b/test-rig/Pixhash.java
@@ -1,9 +1,9 @@
-// Verify that a screencap PNG is non-uniform - i.e., MediaCodec
-// actually painted something. Exits 0 if the image has at least
-// `--min` percent of pixels that differ from pixel (0,0); 1 otherwise.
+// Verify a screencap. The default checks non-uniformity. --pattern also
+// requires the four large color fields drawn by the debug Pattern activity,
+// so ordinary app chrome cannot make a black video surface pass.
//
// java test-rig/Pixhash.java <png> # default min=5%
-// java test-rig/Pixhash.java <png> --min 1
+// java test-rig/Pixhash.java <png> --pattern
//
// ImageIO/BufferedImage live in java.desktop, which the image's
// openjdk-17-jdk-headless package does ship (headless only disables
@@ -17,16 +17,26 @@ import java.awt.image.BufferedImage;
public final class Pixhash {
public static void main(String[] args) throws IOException {
- if (args.length < 1) {
- System.err.println("usage: java Pixhash.java <png> [--min N]");
- System.exit(2);
- }
+ if (args.length < 1) usage();
double minPercent = 5.0;
+ boolean pattern = false;
for (int i = 1; i < args.length; i++) {
- if ("--min".equals(args[i]) && i + 1 < args.length) {
- minPercent = Double.parseDouble(args[++i]);
+ if ("--min".equals(args[i])) {
+ if (++i >= args.length) usage();
+ try {
+ minPercent = Double.parseDouble(args[i]);
+ } catch (NumberFormatException e) {
+ usage();
+ }
+ } else if ("--pattern".equals(args[i])) {
+ pattern = true;
+ } else {
+ usage();
}
}
+ if (!Double.isFinite(minPercent) || minPercent < 0.0 || minPercent > 100.0) {
+ usage();
+ }
BufferedImage img = ImageIO.read(new File(args[0]));
if (img == null) {
@@ -50,5 +60,43 @@ public final class Pixhash {
System.err.printf("pixhash: only %.2f%% differ, need %.2f%%%n", pct, minPercent);
System.exit(1);
}
+ if (pattern) checkPattern(img);
+ }
+
+ private static void usage() {
+ System.err.println("usage: java Pixhash.java <png> [--min N] [--pattern]");
+ System.exit(2);
+ }
+
+ private static void checkPattern(BufferedImage img) {
+ long red = 0, green = 0, blue = 0, yellow = 0, sampled = 0;
+ // Sampling every fourth pixel keeps the check cheap on 1080p images.
+ for (int y = 0; y < img.getHeight(); y += 4) {
+ for (int x = 0; x < img.getWidth(); x += 4) {
+ int rgb = img.getRGB(x, y);
+ int r = (rgb >>> 16) & 0xff;
+ int g = (rgb >>> 8) & 0xff;
+ int b = rgb & 0xff;
+ sampled++;
+ if (r >= 140 && g <= 110 && b <= 110) red++;
+ else if (g >= 110 && r <= 120 && b <= 120) green++;
+ else if (b >= 140 && r <= 110 && g <= 130) blue++;
+ else if (r >= 140 && g >= 120 && b <= 120) yellow++;
+ }
+ }
+ double rp = 100.0 * red / sampled;
+ double gp = 100.0 * green / sampled;
+ double bp = 100.0 * blue / sampled;
+ double yp = 100.0 * yellow / sampled;
+ System.out.printf("pixhash: pattern red=%.2f green=%.2f blue=%.2f yellow=%.2f%%%n",
+ rp, gp, bp, yp);
+ // A rotated 16:9 target fills only about one third of a portrait
+ // source after correct letterboxing. Require every field and strong
+ // combined coverage rather than assuming the video fills the screen.
+ if (rp < 5.0 || gp < 5.0 || bp < 5.0 || yp < 5.0
+ || rp + gp + bp + yp < 25.0) {
+ System.err.println("pixhash: expected four-color target pattern was not rendered");
+ System.exit(1);
+ }
}
}
diff --git a/test-rig/build-apk.sh b/test-rig/build-apk.sh
index 18b3969..f54fad5 100755
--- a/test-rig/build-apk.sh
+++ b/test-rig/build-apk.sh
@@ -4,7 +4,7 @@
# signingConfigs wiring still produces a valid signed apk.
#
# Output: /work/app/build/outputs/apk/release/app-release.apk
-# (and the keystore in /work/.tools/release-smoke.jks)
+# (and the keystore in /work/.tools/release-smoke.p12)
set -eu
@@ -19,7 +19,7 @@ if [ ! -f "$KS" ]; then
keytool -genkey -noprompt \
-keystore "$KS" -storetype PKCS12 \
-storepass "$KS_PASS" -keypass "$KS_PASS" \
- -alias "$KEY_ALIAS" -keyalg RSA -keysize 2048 -validity 365 \
+ -alias "$KEY_ALIAS" -keyalg RSA -keysize 2048 -validity 36500 \
-dname "CN=scrcpy-android smoke, OU=test, O=local, C=US" \
>/dev/null
fi
diff --git a/test-rig/e2e.sh b/test-rig/e2e.sh
index ae2a1a8..4eaf239 100755
--- a/test-rig/e2e.sh
+++ b/test-rig/e2e.sh
@@ -1,172 +1,833 @@
#!/bin/sh
-# Single-emulator self-mirror E2E.
+# Two-emulator release E2E.
#
-# Boot an AOSP API-35 emulator, root it, install the app, let it
-# generate its keypair, copy that pubkey into /data/misc/adb/adb_keys
-# so the app can connect to its own host's adbd over TCP, launch
-# Mirror against 127.0.0.1:5555, wait for the first video frame in
-# logcat, screencap, assert the frame is non-uniform.
+# The target exposes Android's real Wireless debugging pairing dialog. The
+# source release APK reads that six-digit code through its ordinary UI, pairs
+# through Conscrypt/SPAKE2, mirrors a deterministic moving pattern through the
+# production SurfaceView, reconnects after scrcpy is killed, and reconnects
+# again after adbd rotates its process-scoped TLS certificate.
#
-# Runs inside the test image; orchestrated by ../test.
+# Timings are overridable for slower or faster hosts:
+# E2E_BOOT_DEADLINE, E2E_SETTLE_DEADLINE, E2E_UI_DEADLINE,
+# E2E_ROTATE_DEADLINE, E2E_RESIZE_DEADLINE.
set -eu
ROOT=/work
-# Debug applicationId (note the .debug suffix). Activity classes live in
-# the invalid.lena.scrcpy namespace, which carries no suffix.
-PKG=invalid.lena.scrcpy.debug
-AVD=scrcpy-test
-SERIAL=emulator-5554
-APK=$ROOT/app/build/outputs/apk/debug/app-debug.apk
+SOURCE_AVD=scrcpy-source
+TARGET_AVD=scrcpy-target
+SOURCE_SERIAL=emulator-5554
+TARGET_SERIAL=emulator-5556
+SOURCE_PKG=invalid.lena.scrcpy
+TARGET_PKG=invalid.lena.scrcpy.debug
+SOURCE_APK=$ROOT/app/build/outputs/apk/release/app-release.apk
+TARGET_APK=$ROOT/app/build/outputs/apk/debug/app-debug.apk
TMPDIR=$ROOT/.tools/e2e
+CONNECT_FORWARD=37000
+PAIR_FORWARD=37001
+SYSTEM_IMAGE=system-images/android-36/default/x86_64/
+# Do not reuse or kill a developer's host ADB server. A reused server may
+# have a different key and leave a headless emulator unauthorized.
+export ADB_SERVER_SOCKET=tcp:localhost:5038
+export ANDROID_ADB_SERVER_ADDRESS=localhost
+export ANDROID_ADB_SERVER_PORT=5038
+
mkdir -p "$TMPDIR"
log() { printf 'e2e: %s\n' "$*" >&2; }
-# ---- 0. server jar ----
-[ -f "$ROOT/app/src/main/assets/scrcpy-server.jar" ] || "$ROOT/scripts/update-server"
+deadline() {
+ dl_name=$1
+ dl_value=$2
+ case "$dl_value" in
+ ''|0|*[!0-9]*)
+ log "$dl_name must be a positive integer"
+ exit 2
+ ;;
+ esac
+ printf '%s\n' "$dl_value"
+}
-# ---- 1. build APK ----
-log 'gradle :app:assembleDebug'
-"$ROOT/gradlew" --no-daemon -q :app:assembleDebug
+E2E_BOOT_DEADLINE=$(deadline E2E_BOOT_DEADLINE "${E2E_BOOT_DEADLINE:-300}")
+E2E_SETTLE_DEADLINE=$(deadline E2E_SETTLE_DEADLINE "${E2E_SETTLE_DEADLINE:-240}")
+E2E_UI_DEADLINE=$(deadline E2E_UI_DEADLINE "${E2E_UI_DEADLINE:-120}")
+E2E_ROTATE_DEADLINE=$(deadline E2E_ROTATE_DEADLINE "${E2E_ROTATE_DEADLINE:-45}")
+E2E_RESIZE_DEADLINE=$(deadline E2E_RESIZE_DEADLINE "${E2E_RESIZE_DEADLINE:-60}")
-# ---- 2. AVD + boot ----
-if ! avdmanager list avd | grep -q "Name: $AVD$"; then
- log "creating avd $AVD"
- echo no | avdmanager create avd -n "$AVD" -k 'system-images;android-35;default;x86_64' -d pixel >/dev/null
+free_kb=$(df -Pk "$ROOT/.tools" | awk 'NR == 2 { print $4 }')
+case "$free_kb" in
+ ''|*[!0-9]*) log "cannot determine free space under $ROOT/.tools"; exit 1 ;;
+esac
+if [ "$free_kb" -lt 1048576 ]; then
+ log "need at least 1 GiB free under $ROOT/.tools; have $(( free_kb / 1024 )) MiB"
+ exit 1
fi
-# Clean stale lock files from a previously aborted run; otherwise the
-# emulator refuses to start ("Running multiple emulators with the same AVD").
-AVD_DIR="$ANDROID_AVD_HOME/$AVD.avd"
-rm -f "$AVD_DIR"/multiinstance.lock "$AVD_DIR"/hardware-qemu.ini.lock 2>/dev/null || true
-rm -rf "$HOME/.android/avd/running" 2>/dev/null || true
+stop_emulator() {
+ adb -s "$1" emu kill >/dev/null 2>&1 || true
+}
-if ! pgrep -f "emulator.*-avd $AVD" >/dev/null; then
- log 'booting emulator (no window, no snapshot)'
- emulator -avd "$AVD" -no-window -no-audio -no-snapshot -gpu swiftshader_indirect \
- -no-boot-anim -accel on >"$TMPDIR/emulator.log" 2>&1 &
-fi
-
-log 'wait-for-device'
-adb -s "$SERIAL" wait-for-device
-log 'wait for boot'
-until [ "$(adb -s "$SERIAL" shell getprop sys.boot_completed | tr -d '\r')" = 1 ]; do
- sleep 2
-done
+cleanup() {
+ stop_emulator "$SOURCE_SERIAL"
+ stop_emulator "$TARGET_SERIAL"
+ adb kill-server >/dev/null 2>&1 || true
+}
+trap cleanup EXIT INT TERM
-# ---- 3. root (emulator adbd already listens on TCP 5555) ----
-log 'adb root'
-adb -s "$SERIAL" root
-adb -s "$SERIAL" wait-for-device
+wait_boot() {
+ wb_serial=$1
+ wb_pid=${2:-}
+ wb_log=${3:-}
+ # A single API-36 emulator takes about 90 s to boot on an idle
+ # 12-core host, and this tier boots two at once. 120 s left no margin:
+ # on a loaded machine the second one misses it, and even a successful
+ # run was observed finishing at 112 s. Overridable so a fast CI can
+ # tighten it back up.
+ wb_deadline=$(( $(date +%s) + E2E_BOOT_DEADLINE ))
+ while [ "$(date +%s)" -lt "$wb_deadline" ]; do
+ if [ -n "$wb_pid" ] && ! kill -0 "$wb_pid" 2>/dev/null; then
+ if wait "$wb_pid"; then wb_rc=0; else wb_rc=$?; fi
+ log "$wb_serial emulator exited during boot (rc=$wb_rc)"
+ if [ -n "$wb_log" ] && [ -f "$wb_log" ]; then
+ tail -40 "$wb_log" >&2
+ fi
+ exit 1
+ fi
+ if [ "$(adb -s "$wb_serial" get-state 2>/dev/null || true)" = device ]; then
+ wb_done=$(adb -s "$wb_serial" shell getprop sys.boot_completed 2>/dev/null \
+ | tr -d '\r' || true)
+ [ "$wb_done" = 1 ] && return
+ fi
+ sleep 2
+ done
+ log "$wb_serial did not finish booting"
+ exit 1
+}
-# ---- 4. install ----
-log "install $APK"
-adb -s "$SERIAL" install -r "$APK" >/dev/null
+# sys.boot_completed fires while the system is still finishing first-boot
+# work, and this rig passes -wipe-data on every run, so every boot is a
+# full first boot. uiautomator needs an idle window and will not get one
+# until that settles: a dump attempted too early fails for a minute and
+# then gives up. Wait for a focused window before driving any UI.
+wait_ui_ready() {
+ wu_serial=$1
+ wu_deadline=$(( $(date +%s) + E2E_SETTLE_DEADLINE ))
+ while [ "$(date +%s)" -lt "$wu_deadline" ]; do
+ if adb -s "$wu_serial" shell dumpsys window 2>/dev/null \
+ | grep -q 'mCurrentFocus=Window'; then
+ return
+ fi
+ sleep 2
+ done
+ log "$wu_serial never reported a focused window; the system never settled"
+ adb -s "$wu_serial" shell dumpsys window 2>/dev/null | head -20 >&2 || true
+ exit 1
+}
-# ---- 5. let the app generate its keypair ----
-log "launching Main once so Adb.java writes filesDir/adbkey"
-adb -s "$SERIAL" shell am start -W -n "$PKG/invalid.lena.scrcpy.Main" >/dev/null
-# Adb.java writes the keypair inside its constructor, before setContentView
-sleep 1
+wait_wifi() {
+ ww_deadline=$(( $(date +%s) + 90 ))
+ while [ "$(date +%s)" -lt "$ww_deadline" ]; do
+ if adb -s "$TARGET_SERIAL" shell ip -4 addr show wlan0 2>/dev/null \
+ | grep -q ' inet '; then
+ return
+ fi
+ sleep 2
+ done
+ log 'target Wi-Fi never received an address'
+ exit 1
+}
-# ---- 6. extract pubkey and authorise ----
-log 'reading filesDir/adbkey via run-as'
-adb -s "$SERIAL" shell "run-as $PKG cat files/adbkey" > "$TMPDIR/adbkey.der"
-if [ ! -s "$TMPDIR/adbkey.der" ]; then
- log 'adbkey was empty - did Adb.java run?'
- adb -s "$SERIAL" logcat -d -t 200 -s scrcpy-android >&2 || true
+refresh_ui() {
+ ru_serial=$1
+ ru_file=$2
+ ru_tmp=$ru_file.tmp
+ ru_error=$ru_file.error
+ # uiautomator refuses to dump while the window is still animating or
+ # settling, which is exactly the state a freshly booted emulator is in
+ # when the first dump is taken. Animations are already off by here;
+ # this is the remaining margin.
+ ru_deadline=$(( $(date +%s) + E2E_UI_DEADLINE ))
+ ru_n=0
+ while [ "$(date +%s)" -lt "$ru_deadline" ]; do
+ ru_n=$(( ru_n + 1 ))
+ # --compressed drops non-interesting nodes. It succeeds on some
+ # hierarchies where the full dump gives up, and everything this
+ # rig looks for (resource-id, text, bounds) survives compression.
+ if [ $(( ru_n % 2 )) -eq 0 ]; then
+ ru_mode=--compressed
+ else
+ ru_mode=
+ fi
+ if adb -s "$ru_serial" shell uiautomator dump $ru_mode /sdcard/scrcpy-e2e-ui.xml \
+ >"$ru_error" 2>&1 \
+ && adb -s "$ru_serial" exec-out cat /sdcard/scrcpy-e2e-ui.xml \
+ > "$ru_tmp" 2>>"$ru_error" \
+ && grep -q '<hierarchy' "$ru_tmp"; then
+ mv "$ru_tmp" "$ru_file"
+ rm -f "$ru_error"
+ return
+ fi
+ # uiautomator waits for an idle window and gives up if one never
+ # comes. A dozing screen or a stuck animation both cause that, so
+ # nudge the device awake between attempts rather than just waiting.
+ if [ $(( ru_n % 5 )) -eq 0 ]; then
+ adb -s "$ru_serial" shell input keyevent KEYCODE_WAKEUP >/dev/null 2>&1 || true
+ adb -s "$ru_serial" shell wm dismiss-keyguard >/dev/null 2>&1 || true
+ fi
+ sleep 1
+ done
+ log "uiautomator never produced a hierarchy for $ru_serial after $ru_n attempts"
+ adb -s "$ru_serial" shell dumpsys window 2>/dev/null \
+ | grep -E 'mCurrentFocus|mFocusedApp' >&2 || true
+ [ ! -s "$ru_error" ] || tail -20 "$ru_error" >&2
+ rm -f "$ru_tmp" "$ru_error"
+ log "could not capture UI hierarchy from $ru_serial"
exit 1
-fi
+}
+
+find_node() {
+ fn_file=$1
+ fn_needle=$2
+ tr '>' '\n' < "$fn_file" | awk -v needle="$fn_needle" \
+ 'index($0, needle) { print; exit }'
+}
+
+node_attr() {
+ na_node=$1
+ na_attr=$2
+ printf '%s\n' "$na_node" \
+ | sed -n "s/.* ${na_attr}=\"\([^\"]*\)\".*/\1/p"
+}
-log 'computing android pubkey blob'
-java "$ROOT/test-rig/Keygen.java" "$TMPDIR/adbkey.der" > "$TMPDIR/adb_keys.line"
-
-log 'appending to /data/misc/adb/adb_keys'
-adb -s "$SERIAL" push "$TMPDIR/adb_keys.line" /sdcard/adb_keys.line >/dev/null
-adb -s "$SERIAL" shell "cat /sdcard/adb_keys.line >> /data/misc/adb/adb_keys \
- && chown system:shell /data/misc/adb/adb_keys \
- && chmod 640 /data/misc/adb/adb_keys \
- && rm /sdcard/adb_keys.line"
-
-# adbd rereads /data/misc/adb/adb_keys on every AUTH challenge,
-# so no daemon restart is necessary. Restarting it would put the
-# host's adb client into 'offline' state and force a reconnect dance.
-
-# ---- 7. trigger Mirror ----
-log 'starting Mirror -> 127.0.0.1:5555'
-adb -s "$SERIAL" logcat -c
-adb -s "$SERIAL" shell am start -n "$PKG/invalid.lena.scrcpy.Mirror" \
- --es host 127.0.0.1 --ei port 5555 >/dev/null
-
-# ---- 8. wait for first frame, fail on session give-up ----
-# Server's openAbstract budget (Server.OPEN_DEADLINE_MS) must stay
-# shorter than this deadline; otherwise our retries finish before
-# we've given the wire a chance.
-deadline=$(( $(date +%s) + ${E2E_DEADLINE:-60} ))
-ok=
-while [ "$(date +%s)" -lt "$deadline" ]; do
- line=$(adb -s "$SERIAL" logcat -d -s scrcpy-android | tail -200)
- if printf '%s' "$line" | grep -q 'video frame n=1'; then
- ok=1; break
+tap_node() {
+ tn_serial=$1
+ tn_node=$2
+ tn_bounds=$(node_attr "$tn_node" bounds)
+ tn_coords=$(printf '%s\n' "$tn_bounds" | sed -n \
+ 's/^\[\([0-9][0-9]*\),\([0-9][0-9]*\)\]\[\([0-9][0-9]*\),\([0-9][0-9]*\)\]$/\1 \2 \3 \4/p')
+ [ -n "$tn_coords" ] || {
+ log "could not parse UI bounds: $tn_bounds"
+ exit 1
+ }
+ # Split the validated numeric tuple into four shell arguments.
+ # shellcheck disable=SC2086
+ set -- $tn_coords
+ adb -s "$tn_serial" shell input tap $(( ($1 + $3) / 2 )) $(( ($2 + $4) / 2 ))
+}
+
+tap_resource() {
+ tr_serial=$1
+ tr_file=$2
+ tr_id=$3
+ tr_node=$(find_node "$tr_file" "resource-id=\"$tr_id\"")
+ [ -n "$tr_node" ] || {
+ log "resource $tr_id is not visible"
+ exit 1
+ }
+ tap_node "$tr_serial" "$tr_node"
+}
+
+input_resource() {
+ ir_serial=$1
+ ir_file=$2
+ ir_id=$3
+ ir_text=$4
+ tap_resource "$ir_serial" "$ir_file" "$ir_id"
+ adb -s "$ir_serial" shell input text "$ir_text"
+ sleep 1
+ refresh_ui "$ir_serial" "$ir_file"
+}
+
+dismiss_fullscreen_prompt() {
+ df_serial=$1
+ df_file=$2
+ sleep 1
+ refresh_ui "$df_serial" "$df_file"
+ df_button=$(find_node "$df_file" 'text="Got it"')
+ if [ -n "$df_button" ]; then
+ log "dismissing first-use full-screen prompt on $df_serial"
+ tap_node "$df_serial" "$df_button"
+ sleep 1
fi
- if printf '%s' "$line" | grep -q 'session: gave up'; then
- log 'session gave up; logcat:'
- printf '%s\n' "$line" | tail -50 >&2
+}
+
+start_pattern() {
+ sp_file=$1
+ adb -s "$TARGET_SERIAL" shell am force-stop "$TARGET_PKG"
+ if ! sp_start=$(adb -s "$TARGET_SERIAL" shell am start -W -n \
+ "$TARGET_PKG/invalid.lena.scrcpy.Pattern" 2>&1); then
+ log 'target Pattern activity failed to start'
+ printf '%s\n' "$sp_start" >&2
exit 1
fi
+ sp_deadline=$(( $(date +%s) + 15 ))
+ while [ "$(date +%s)" -lt "$sp_deadline" ]; do
+ if adb -s "$TARGET_SERIAL" shell dumpsys activity activities 2>/dev/null \
+ | grep -q 'ResumedActivity.*invalid.lena.scrcpy.Pattern'; then
+ dismiss_fullscreen_prompt "$TARGET_SERIAL" "$sp_file"
+ return
+ fi
+ sleep 1
+ done
+ log 'target Pattern activity did not become resumed'
+ printf '%s\n' "$sp_start" >&2
+ adb -s "$TARGET_SERIAL" shell dumpsys activity activities 2>/dev/null \
+ | grep -E 'ResumedActivity|invalid\.lena\.scrcpy\.Pattern' >&2 || true
+ adb -s "$TARGET_SERIAL" logcat -d -b crash 2>/dev/null | tail -80 >&2 || true
+ exit 1
+}
+
+reopen_source_from_notification() {
+ rn_file=$1
+ rn_node=
+ rn_deadline=$(( $(date +%s) + 15 ))
+ while [ "$(date +%s)" -lt "$rn_deadline" ]; do
+ adb -s "$SOURCE_SERIAL" shell cmd statusbar expand-notifications
+ sleep 1
+ refresh_ui "$SOURCE_SERIAL" "$rn_file"
+ rn_node=$(find_node "$rn_file" 'text="Mirroring active"')
+ [ -n "$rn_node" ] && break
+ done
+ [ -n "$rn_node" ] || {
+ log 'foreground-session notification was not visible'
+ cat "$rn_file" >&2
+ exit 1
+ }
+ tap_node "$SOURCE_SERIAL" "$rn_node"
+}
+
+open_wireless_page() {
+ ow_file=$1
+ ow_started=
+ ow_deadline=$(( $(date +%s) + 15 ))
+ while [ "$(date +%s)" -lt "$ow_deadline" ]; do
+ if adb -s "$TARGET_SERIAL" shell am start -W -a \
+ android.settings.APPLICATION_DEVELOPMENT_SETTINGS >/dev/null 2>&1; then
+ ow_started=1
+ break
+ fi
+ sleep 1
+ done
+ [ -n "$ow_started" ] || {
+ log 'Developer options did not open'
+ exit 1
+ }
+ ow_search=
+ ow_deadline=$(( $(date +%s) + 30 ))
+ while [ "$(date +%s)" -lt "$ow_deadline" ]; do
+ refresh_ui "$TARGET_SERIAL" "$ow_file"
+ [ -n "$(find_node "$ow_file" 'text="Use wireless debugging"')" ] && return
+ ow_search=$(find_node "$ow_file" 'content-desc="Search settings"')
+ [ -n "$ow_search" ] && break
+ sleep 1
+ done
+ [ -n "$ow_search" ] || {
+ log 'Settings search button did not appear'
+ cat "$ow_file" >&2
+ exit 1
+ }
+ tap_node "$TARGET_SERIAL" "$ow_search"
sleep 1
-done
-if [ -z "$ok" ]; then
- log 'timed out waiting for video frame n=1'
- log "full app log: $TMPDIR/logcat.scrcpy"
- adb -s "$SERIAL" logcat -d -s scrcpy-android > "$TMPDIR/logcat.scrcpy" 2>&1 || true
- log "full system log: $TMPDIR/logcat.full"
- adb -s "$SERIAL" logcat -d > "$TMPDIR/logcat.full" 2>&1 || true
- log 'last 80 scrcpy-android lines:'
- tail -80 "$TMPDIR/logcat.scrcpy" >&2 || true
+ adb -s "$TARGET_SERIAL" shell input text 'Wireless%sdebugging'
+
+ ow_result=
+ ow_deadline=$(( $(date +%s) + 60 ))
+ while [ "$(date +%s)" -lt "$ow_deadline" ]; do
+ refresh_ui "$TARGET_SERIAL" "$ow_file"
+ # Search returns more than one exact title. Either result opens a
+ # containing Settings page with the matching preference visible.
+ ow_result=$(tr '>' '\n' < "$ow_file" | awk \
+ 'index($0, "text=\"Wireless debugging\"") \
+ && index($0, "resource-id=\"android:id/title\"") { print; exit }')
+ [ -n "$ow_result" ] && break
+ sleep 1
+ done
+ [ -n "$ow_result" ] || {
+ log 'Wireless debugging did not appear in Settings search'
+ cat "$ow_file" >&2
+ exit 1
+ }
+ tap_node "$TARGET_SERIAL" "$ow_result"
+
+ ow_deadline=$(( $(date +%s) + 30 ))
+ while [ "$(date +%s)" -lt "$ow_deadline" ]; do
+ refresh_ui "$TARGET_SERIAL" "$ow_file"
+ [ -n "$(find_node "$ow_file" 'text="Use wireless debugging"')" ] && return
+ ow_pref=$(tr '>' '\n' < "$ow_file" | awk \
+ 'index($0, "text=\"Wireless debugging\"") \
+ && index($0, "resource-id=\"android:id/title\"") { print; exit }')
+ [ -z "$ow_pref" ] || tap_node "$TARGET_SERIAL" "$ow_pref"
+ sleep 1
+ done
+ log 'Wireless debugging page did not open from Settings search'
+ cat "$ow_file" >&2
+ exit 1
+}
+
+enable_wireless() {
+ ew_file=$1
+ refresh_ui "$TARGET_SERIAL" "$ew_file"
+ ew_switch=$(find_node "$ew_file" 'text="Use wireless debugging"')
+ [ -n "$ew_switch" ] || {
+ log 'Wireless debugging switch was not visible on target'
+ cat "$ew_file" >&2
+ exit 1
+ }
+
+ ew_toggle=$(find_node "$ew_file" 'resource-id="android:id/switch_widget"')
+ if [ "$(node_attr "$ew_toggle" checked)" != true ]; then
+ tap_node "$TARGET_SERIAL" "$ew_switch"
+ sleep 1
+ refresh_ui "$TARGET_SERIAL" "$ew_file"
+ ew_allow=$(find_node "$ew_file" 'resource-id="android:id/button1"')
+ if [ -n "$ew_allow" ]; then
+ tap_node "$TARGET_SERIAL" "$ew_allow"
+ fi
+ fi
+
+ ew_enabled=
+ ew_deadline=$(( $(date +%s) + 15 ))
+ while [ "$(date +%s)" -lt "$ew_deadline" ]; do
+ ew_enabled=$(adb -s "$TARGET_SERIAL" shell settings get global adb_wifi_enabled \
+ 2>/dev/null | tr -d '\r' || true)
+ [ "$ew_enabled" = 1 ] && return
+ sleep 1
+ done
+ log "Wireless debugging confirmation did not enable it (state=$ew_enabled)"
+ refresh_ui "$TARGET_SERIAL" "$ew_file"
+ cat "$ew_file" >&2
+ exit 1
+}
+
+source_log() {
+ adb -s "$SOURCE_SERIAL" logcat -d -s scrcpy-android 2>/dev/null || true
+}
+
+target_log() {
+ adb -s "$TARGET_SERIAL" logcat -d -s scrcpy-android 2>/dev/null || true
+}
+
+fail_on_runtime_error() {
+ fo_log=$(source_log)
+ if printf '%s\n' "$fo_log" | grep -q 'session: gave up'; then
+ log 'session gave up:'
+ printf '%s\n' "$fo_log" | tail -80 >&2
+ exit 1
+ fi
+ if adb -s "$SOURCE_SERIAL" logcat -d -b crash 2>/dev/null \
+ | grep -q "$SOURCE_PKG"; then
+ log 'release app crashed:'
+ adb -s "$SOURCE_SERIAL" logcat -d -b crash 2>/dev/null | tail -80 >&2
+ exit 1
+ fi
+ if adb -s "$SOURCE_SERIAL" logcat -d 2>/dev/null \
+ | grep -q "ANR in $SOURCE_PKG"; then
+ log 'release app ANR detected'
+ exit 1
+ fi
+}
+
+# Rotating the target only proves something if the target's display
+# actually turns. Without this the next wait cannot tell a rig that failed
+# to rotate from a client that ignored the new geometry, and the failure
+# reads as a client bug either way.
+target_display() {
+ adb -s "$TARGET_SERIAL" shell dumpsys window displays 2>/dev/null \
+ | grep -o 'cur=[0-9]*x[0-9]*' | head -1 | tr -d '\r'
+}
+
+# Compare against what the display was, not against a literal geometry:
+# the target's size depends on its AVD profile, and asserting a hardcoded
+# 1920x1080 failed on a device that rotates to 1080x606.
+wait_display_change() {
+ wd_before=$1
+ wd_deadline=$(( $(date +%s) + E2E_ROTATE_DEADLINE ))
+ wd_cur=
+ while [ "$(date +%s)" -lt "$wd_deadline" ]; do
+ wd_cur=$(target_display)
+ if [ -n "$wd_cur" ] && [ "$wd_cur" != "$wd_before" ]; then
+ log "target display $wd_before -> $wd_cur"
+ return
+ fi
+ sleep 1
+ done
+ log "target display never moved from ${wd_before:-unknown}"
+ log 'the target did not rotate, so the client was never asked to resize'
+ exit 1
+}
+
+wait_log() {
+ wl_pattern=$1
+ wl_seconds=$2
+ wl_deadline=$(( $(date +%s) + wl_seconds ))
+ while [ "$(date +%s)" -lt "$wl_deadline" ]; do
+ fail_on_runtime_error
+ source_log | grep -q "$wl_pattern" && return
+ sleep 1
+ done
+ log "timed out waiting for log pattern: $wl_pattern"
+ source_log | tail -100 >&2
+ exit 1
+}
+
+wait_target_log() {
+ wt_pattern=$1
+ wt_seconds=$2
+ wt_deadline=$(( $(date +%s) + wt_seconds ))
+ while [ "$(date +%s)" -lt "$wt_deadline" ]; do
+ fail_on_runtime_error
+ target_log | grep -q "$wt_pattern" && return
+ sleep 1
+ done
+ log "timed out waiting for target log pattern: $wt_pattern"
+ target_log | tail -100 >&2
+ log 'source log at target assertion failure:'
+ source_log | tail -100 >&2
exit 1
+}
+
+wait_connect_port() {
+ wc_file=$1
+ wp_deadline=$(( $(date +%s) + 60 ))
+ while [ "$(date +%s)" -lt "$wp_deadline" ]; do
+ refresh_ui "$TARGET_SERIAL" "$wc_file"
+ wp_addr=$(tr '>' '\n' < "$wc_file" | sed -n \
+ 's/.* text="\([0-9][0-9.]*:[0-9][0-9]*\)".*/\1/p' | sed -n '1p')
+ wp_port=${wp_addr##*:}
+ case "$wp_port" in
+ ''|*[!0-9]*) ;;
+ *) [ "$wp_port" -gt 0 ] && [ "$wp_port" -le 65535 ] \
+ && { printf '%s\n' "$wp_port"; return; } ;;
+ esac
+ # In landscape the IP row starts below the visible fold. These
+ # coordinates are inside the Settings list in both orientations.
+ adb -s "$TARGET_SERIAL" shell input swipe 900 950 900 500 250
+ sleep 1
+ done
+ log 'target never displayed a Wireless debugging connect port'
+ wc_state=$(adb -s "$TARGET_SERIAL" shell settings get global adb_wifi_enabled \
+ 2>/dev/null | tr -d '\r' || true)
+ log "adb_wifi_enabled=$wc_state"
+ cat "$wc_file" >&2
+ exit 1
+}
+
+wait_tls_stopped() {
+ ws_deadline=$(( $(date +%s) + 30 ))
+ while [ "$(date +%s)" -lt "$ws_deadline" ]; do
+ ws_port=$(adb -s "$TARGET_SERIAL" shell getprop persist.adb.tls_server.enable \
+ 2>/dev/null | tr -d '\r' || true)
+ case "$ws_port" in
+ ''|0) return ;;
+ esac
+ sleep 1
+ done
+ log 'target Wireless debugging TLS port did not stop'
+ exit 1
+}
+
+# ---- build the exact artifacts under test ----
+
+[ -f "$ROOT/app/src/main/assets/scrcpy-server.jar" ] || "$ROOT/scripts/update-server"
+log 'building debug target fixture and minified release source'
+"$ROOT/gradlew" --no-daemon -q :app:assembleDebug
+KS=$ROOT/.tools/e2e-release.p12
+if [ ! -f "$KS" ]; then
+ keytool -genkey -noprompt -keystore "$KS" -storetype PKCS12 \
+ -storepass changeit -keypass changeit -alias e2e \
+ -keyalg RSA -keysize 2048 -validity 36500 \
+ -dname 'CN=scrcpy-android e2e, O=local, C=US' >/dev/null
fi
-log 'video frame n=1 - proceeding to screencap'
+KEYSTORE_PATH=$KS KEYSTORE_PASS=changeit KEY_ALIAS=e2e KEY_PASS=changeit \
+ "$ROOT/scripts/build-apk" >/dev/null
-# Give the decoder a moment to actually render to the surface.
-sleep 1
+# ---- boot two isolated devices ----
-# ---- 9. screencap + pixhash ----
-adb -s "$SERIAL" exec-out screencap -p > "$TMPDIR/shot.png"
-log "screencap $(wc -c <"$TMPDIR/shot.png") bytes"
-java "$ROOT/test-rig/Pixhash.java" "$TMPDIR/shot.png" --min 5
-
-# ---- 9b. mid-session disconnect -> auto-reconnect ----
-# Kill the scrcpy server on the target out from under the app. The video
-# socket dies; Session.run() must detect it, rerun the bring-up ladder,
-# and a fresh VideoStream must log 'video frame n=1' again. Clear logcat
-# first so the only frame we can match is the post-reconnect one.
-log 'forcing a mid-session disconnect (pkill scrcpy server on target)'
-adb -s "$SERIAL" logcat -c
-adb -s "$SERIAL" shell 'pkill -f com.genymobile.scrcpy.Server' || true
-
-rdeadline=$(( $(date +%s) + ${E2E_RECONNECT_DEADLINE:-60} ))
-relink=
-while [ "$(date +%s)" -lt "$rdeadline" ]; do
- if adb -s "$SERIAL" logcat -d -s scrcpy-android | grep -q 'video frame n=1'; then
- relink=1; break
+ensure_avd() {
+ ea_name=$1
+ ea_config=$ANDROID_AVD_HOME/$ea_name.avd/config.ini
+ if avdmanager list avd | grep -q "Name: $ea_name$"; then
+ if [ -f "$ea_config" ] \
+ && grep -Eq "^image\.sysdir\.[0-9]+ *= *$SYSTEM_IMAGE$" "$ea_config"; then
+ return
+ fi
+ log "recreating stale AVD $ea_name"
+ avdmanager delete avd -n "$ea_name" >/dev/null
fi
+ log "creating AVD $ea_name"
+ echo no | avdmanager create avd -n "$ea_name" \
+ -k 'system-images;android-36;default;x86_64' -d pixel >/dev/null
+}
+
+for e2_avd in "$SOURCE_AVD" "$TARGET_AVD"; do
+ ensure_avd "$e2_avd"
+done
+
+stop_emulator "$SOURCE_SERIAL"
+stop_emulator "$TARGET_SERIAL"
+adb start-server >/dev/null
+sleep 2
+
+for e2_avd in "$SOURCE_AVD" "$TARGET_AVD"; do
+ e2_dir=$ANDROID_AVD_HOME/$e2_avd.avd
+ rm -f "$e2_dir/multiinstance.lock" "$e2_dir/hardware-qemu.ini.lock" \
+ "$e2_dir/snapshot.lock.lock" 2>/dev/null || true
+done
+
+log 'booting source on 5554'
+emulator -avd "$SOURCE_AVD" -port 5554 -no-window -no-audio -no-snapshot \
+ -wipe-data -memory 1536 -no-metrics -gpu swiftshader \
+ -no-boot-anim -accel on \
+ >"$TMPDIR/source-emulator.log" 2>&1 &
+SOURCE_PID=$!
+wait_boot "$SOURCE_SERIAL" "$SOURCE_PID" "$TMPDIR/source-emulator.log"
+wait_ui_ready "$SOURCE_SERIAL"
+
+log 'booting target on 5556'
+emulator -avd "$TARGET_AVD" -port 5556 -no-window -no-audio -no-snapshot \
+ -wipe-data -memory 1536 -no-metrics -gpu swiftshader \
+ -no-boot-anim -accel on \
+ >"$TMPDIR/target-emulator.log" 2>&1 &
+TARGET_PID=$!
+wait_boot "$TARGET_SERIAL" "$TARGET_PID" "$TMPDIR/target-emulator.log"
+wait_ui_ready "$TARGET_SERIAL"
+wait_wifi
+sleep 10
+
+# AOSP may show a one-off first-boot System UI timeout while software graphics
+# settles. Waiting is safe here, before any app or product assertion exists.
+for e2_serial in "$SOURCE_SERIAL" "$TARGET_SERIAL"; do
+ e2_ui=$TMPDIR/boot-${e2_serial}.xml
+ refresh_ui "$e2_serial" "$e2_ui"
+ e2_wait=$(find_node "$e2_ui" 'resource-id="android:id/aerr_wait"')
+ if [ -n "$e2_wait" ]; then
+ tap_node "$e2_serial" "$e2_wait"
+ sleep 5
+ fi
+done
+
+for e2_serial in "$SOURCE_SERIAL" "$TARGET_SERIAL"; do
+ adb -s "$e2_serial" shell settings put global window_animation_scale 0
+ adb -s "$e2_serial" shell settings put global transition_animation_scale 0
+ adb -s "$e2_serial" shell settings put global animator_duration_scale 0
+ adb -s "$e2_serial" shell settings put secure \
+ immersive_mode_confirmations confirmed
+done
+
+# ---- install clean source and target apps ----
+
+adb -s "$SOURCE_SERIAL" uninstall "$SOURCE_PKG" >/dev/null 2>&1 || true
+adb -s "$SOURCE_SERIAL" uninstall "$TARGET_PKG" >/dev/null 2>&1 || true
+adb -s "$TARGET_SERIAL" uninstall "$TARGET_PKG" >/dev/null 2>&1 || true
+adb -s "$SOURCE_SERIAL" install "$SOURCE_APK" >/dev/null
+adb -s "$SOURCE_SERIAL" install "$TARGET_APK" >/dev/null
+adb -s "$TARGET_SERIAL" install "$TARGET_APK" >/dev/null
+adb -s "$SOURCE_SERIAL" shell pm grant "$SOURCE_PKG" \
+ android.permission.POST_NOTIFICATIONS >/dev/null
+
+# ---- open the target's real pairing-code dialog ----
+
+log 'enabling Developer options and opening Wireless debugging'
+adb -s "$TARGET_SERIAL" shell settings put global development_settings_enabled 1
+
+TARGET_UI=$TMPDIR/target-ui.xml
+open_wireless_page "$TARGET_UI"
+enable_wireless "$TARGET_UI"
+CONNECT_PORT=$(wait_connect_port "$TARGET_UI")
+
+pair_pref=
+pair_deadline=$(( $(date +%s) + 30 ))
+while [ "$(date +%s)" -lt "$pair_deadline" ]; do
+ refresh_ui "$TARGET_SERIAL" "$TARGET_UI"
+ pair_pref=$(find_node "$TARGET_UI" 'text="Pair device with pairing code"')
+ [ -n "$pair_pref" ] && break
sleep 1
done
-if [ -z "$relink" ]; then
- log 'timed out waiting for auto-reconnect (no fresh video frame n=1)'
- adb -s "$SERIAL" logcat -d -s scrcpy-android > "$TMPDIR/logcat.reconnect" 2>&1 || true
- log "full app log: $TMPDIR/logcat.reconnect"
- tail -80 "$TMPDIR/logcat.reconnect" >&2 || true
+[ -n "$pair_pref" ] || {
+ log 'pairing-code preference was not visible on target'
+ cat "$TARGET_UI" >&2
exit 1
+}
+tap_node "$TARGET_SERIAL" "$pair_pref"
+
+PAIR_CODE=
+PAIR_ADDR=
+code_deadline=$(( $(date +%s) + 30 ))
+while [ "$(date +%s)" -lt "$code_deadline" ]; do
+ refresh_ui "$TARGET_SERIAL" "$TARGET_UI"
+ code_node=$(find_node "$TARGET_UI" 'resource-id="com.android.settings:id/pairing_code"')
+ addr_node=$(find_node "$TARGET_UI" 'resource-id="com.android.settings:id/ip_addr"')
+ PAIR_CODE=$(node_attr "$code_node" text)
+ PAIR_ADDR=$(node_attr "$addr_node" text)
+ [ "${#PAIR_CODE}" -eq 6 ] && [ -n "$PAIR_ADDR" ] && break
+ sleep 1
+done
+case "$PAIR_CODE" in
+ [0-9][0-9][0-9][0-9][0-9][0-9]) ;;
+ *) log "bad pairing code from Settings: $PAIR_CODE"; exit 1 ;;
+esac
+PAIR_PORT=${PAIR_ADDR##*:}
+case "$PAIR_PORT" in
+ ''|*[!0-9]*) log "bad pairing address from Settings: $PAIR_ADDR"; exit 1 ;;
+esac
+log "target connect=$CONNECT_PORT pair=$PAIR_PORT"
+
+adb -s "$TARGET_SERIAL" emu redir del tcp:$CONNECT_FORWARD >/dev/null 2>&1 || true
+adb -s "$TARGET_SERIAL" emu redir del tcp:$PAIR_FORWARD >/dev/null 2>&1 || true
+adb -s "$TARGET_SERIAL" emu redir add "tcp:$CONNECT_FORWARD:$CONNECT_PORT" >/dev/null
+adb -s "$TARGET_SERIAL" emu redir add "tcp:$PAIR_FORWARD:$PAIR_PORT" >/dev/null
+
+# ---- pair through the release app's ordinary form ----
+
+log 'entering pairing data in the source release app'
+adb -s "$SOURCE_SERIAL" logcat -c
+adb -s "$SOURCE_SERIAL" shell am start -W -n "$SOURCE_PKG/.Main" >/dev/null
+SOURCE_UI=$TMPDIR/source-ui.xml
+button_deadline=$(( $(date +%s) + 90 ))
+while [ "$(date +%s)" -lt "$button_deadline" ]; do
+ refresh_ui "$SOURCE_SERIAL" "$SOURCE_UI"
+ button_node=$(find_node "$SOURCE_UI" "resource-id=\"$SOURCE_PKG:id/pair\"")
+ [ "$(node_attr "$button_node" enabled)" = true ] && break
+ sleep 1
+done
+[ "$(node_attr "$button_node" enabled)" = true ] || {
+ log 'source pairing button never became enabled'
+ source_log | tail -80 >&2
+ exit 1
+}
+
+input_resource "$SOURCE_SERIAL" "$SOURCE_UI" \
+ "$SOURCE_PKG:id/device_address" "10.0.2.2:$CONNECT_FORWARD"
+input_resource "$SOURCE_SERIAL" "$SOURCE_UI" \
+ "$SOURCE_PKG:id/pair_port" "$PAIR_FORWARD"
+input_resource "$SOURCE_SERIAL" "$SOURCE_UI" \
+ "$SOURCE_PKG:id/pair_code" "$PAIR_CODE"
+tap_resource "$SOURCE_SERIAL" "$SOURCE_UI" "$SOURCE_PKG:id/pair"
+wait_log 'pair ok host=10.0.2.2' 45
+
+# Put a known moving image on the target before opening the saved row.
+start_pattern "$TARGET_UI"
+adb -s "$SOURCE_SERIAL" shell input keyevent KEYCODE_BACK >/dev/null
+sleep 1
+refresh_ui "$SOURCE_SERIAL" "$SOURCE_UI"
+row_node=$(find_node "$SOURCE_UI" "resource-id=\"$SOURCE_PKG:id/device_label\"")
+if [ -z "$row_node" ]; then
+ adb -s "$SOURCE_SERIAL" shell input swipe 500 1500 500 500 300
+ refresh_ui "$SOURCE_SERIAL" "$SOURCE_UI"
+ row_node=$(find_node "$SOURCE_UI" "resource-id=\"$SOURCE_PKG:id/device_label\"")
fi
-log 'auto-reconnect ok - fresh video frame n=1 after link loss'
+[ -n "$row_node" ] || {
+ log 'paired row did not appear in source UI'
+ cat "$SOURCE_UI" >&2
+ exit 1
+}
+[ "$(node_attr "$row_node" text)" = "10.0.2.2:$CONNECT_FORWARD" ] || {
+ log 'saved-device row contains the wrong endpoint'
+ cat "$SOURCE_UI" >&2
+ exit 1
+}
+tap_node "$SOURCE_SERIAL" "$row_node"
+
+# ---- require TLS, Opus playback, and actual rendered output ----
+
+wait_log 'adb connect ok' 90
+wait_log 'audio meta codec=opus' 30
+wait_log 'audio sink: opus configured' 30
+wait_log 'audio sink: playback started' 30
+wait_log 'video sink: rendered frame n=1' 30
+dismiss_fullscreen_prompt "$SOURCE_SERIAL" "$SOURCE_UI"
+
+sleep 1
+adb -s "$SOURCE_SERIAL" exec-out screencap -p > "$TMPDIR/paired.png"
+java "$ROOT/test-rig/Pixhash.java" "$TMPDIR/paired.png" --pattern
+
+log 'forwarding touch, key, and target clipboard through the control socket'
+adb -s "$SOURCE_SERIAL" logcat -c
+adb -s "$TARGET_SERIAL" logcat -c
+adb -s "$SOURCE_SERIAL" shell input tap 500 1000
+wait_target_log 'pattern: touch up' 15
+wait_target_log 'pattern: clipboard set' 15
+wait_log 'clipboard from target: 10 chars' 15
+adb -s "$SOURCE_SERIAL" shell input keyevent KEYCODE_A
+wait_target_log 'pattern: key up code=29' 15
+
+# Copy text in another source app while Mirror is backgrounded. Android 10+
+# only lets the focused app read the clipboard, so Mirror must read and send
+# it explicitly after regaining focus. Backgrounding also destroys the
+# SurfaceView Surface; resume must rebuild the decoder from cached codec config.
+log 'forwarding source clipboard and requiring Surface recreation'
+adb -s "$SOURCE_SERIAL" logcat -c
+adb -s "$TARGET_SERIAL" logcat -c
+if ! source_pattern_start=$(adb -s "$SOURCE_SERIAL" shell am start -W -n \
+ "$TARGET_PKG/invalid.lena.scrcpy.Pattern" \
+ --es clipboard_text source-e2e 2>&1); then
+ log 'source clipboard fixture failed to start'
+ printf '%s\n' "$source_pattern_start" >&2
+ exit 1
+fi
+wait_log 'mirror: surface destroyed' 30
+dismiss_fullscreen_prompt "$SOURCE_SERIAL" "$SOURCE_UI"
+adb -s "$SOURCE_SERIAL" shell input tap 500 1000
+wait_log 'pattern: clipboard set' 15
+reopen_source_from_notification "$SOURCE_UI"
+wait_log 'mirror: surface created' 30
+wait_log 'clipboard to target: 10 chars' 30
+wait_target_log 'pattern: clipboard from source' 30
+wait_log 'video sink: rendered frame n=1' 45
+adb -s "$SOURCE_SERIAL" exec-out screencap -p > "$TMPDIR/resumed.png"
+java "$ROOT/test-rig/Pixhash.java" "$TMPDIR/resumed.png" --pattern
+sleep 2
+if source_log | grep -q 'session: link lost'; then
+ log 'session dropped after Surface recreation'
+ source_log | tail -100 >&2
+ exit 1
+fi
+
+# A target resize rebuilds MediaCodec. The new decoder must receive the
+# cached codec-config packet that was sent only once at stream startup.
+log 'rotating target and requiring a resized rendered stream'
+adb -s "$SOURCE_SERIAL" logcat -c
+ROTATE_BEFORE=$(target_display)
+log "target display before rotation: ${ROTATE_BEFORE:-unknown}"
+adb -s "$TARGET_SERIAL" shell wm user-rotation lock 1
+wait_display_change "$ROTATE_BEFORE"
+wait_log 'video resize' "$E2E_RESIZE_DEADLINE"
+wait_log 'video sink: rendered frame n=1' 45
+adb -s "$SOURCE_SERIAL" exec-out screencap -p > "$TMPDIR/rotated.png"
+java "$ROOT/test-rig/Pixhash.java" "$TMPDIR/rotated.png" --pattern
+adb -s "$TARGET_SERIAL" logcat -c
+adb -s "$SOURCE_SERIAL" shell input tap 500 1000
+wait_target_log 'pattern: touch up' 15
+
+# ---- kill only scrcpy and require a fresh generation ----
+
+log 'killing target scrcpy server and requiring automatic reconnect'
+adb -s "$SOURCE_SERIAL" logcat -c
+adb -s "$TARGET_SERIAL" shell 'pkill -f com.genymobile.scrcpy.Server' || true
+wait_log 'session: link lost' 45
+wait_log 'spawn server ver=' 60
+wait_log 'video sink: rendered frame n=1' 45
+adb -s "$SOURCE_SERIAL" exec-out screencap -p > "$TMPDIR/reconnected.png"
+java "$ROOT/test-rig/Pixhash.java" "$TMPDIR/reconnected.png" --pattern
-# ---- 10. cleanup ----
-log "full app log: $TMPDIR/logcat.scrcpy"
-adb -s "$SERIAL" logcat -d -s scrcpy-android > "$TMPDIR/logcat.scrcpy" 2>&1 || true
-log 'adb emu kill'
-adb -s "$SERIAL" emu kill || true
+# ---- restart adbd and require reconnect with its new ephemeral certificate ----
-log 'e2e: pass'
+log 'restarting target adbd to change its process-scoped TLS identity'
+adb -s "$SOURCE_SERIAL" logcat -c
+adb -s "$TARGET_SERIAL" root >/dev/null 2>&1 || true
+wait_boot "$TARGET_SERIAL"
+adb -s "$TARGET_SERIAL" shell settings put global adb_wifi_enabled 0
+wait_tls_stopped
+open_wireless_page "$TARGET_UI"
+enable_wireless "$TARGET_UI"
+NEW_CONNECT_PORT=$(wait_connect_port "$TARGET_UI")
+adb -s "$TARGET_SERIAL" emu redir del tcp:$CONNECT_FORWARD >/dev/null 2>&1 || true
+adb -s "$TARGET_SERIAL" emu redir add "tcp:$CONNECT_FORWARD:$NEW_CONNECT_PORT" >/dev/null
+start_pattern "$TARGET_UI"
+wait_log 'session: link lost' 90
+wait_log 'adb connect ok' 90
+wait_log 'video sink: rendered frame n=1' 60
+adb -s "$SOURCE_SERIAL" exec-out screencap -p > "$TMPDIR/adbd-restarted.png"
+java "$ROOT/test-rig/Pixhash.java" "$TMPDIR/adbd-restarted.png" --pattern
+source_log > "$TMPDIR/logcat.scrcpy"
+log 'pairing, TLS, Opus, input, two-way clipboard, resize, render, and reconnect: pass'
diff --git a/test-rig/record.sh b/test-rig/record.sh
deleted file mode 100755
index 82e31dc..0000000
--- a/test-rig/record.sh
+++ /dev/null
@@ -1,178 +0,0 @@
-#!/bin/sh
-# Boot the emulator + Mirror, then capture frames via `screencap` and
-# inject taps/keys through the app. Frames are PNGs in $TMPDIR/frames;
-# the host side stitches them into /work/output.mp4 with ffmpeg.
-#
-# Why not `screenrecord`? On the AOSP x86_64 system image with a
-# software GPU, screenrecord composes the system layer but misses
-# SurfaceView's hardware layer - the mirror's decoded surface ends
-# up as a black rectangle in the recording. screencap goes through
-# a different capture path that does include the SurfaceView, as
-# the pixhash check in ./test e2e already verifies.
-#
-# Source == target: every injected tap/key fires both locally and
-# over the wire - visible in the nested mirror, which is the demo.
-
-set -eu
-
-ROOT=/work
-# Debug applicationId (note the .debug suffix). Activity classes live in
-# the invalid.lena.scrcpy namespace, which carries no suffix.
-PKG=invalid.lena.scrcpy.debug
-AVD=scrcpy-test
-SERIAL=emulator-5554
-APK=$ROOT/app/build/outputs/apk/debug/app-debug.apk
-TMPDIR=$ROOT/.tools/e2e
-FRAMES=$TMPDIR/frames
-OUT=$ROOT/output.mp4
-FPS=8
-mkdir -p "$TMPDIR" "$FRAMES"
-rm -f "$FRAMES"/*.png 2>/dev/null || true
-
-log() { printf 'record: %s\n' "$*" >&2; }
-
-# ---- 0. server jar ----
-[ -f "$ROOT/app/src/main/assets/scrcpy-server.jar" ] || "$ROOT/scripts/update-server"
-
-# ---- 1. build APK ----
-log 'gradle :app:assembleDebug'
-"$ROOT/gradlew" --no-daemon -q :app:assembleDebug
-
-# ---- 2. AVD + boot ----
-if ! avdmanager list avd | grep -q "Name: $AVD$"; then
- log "creating avd $AVD"
- echo no | avdmanager create avd -n "$AVD" -k 'system-images;android-35;default;x86_64' -d pixel >/dev/null
-fi
-AVD_DIR="$ANDROID_AVD_HOME/$AVD.avd"
-rm -f "$AVD_DIR"/multiinstance.lock "$AVD_DIR"/hardware-qemu.ini.lock \
- "$AVD_DIR"/snapshot.lock.lock "$AVD_DIR"/read-snapshot.txt 2>/dev/null || true
-rm -rf "$HOME/.android/avd/running" 2>/dev/null || true
-
-if ! pgrep -f "emulator.*-avd $AVD" >/dev/null; then
- log 'booting emulator'
- emulator -avd "$AVD" -no-window -no-audio -no-snapshot -gpu swiftshader_indirect \
- -no-boot-anim -accel on >"$TMPDIR/emulator.log" 2>&1 &
-fi
-adb -s "$SERIAL" wait-for-device
-until [ "$(adb -s "$SERIAL" shell getprop sys.boot_completed | tr -d '\r')" = 1 ]; do
- sleep 2
-done
-
-# ---- 3. root + install ----
-log 'adb root'
-adb -s "$SERIAL" root
-adb -s "$SERIAL" wait-for-device
-log "install $APK"
-adb -s "$SERIAL" install -r "$APK" >/dev/null
-
-# ---- 4. bootstrap adb_keys via the app's own keypair ----
-adb -s "$SERIAL" shell am start -W -n "$PKG/invalid.lena.scrcpy.Main" >/dev/null
-sleep 1
-adb -s "$SERIAL" shell "run-as $PKG cat files/adbkey" > "$TMPDIR/adbkey.der"
-[ -s "$TMPDIR/adbkey.der" ] || { log 'adbkey was empty'; exit 1; }
-java "$ROOT/test-rig/Keygen.java" "$TMPDIR/adbkey.der" > "$TMPDIR/adb_keys.line"
-adb -s "$SERIAL" push "$TMPDIR/adb_keys.line" /sdcard/adb_keys.line >/dev/null
-adb -s "$SERIAL" shell "cat /sdcard/adb_keys.line >> /data/misc/adb/adb_keys \
- && chown system:shell /data/misc/adb/adb_keys \
- && chmod 640 /data/misc/adb/adb_keys \
- && rm /sdcard/adb_keys.line"
-
-# Dismiss the "Viewing full screen" system toast that pops up the
-# first time an activity goes immersive - otherwise it covers the top
-# strip of the mirror in our captures.
-adb -s "$SERIAL" shell settings put global policy_control \
- 'immersive.full=*' >/dev/null 2>&1 || true
-
-# ---- 5. launch Mirror ----
-log 'launching Mirror against 127.0.0.1:5555'
-adb -s "$SERIAL" logcat -c
-adb -s "$SERIAL" shell am start -n "$PKG/invalid.lena.scrcpy.Mirror" \
- --es host 127.0.0.1 --ei port 5555 >/dev/null
-
-# ---- 6. wait for first frame ----
-deadline=$(( $(date +%s) + ${E2E_DEADLINE:-120} ))
-while [ "$(date +%s)" -lt "$deadline" ]; do
- if adb -s "$SERIAL" logcat -d -s scrcpy-android | tail -500 | grep -q 'video frame n=1'; then
- ok=1; break
- fi
- sleep 1
-done
-if [ "${ok:-}" != 1 ]; then
- log 'timed out waiting for first frame; full app log:'
- adb -s "$SERIAL" logcat -d -s scrcpy-android > "$TMPDIR/logcat.record.scrcpy" 2>&1 || true
- tail -60 "$TMPDIR/logcat.record.scrcpy" >&2 || true
- exit 1
-fi
-log 'first frame ok - settling 5 s for the immersive toast to fade'
-# The first immersive activity on a fresh AVD draws a top-of-screen
-# "Viewing full screen" toast that fades after ~4 s. We deliberately
-# do NOT try to dismiss it by injecting touch - anything at y ~= 0
-# pulls the notification shade, anything near the bottom hits the nav
-# bar, and either ends Mirror's foreground status. Wait it out instead.
-sleep 5
-
-# ---- 7. screencap loop in background while we inject events ----
-# Mirror's overlay TextViews (target / frame counts / last event)
-# live in the regular view hierarchy, so screencap captures them
-# fine - the actual decoded video underneath stays black to capture
-# but the overlay is the in-test proof.
-log "capturing frames @ ${FPS} fps via screencap"
-touch "$TMPDIR/recording"
-(
- i=0
- while [ -f "$TMPDIR/recording" ]; do
- n=$(printf '%05d' "$i")
- adb -s "$SERIAL" exec-out screencap -p > "$FRAMES/f_$n.png" 2>/dev/null || true
- i=$((i + 1))
- done
-) &
-CAP_PID=$!
-sleep 1
-
-log 'taps'
-adb -s "$SERIAL" shell input tap 540 960
-sleep 0.6
-adb -s "$SERIAL" shell input tap 270 480
-sleep 0.6
-adb -s "$SERIAL" shell input tap 810 1440
-sleep 0.6
-
-log 'swipe'
-adb -s "$SERIAL" shell input swipe 200 1000 880 1000 250
-sleep 0.6
-
-log 'dpad keys'
-for k in KEYCODE_DPAD_RIGHT KEYCODE_DPAD_LEFT KEYCODE_DPAD_DOWN KEYCODE_DPAD_UP; do
- adb -s "$SERIAL" shell input keyevent "$k"
- sleep 0.3
-done
-
-log 'alphanumerics'
-for k in KEYCODE_S KEYCODE_C KEYCODE_R KEYCODE_C KEYCODE_P KEYCODE_Y; do
- adb -s "$SERIAL" shell input keyevent "$k"
- sleep 0.25
-done
-
-sleep 1
-adb -s "$SERIAL" shell input tap 540 960
-sleep 2
-
-# ---- 8. stop capture, stitch ----
-rm -f "$TMPDIR/recording"
-wait "$CAP_PID" 2>/dev/null || true
-
-count=$(ls "$FRAMES"/*.png 2>/dev/null | wc -l)
-log "captured $count frames"
-[ "$count" -gt 0 ] || { log 'no frames captured'; exit 1; }
-
-log "stitching $FRAMES/*.png -> $OUT"
-ffmpeg -y -framerate "$FPS" -pattern_type glob -i "$FRAMES/f_*.png" \
- -c:v libx264 -pix_fmt yuv420p -movflags +faststart "$OUT" \
- >/dev/null 2>&1
-
-# ---- 9. cleanup ----
-adb -s "$SERIAL" logcat -d -s scrcpy-android > "$TMPDIR/logcat.record.scrcpy" 2>&1 || true
-log "app log: $TMPDIR/logcat.record.scrcpy"
-adb -s "$SERIAL" emu kill || true
-log "wrote $(wc -c <"$OUT") bytes to $OUT"
-log 'done'
diff --git a/test-rig/run.sh b/test-rig/run.sh
index b55ce1e..d4b7b36 100755
--- a/test-rig/run.sh
+++ b/test-rig/run.sh
@@ -1,6 +1,6 @@
#!/bin/sh
# In-container orchestrator. Called by ./test from outside.
-# /work/test-rig/run.sh unit|e2e|all|record|screenshots|apk
+# /work/test-rig/run.sh unit|server|e2e|all|screenshots|apk
#
# Expects to run inside the scrcpy-android-test docker image with
# /work mounted at the project root.
@@ -9,25 +9,31 @@ set -eu
cmd="${1:-unit}"
-mkdir -p /work/.tools/home /work/.tools/gradle-home /work/.tools/avd
+mkdir -p /work/.tools/gradle-home /work/.tools/avd /work/.tools/home/.android
export HOME="/work/.tools/home"
+export ANDROID_USER_HOME="/work/.tools/home/.android"
+export ANDROID_SDK_HOME="/work/.tools/home"
export GRADLE_USER_HOME="/work/.tools/gradle-home"
export ANDROID_AVD_HOME="/work/.tools/avd"
unit() {
- echo "test: gradle :app:test"
- /work/gradlew --no-daemon -q :app:test
+ echo "test: gradle :app:test :adb:test :app:lint"
+ /work/gradlew --no-daemon -q :app:test :adb:test :app:lint
}
e2e() {
/work/test-rig/e2e.sh
}
+server() {
+ /work/scripts/check-server-source
+}
+
case "$cmd" in
unit) unit ;;
+ server) server ;;
e2e) e2e ;;
- all) unit && e2e ;;
- record) /work/test-rig/record.sh ;;
+ all) unit && server && e2e ;;
screenshots) /work/test-rig/screenshots.sh ;;
apk) /work/test-rig/build-apk.sh ;;
*) echo "run.sh: bad cmd $cmd" >&2; exit 2 ;;
diff --git a/test-rig/screenshots.sh b/test-rig/screenshots.sh
index 5da6349..46e2153 100755
--- a/test-rig/screenshots.sh
+++ b/test-rig/screenshots.sh
@@ -1,7 +1,7 @@
#!/bin/sh
# Regenerate the F-Droid listing screenshots.
#
-# Boots a pixel_6 AVD on the same AOSP API-35 image the e2e tier uses -
+# Boots a pixel_6 AVD on the same AOSP API-36 image the e2e tier uses -
# natively 1080x2400, the geometry the tracked PNGs already have - and
# screencaps Main and Settings into
# fastlane/metadata/android/en-US/images/phoneScreenshots/.
@@ -25,12 +25,79 @@ SERIAL=emulator-5554
APK=$ROOT/app/build/outputs/apk/debug/app-debug.apk
SHOTS=$ROOT/fastlane/metadata/android/en-US/images/phoneScreenshots
TMPDIR=$ROOT/.tools/shots
+STAGE=$TMPDIR/output
WIDTH=1080
HEIGHT=2400
-mkdir -p "$TMPDIR" "$SHOTS"
+SYSTEM_IMAGE=system-images/android-36/default/x86_64/
+# Isolate emulator authorization from any ADB server running on the host.
+export ADB_SERVER_SOCKET=tcp:localhost:5039
+export ANDROID_ADB_SERVER_ADDRESS=localhost
+export ANDROID_ADB_SERVER_PORT=5039
+mkdir -p "$TMPDIR" "$STAGE" "$SHOTS"
log() { printf 'screenshots: %s\n' "$*" >&2; }
+cleanup() {
+ adb -s "$SERIAL" emu kill >/dev/null 2>&1 || true
+ adb kill-server >/dev/null 2>&1 || true
+}
+trap cleanup EXIT INT TERM
+
+wait_boot() {
+ # A clean API-36 emulator commonly needs more than two minutes on a
+ # loaded software-rendering host. Match the proven e2e boot margin.
+ wb_deadline=$(( $(date +%s) + 300 ))
+ while [ "$(date +%s)" -lt "$wb_deadline" ]; do
+ if [ "$(adb -s "$SERIAL" get-state 2>/dev/null || true)" = device ]; then
+ wb_done=$(adb -s "$SERIAL" shell getprop sys.boot_completed \
+ 2>/dev/null | tr -d '\r' || true)
+ [ "$wb_done" = 1 ] && return
+ fi
+ sleep 2
+ done
+ log "$SERIAL did not finish booting"
+ exit 1
+}
+
+dump_ui() {
+ du_file=$1
+ du_tmp=$du_file.tmp
+ du_deadline=$(( $(date +%s) + 15 ))
+ while [ "$(date +%s)" -lt "$du_deadline" ]; do
+ if adb -s "$SERIAL" shell uiautomator dump /sdcard/scrcpy-shots-ui.xml \
+ >/dev/null 2>&1 \
+ && adb -s "$SERIAL" exec-out cat /sdcard/scrcpy-shots-ui.xml \
+ > "$du_tmp" 2>/dev/null \
+ && grep -q '<hierarchy' "$du_tmp"; then
+ mv "$du_tmp" "$du_file"
+ return
+ fi
+ sleep 1
+ done
+ rm -f "$du_tmp"
+ log 'could not capture UI hierarchy'
+ exit 1
+}
+
+dismiss_systemui_anr() {
+ da_ui=$TMPDIR/systemui-anr.xml
+ dump_ui "$da_ui"
+ da_node=$(tr '>' '\n' < "$da_ui" | awk \
+ 'index($0, "resource-id=\"android:id/aerr_wait\"") { print; exit }')
+ [ -n "$da_node" ] || return 0
+ da_bounds=$(printf '%s\n' "$da_node" \
+ | sed -n 's/.* bounds="\([^\"]*\)".*/\1/p')
+ da_coords=$(printf '%s\n' "$da_bounds" | sed -n \
+ 's/^\[\([0-9][0-9]*\),\([0-9][0-9]*\)\]\[\([0-9][0-9]*\),\([0-9][0-9]*\)\]$/\1 \2 \3 \4/p')
+ [ -n "$da_coords" ] || return 0
+ # Split the validated numeric tuple into four shell arguments.
+ # shellcheck disable=SC2086
+ set -- $da_coords
+ log 'waiting for first-boot System UI ANR to recover'
+ adb -s "$SERIAL" shell input tap $(( ($1 + $3) / 2 )) $(( ($2 + $4) / 2 ))
+ sleep 5
+}
+
# ---- 0. server jar ----
[ -f "$ROOT/app/src/main/assets/scrcpy-server.jar" ] || "$ROOT/scripts/update-server"
@@ -39,30 +106,34 @@ log 'gradle :app:assembleDebug'
"$ROOT/gradlew" --no-daemon -q :app:assembleDebug
# ---- 2. AVD + boot ----
+AVD_DIR="$ANDROID_AVD_HOME/$AVD.avd"
+if avdmanager list avd | grep -q "Name: $AVD$"; then
+ if [ ! -f "$AVD_DIR/config.ini" ] \
+ || ! grep -Eq "^image\.sysdir\.[0-9]+ *= *$SYSTEM_IMAGE$" \
+ "$AVD_DIR/config.ini"; then
+ log "recreating stale avd $AVD"
+ avdmanager delete avd -n "$AVD" >/dev/null
+ fi
+fi
if ! avdmanager list avd | grep -q "Name: $AVD$"; then
log "creating avd $AVD (pixel_6, ${WIDTH}x${HEIGHT})"
- echo no | avdmanager create avd -n "$AVD" -k 'system-images;android-35;default;x86_64' -d pixel_6 >/dev/null
+ echo no | avdmanager create avd -n "$AVD" -k 'system-images;android-36;default;x86_64' -d pixel_6 >/dev/null
fi
# Clean stale lock files from a previously aborted run; snapshot.lock.lock
# and read-snapshot.txt in particular make the next boot die with
# "a snapshot operation is pending and timeout has expired".
-AVD_DIR="$ANDROID_AVD_HOME/$AVD.avd"
rm -f "$AVD_DIR"/multiinstance.lock "$AVD_DIR"/hardware-qemu.ini.lock \
"$AVD_DIR"/snapshot.lock.lock "$AVD_DIR"/read-snapshot.txt 2>/dev/null || true
-rm -rf "$HOME/.android/avd/running" 2>/dev/null || true
+rm -rf "$ANDROID_USER_HOME/avd/running" 2>/dev/null || true
+adb start-server >/dev/null
if ! pgrep -f "emulator.*-avd $AVD" >/dev/null; then
log 'booting emulator (no window, no snapshot)'
- emulator -avd "$AVD" -no-window -no-audio -no-snapshot -gpu swiftshader_indirect \
+ emulator -avd "$AVD" -no-window -no-audio -no-snapshot -gpu swiftshader \
-no-boot-anim -accel on >"$TMPDIR/emulator.log" 2>&1 &
fi
-adb -s "$SERIAL" wait-for-device
-until [ "$(adb -s "$SERIAL" shell getprop sys.boot_completed | tr -d '\r')" = 1 ]; do
- sleep 2
-done
-
-trap 'adb -s "$SERIAL" emu kill >/dev/null 2>&1 || true' EXIT INT TERM
+wait_boot
# ---- 3. install ----
log "install $APK"
@@ -72,38 +143,93 @@ adb -s "$SERIAL" install -r "$APK" >/dev/null
adb -s "$SERIAL" shell pm clear "$PKG" >/dev/null
# ---- 4. capture ----
-# One activity per shot: start it, let the window settle, screencap.
-# SettingsActivity is not exported, so this needs adbd running as root.
-log 'adb root'
-adb -s "$SERIAL" root
-adb -s "$SERIAL" wait-for-device
+# Start from the exported launcher activity. Settings is deliberately not
+# exported, so open it through the same button a user taps.
+
+open_settings() {
+ os_ui=$TMPDIR/settings-button.xml
+ dump_ui "$os_ui"
+ os_node=$(tr '>' '\n' < "$os_ui" | awk -v id="$PKG:id/settings" \
+ 'index($0, "resource-id=\"" id "\"") { print; exit }')
+ [ -n "$os_node" ] || {
+ log 'settings button was not visible'
+ cat "$os_ui" >&2
+ exit 1
+ }
+ os_bounds=$(printf '%s\n' "$os_node" \
+ | sed -n 's/.* bounds="\([^\"]*\)".*/\1/p')
+ os_coords=$(printf '%s\n' "$os_bounds" | sed -n \
+ 's/^\[\([0-9][0-9]*\),\([0-9][0-9]*\)\]\[\([0-9][0-9]*\),\([0-9][0-9]*\)\]$/\1 \2 \3 \4/p')
+ [ -n "$os_coords" ] || {
+ log "could not parse settings-button bounds: $os_bounds"
+ exit 1
+ }
+ # Split the validated numeric tuple into four shell arguments.
+ # shellcheck disable=SC2086
+ set -- $os_coords
+ os_x=$(( ($1 + $3) / 2 ))
+ os_y=$(( ($2 + $4) / 2 ))
+ os_deadline=$(( $(date +%s) + 15 ))
+ while [ "$(date +%s)" -lt "$os_deadline" ]; do
+ if adb -s "$SERIAL" shell input tap "$os_x" "$os_y" 2>/dev/null; then
+ return
+ fi
+ sleep 1
+ done
+ log 'could not tap the settings button'
+ exit 1
+}
shot() {
activity=$1
out=$2
log "capture $activity -> $out"
- adb -s "$SERIAL" shell am force-stop "$PKG"
- adb -s "$SERIAL" shell am start -W -n "$PKG/invalid.lena.scrcpy.$activity" >/dev/null
+ case "$activity" in
+ Main)
+ adb -s "$SERIAL" shell am force-stop "$PKG"
+ if ! start_output=$(adb -s "$SERIAL" shell am start -W -n \
+ "$PKG/invalid.lena.scrcpy.Main" 2>&1); then
+ log 'launcher activity failed to start'
+ printf '%s\n' "$start_output" >&2
+ exit 1
+ fi ;;
+ SettingsActivity)
+ open_settings ;;
+ *)
+ log "unsupported screenshot activity: $activity"
+ exit 1 ;;
+ esac
sleep 4
+ dismiss_systemui_anr
# Anything else on top - a system dialog, an ANR, a failed launch -
# means the shot is not the screen we asked for. Fail, do not ship it.
- focus=$(adb -s "$SERIAL" shell dumpsys window \
- | sed -n 's/.*mCurrentFocus=[^ ]* [^ ]* \([^ }]*\).*/\1/p' | head -1)
+ focus_line=$(adb -s "$SERIAL" shell dumpsys window \
+ | sed -n '/mCurrentFocus=/ { p; q; }')
+ focus=$(printf '%s\n' "$focus_line" \
+ | sed -n 's/.* \([^ ]*\/[^ }]*\).*/\1/p')
case "$focus" in
- "$PKG/"*) ;;
- *) log "focused window is '$focus', want $PKG/*"; exit 1 ;;
+ "$PKG/invalid.lena.scrcpy.$activity") ;;
+ *)
+ log "focused window is '$focus', want $PKG/invalid.lena.scrcpy.$activity"
+ log "$focus_line"
+ adb -s "$SERIAL" logcat -d -b crash 2>/dev/null | tail -80 >&2 || true
+ exit 1 ;;
esac
- adb -s "$SERIAL" exec-out screencap -p > "$SHOTS/$out"
- [ -s "$SHOTS/$out" ] || { log "$out is empty"; exit 1; }
+ staged=$STAGE/$out
+ adb -s "$SERIAL" exec-out screencap -p > "$staged"
+ [ -s "$staged" ] || { log "$out is empty"; exit 1; }
# Reject anything that is not the panel we expect: a wrong-sized PNG
# means the device profile changed and the shot is unusable.
got=$(ffprobe -v error -select_streams v:0 \
- -show_entries stream=width,height -of csv=p=0:s=x "$SHOTS/$out")
+ -show_entries stream=width,height -of csv=p=0:s=x "$staged")
[ "$got" = "${WIDTH}x${HEIGHT}" ] || { log "$out is $got, want ${WIDTH}x${HEIGHT}"; exit 1; }
}
shot Main 1.png
shot SettingsActivity 2.png
+install -m 0644 "$STAGE/1.png" "$SHOTS/1.png"
+install -m 0644 "$STAGE/2.png" "$SHOTS/2.png"
+
log "wrote $SHOTS/1.png $SHOTS/2.png"
log 'done'
diff --git a/test-rig/sdk-packages-e2e.txt b/test-rig/sdk-packages-e2e.txt
index 4732f6e..bcdfc1a 100644
--- a/test-rig/sdk-packages-e2e.txt
+++ b/test-rig/sdk-packages-e2e.txt
@@ -1,2 +1,2 @@
emulator
-system-images;android-35;default;x86_64
+system-images;android-36;default;x86_64
diff --git a/test-rig/sdk-packages.txt b/test-rig/sdk-packages.txt
index 48d2636..ca96226 100644
--- a/test-rig/sdk-packages.txt
+++ b/test-rig/sdk-packages.txt
@@ -1,4 +1,4 @@
platform-tools
-platforms;android-35
-build-tools;34.0.0
+platforms;android-36
build-tools;35.0.0
+build-tools;36.0.0