diff options
Diffstat (limited to 'test-rig')
| -rw-r--r-- | test-rig/Dockerfile | 43 | ||||
| -rw-r--r-- | test-rig/Keygen.java | 92 | ||||
| -rw-r--r-- | test-rig/Pixhash.java | 54 | ||||
| -rwxr-xr-x | test-rig/build-apk.sh | 32 | ||||
| -rwxr-xr-x | test-rig/e2e.sh | 172 | ||||
| -rwxr-xr-x | test-rig/record.sh | 178 | ||||
| -rwxr-xr-x | test-rig/run.sh | 33 | ||||
| -rw-r--r-- | test-rig/sdk-packages.txt | 5 |
8 files changed, 609 insertions, 0 deletions
diff --git a/test-rig/Dockerfile b/test-rig/Dockerfile new file mode 100644 index 0000000..759a09d --- /dev/null +++ b/test-rig/Dockerfile @@ -0,0 +1,43 @@ +# scrcpy-android test image. +# +# Bundles JDK 17, Android cmdline-tools, platform 35, build-tools 35, +# emulator, and an x86_64 default API-35 system image (for e2e). +# Project is mounted at /work; runs in --rm mode. + +FROM debian:bookworm-slim + +ARG SDK_VER=11076708 +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + openjdk-17-jdk-headless \ + curl unzip ca-certificates \ + libpulse0 libxkbcommon0 libgl1 libnss3 libasound2 libdbus-1-3 \ + qemu-system-x86 qemu-utils \ + procps \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +ENV ANDROID_SDK_ROOT=/opt/android-sdk +ENV ANDROID_HOME=$ANDROID_SDK_ROOT + +RUN mkdir -p $ANDROID_SDK_ROOT/cmdline-tools \ + && curl -fsSL "https://dl.google.com/android/repository/commandlinetools-linux-${SDK_VER}_latest.zip" \ + -o /tmp/clt.zip \ + && unzip -q /tmp/clt.zip -d $ANDROID_SDK_ROOT/cmdline-tools \ + && mv $ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools $ANDROID_SDK_ROOT/cmdline-tools/latest \ + && rm /tmp/clt.zip + +ENV PATH=$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools:$ANDROID_SDK_ROOT/emulator:$PATH + +COPY sdk-packages.txt /tmp/sdk-packages.txt +RUN yes | sdkmanager --licenses >/dev/null \ + && sdkmanager $(tr '\n' ' ' < /tmp/sdk-packages.txt) >/dev/null \ + && chmod -R a+rwX "$ANDROID_SDK_ROOT" + +# Pre-create the default AVD location and let gradle put its cache here. +ENV GRADLE_USER_HOME=/work/.tools/gradle-home +ENV ANDROID_AVD_HOME=/work/.tools/avd + +WORKDIR /work diff --git a/test-rig/Keygen.java b/test-rig/Keygen.java new file mode 100644 index 0000000..8cb90a6 --- /dev/null +++ b/test-rig/Keygen.java @@ -0,0 +1,92 @@ +// 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 new file mode 100644 index 0000000..47ab8b8 --- /dev/null +++ b/test-rig/Pixhash.java @@ -0,0 +1,54 @@ +// 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. +// +// java test-rig/Pixhash.java <png> # default min=5% +// java test-rig/Pixhash.java <png> --min 1 +// +// We avoid AWT's BufferedImage because some headless JDKs ship without +// it; ImageIO is in java.desktop which IS present in the openjdk-17-jdk +// package in our image, so it's fine. + +import java.io.File; +import java.io.IOException; +import javax.imageio.ImageIO; +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); + } + double minPercent = 5.0; + for (int i = 1; i < args.length; i++) { + if ("--min".equals(args[i]) && i + 1 < args.length) { + minPercent = Double.parseDouble(args[++i]); + } + } + + BufferedImage img = ImageIO.read(new File(args[0])); + if (img == null) { + System.err.println("pixhash: could not read " + args[0]); + System.exit(2); + } + + int w = img.getWidth(), h = img.getHeight(); + int ref = img.getRGB(0, 0); + long total = (long) w * h; + long diff = 0; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + if (img.getRGB(x, y) != ref) diff++; + } + } + double pct = 100.0 * diff / total; + System.out.printf("pixhash: %d/%d differ from (0,0)=%08x => %.2f%%%n", + diff, total, ref, pct); + if (pct < minPercent) { + System.err.printf("pixhash: only %.2f%% differ, need %.2f%%%n", pct, minPercent); + System.exit(1); + } + } +} diff --git a/test-rig/build-apk.sh b/test-rig/build-apk.sh new file mode 100755 index 0000000..18b3969 --- /dev/null +++ b/test-rig/build-apk.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Smoke test: build a signed release APK from inside the rig using a +# throwaway keystore. Validates that scripts/build-apk + the gradle +# 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) + +set -eu + +KS="/work/.tools/release-smoke.p12" +KS_PASS="changeit" +KEY_ALIAS="scrcpy-android-smoke" + +mkdir -p /work/.tools + +if [ ! -f "$KS" ]; then + echo "build-apk.sh: generating throwaway keystore at $KS" + keytool -genkey -noprompt \ + -keystore "$KS" -storetype PKCS12 \ + -storepass "$KS_PASS" -keypass "$KS_PASS" \ + -alias "$KEY_ALIAS" -keyalg RSA -keysize 2048 -validity 365 \ + -dname "CN=scrcpy-android smoke, OU=test, O=local, C=US" \ + >/dev/null +fi + +export KEYSTORE_PATH="$KS" +export KEYSTORE_PASS="$KS_PASS" +export KEY_ALIAS="$KEY_ALIAS" +export KEY_PASS="$KS_PASS" + +exec /work/scripts/build-apk diff --git a/test-rig/e2e.sh b/test-rig/e2e.sh new file mode 100755 index 0000000..ae2a1a8 --- /dev/null +++ b/test-rig/e2e.sh @@ -0,0 +1,172 @@ +#!/bin/sh +# Single-emulator self-mirror 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. +# +# Runs inside the test image; orchestrated by ../test. + +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 +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" + +# ---- 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 + +# 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 + +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 + +# ---- 3. root (emulator adbd already listens on TCP 5555) ---- +log 'adb root' +adb -s "$SERIAL" root +adb -s "$SERIAL" wait-for-device + +# ---- 4. install ---- +log "install $APK" +adb -s "$SERIAL" install -r "$APK" >/dev/null + +# ---- 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 + +# ---- 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 + exit 1 +fi + +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 + fi + if printf '%s' "$line" | grep -q 'session: gave up'; then + log 'session gave up; logcat:' + printf '%s\n' "$line" | tail -50 >&2 + exit 1 + fi + 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 + exit 1 +fi +log 'video frame n=1 - proceeding to screencap' + +# Give the decoder a moment to actually render to the surface. +sleep 1 + +# ---- 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 + fi + 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 + exit 1 +fi +log 'auto-reconnect ok - fresh video frame n=1 after link loss' + +# ---- 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 + +log 'e2e: pass' diff --git a/test-rig/record.sh b/test-rig/record.sh new file mode 100755 index 0000000..82e31dc --- /dev/null +++ b/test-rig/record.sh @@ -0,0 +1,178 @@ +#!/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 new file mode 100755 index 0000000..ff63c21 --- /dev/null +++ b/test-rig/run.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# In-container orchestrator. Called by ./test from outside. +# /work/test-rig/run.sh unit|e2e|all +# +# Expects to run inside the scrcpy-android-test docker image with +# /work mounted at the project root. + +set -eu + +cmd="${1:-unit}" + +mkdir -p /work/.tools/home /work/.tools/gradle-home /work/.tools/avd +export 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 +} + +e2e() { + /work/test-rig/e2e.sh +} + +case "$cmd" in + unit) unit ;; + e2e) e2e ;; + all) unit && e2e ;; + record) /work/test-rig/record.sh ;; + apk) /work/test-rig/build-apk.sh ;; + *) echo "run.sh: bad cmd $cmd" >&2; exit 2 ;; +esac diff --git a/test-rig/sdk-packages.txt b/test-rig/sdk-packages.txt new file mode 100644 index 0000000..97ed4a6 --- /dev/null +++ b/test-rig/sdk-packages.txt @@ -0,0 +1,5 @@ +platform-tools +platforms;android-35 +build-tools;35.0.0 +emulator +system-images;android-35;default;x86_64 |