diff options
Diffstat (limited to 'test-rig')
| -rw-r--r-- | test-rig/Dockerfile | 8 | ||||
| -rw-r--r-- | test-rig/Keygen.java | 92 | ||||
| -rw-r--r-- | test-rig/Pixhash.java | 68 | ||||
| -rwxr-xr-x | test-rig/build-apk.sh | 4 | ||||
| -rwxr-xr-x | test-rig/e2e.sh | 929 | ||||
| -rwxr-xr-x | test-rig/record.sh | 178 | ||||
| -rwxr-xr-x | test-rig/run.sh | 18 | ||||
| -rwxr-xr-x | test-rig/screenshots.sh | 178 | ||||
| -rw-r--r-- | test-rig/sdk-packages-e2e.txt | 2 | ||||
| -rw-r--r-- | test-rig/sdk-packages.txt | 4 |
10 files changed, 1025 insertions, 456 deletions
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 |