|
| 1 | +#!/usr/bin/env bash |
| 2 | +# |
| 3 | +# Local emulator instrumentation runner that bypasses AGP's Unified Test |
| 4 | +# Platform (UTP). |
| 5 | +# |
| 6 | +# Why: on AGP 8.1.2 + JDK 17, `./gradlew connectedDebugAndroidTest` fails with |
| 7 | +# |
| 8 | +# java.lang.IllegalAccessError: class com.google.protobuf.GeneratedMessageV3 |
| 9 | +# tried to access method |
| 10 | +# 'boolean com.google.protobuf.CodedInputStream.shouldDiscardUnknownFields()' |
| 11 | +# (com.google.protobuf.GeneratedMessageV3 is in unnamed module of loader |
| 12 | +# java.net.URLClassLoader @...; |
| 13 | +# com.google.protobuf.CodedInputStream is in unnamed module of loader 'app') |
| 14 | +# |
| 15 | +# Root cause: UTP host plugins live in a separate URLClassLoader, ddmlib lives |
| 16 | +# in Gradle's "app" classloader, and each pulls its own protobuf-java version. |
| 17 | +# AGP 8.1.2 has no `useUnifiedTestPlatform=false` opt-out (removed in AGP 8.x), |
| 18 | +# and upgrading AGP retriggers the rust-android-gradle 0.9.6 jniLibs dup bug. |
| 19 | +# |
| 20 | +# This script builds the APKs via gradle but launches instrumentation through |
| 21 | +# `adb shell am instrument -w`, which avoids UTP and ddmlib's protobuf parsing |
| 22 | +# entirely. Same code path CI's android-emulator-runner action uses. |
| 23 | +# |
| 24 | +# Prereqs: a running emulator (or device) reachable via adb, and Python 3 on |
| 25 | +# the host for the four fake upstream proxies. The script auto-picks free TCP |
| 26 | +# ports if the defaults (1080/1081/8081/8082) are taken locally (e.g. by a |
| 27 | +# sslocal already listening on 8081). |
| 28 | + |
| 29 | +set -euo pipefail |
| 30 | + |
| 31 | +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 32 | +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" |
| 33 | + |
| 34 | +PACKAGE="org.proxydroid" |
| 35 | +TEST_PACKAGE="org.proxydroid.test" |
| 36 | +RUNNER="androidx.test.runner.AndroidJUnitRunner" |
| 37 | + |
| 38 | +AUTH_USER="${AUTH_USER:-alice}" |
| 39 | +AUTH_PASS="${AUTH_PASS:-s3cret}" |
| 40 | + |
| 41 | +# Defaults match the CI rig; the picker below will hop to a free port if any |
| 42 | +# is already bound on this host. |
| 43 | +DEFAULT_SOCKS_NOAUTH=1080 |
| 44 | +DEFAULT_SOCKS_AUTH=1081 |
| 45 | +DEFAULT_HTTP_NOAUTH=8081 |
| 46 | +DEFAULT_HTTP_AUTH=8082 |
| 47 | + |
| 48 | +# ----------------------------------------------------------------------------- |
| 49 | +# Helpers |
| 50 | +# ----------------------------------------------------------------------------- |
| 51 | + |
| 52 | +log() { printf '\033[1;34m[%s]\033[0m %s\n' "$(date +%H:%M:%S)" "$*"; } |
| 53 | +die() { printf '\033[1;31m[FATAL]\033[0m %s\n' "$*" >&2; exit 1; } |
| 54 | + |
| 55 | +port_free() { ! lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1; } |
| 56 | + |
| 57 | +pick_port() { |
| 58 | + # Walk from $1 upward (stride 100 to stay tidy) until a free port shows up. |
| 59 | + local p="$1" |
| 60 | + for _ in $(seq 1 64); do |
| 61 | + if port_free "$p"; then echo "$p"; return; fi |
| 62 | + p=$((p + 100)) |
| 63 | + done |
| 64 | + die "no free port near $1" |
| 65 | +} |
| 66 | + |
| 67 | +PIDS=() |
| 68 | +cleanup() { |
| 69 | + set +e |
| 70 | + log "cleaning up" |
| 71 | + for pid in "${PIDS[@]:-}"; do |
| 72 | + [ -n "$pid" ] && kill "$pid" 2>/dev/null |
| 73 | + done |
| 74 | + [ -n "${TMPDIR_RUN:-}" ] && rm -rf "$TMPDIR_RUN" |
| 75 | +} |
| 76 | +trap cleanup EXIT |
| 77 | + |
| 78 | +start_bg() { |
| 79 | + local name="$1"; shift |
| 80 | + "$@" >/dev/null 2>&1 & |
| 81 | + local pid=$! |
| 82 | + PIDS+=("$pid") |
| 83 | + log "started $name (pid $pid): $*" |
| 84 | +} |
| 85 | + |
| 86 | +require_emulator() { |
| 87 | + local serial |
| 88 | + serial=$(adb devices | awk '/^emulator-[0-9]+\tdevice/{print $1; exit}') |
| 89 | + if [ -z "$serial" ]; then |
| 90 | + die "no emulator attached; boot one first (e.g. \$ANDROID_HOME/emulator/emulator -avd <avd>)" |
| 91 | + fi |
| 92 | + if [ "$(adb -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; then |
| 93 | + die "$serial is not finished booting" |
| 94 | + fi |
| 95 | + echo "$serial" |
| 96 | +} |
| 97 | + |
| 98 | +# ----------------------------------------------------------------------------- |
| 99 | +# Build & install |
| 100 | +# ----------------------------------------------------------------------------- |
| 101 | + |
| 102 | +build_apks() { |
| 103 | + log "building debug + androidTest APKs" |
| 104 | + ( |
| 105 | + cd "$PROJECT_DIR" |
| 106 | + ./gradlew :app:assembleDebug :app:assembleDebugAndroidTest -q |
| 107 | + ) |
| 108 | +} |
| 109 | + |
| 110 | +install_apks() { |
| 111 | + local serial="$1" |
| 112 | + local app_apk="$PROJECT_DIR/app/build/outputs/apk/debug/app-debug.apk" |
| 113 | + local test_apk="$PROJECT_DIR/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk" |
| 114 | + [ -f "$app_apk" ] || die "missing $app_apk" |
| 115 | + [ -f "$test_apk" ] || die "missing $test_apk" |
| 116 | + log "uninstalling any prior $PACKAGE / $TEST_PACKAGE" |
| 117 | + adb -s "$serial" uninstall "$PACKAGE" >/dev/null 2>&1 || true |
| 118 | + adb -s "$serial" uninstall "$TEST_PACKAGE" >/dev/null 2>&1 || true |
| 119 | + log "installing $app_apk" |
| 120 | + adb -s "$serial" install -r "$app_apk" >/dev/null |
| 121 | + log "installing $test_apk" |
| 122 | + adb -s "$serial" install -r "$test_apk" >/dev/null |
| 123 | +} |
| 124 | + |
| 125 | +# ----------------------------------------------------------------------------- |
| 126 | +# Run |
| 127 | +# ----------------------------------------------------------------------------- |
| 128 | + |
| 129 | +run_instrumentation() { |
| 130 | + local serial="$1" |
| 131 | + local socks_noauth="$2" socks_auth="$3" http_noauth="$4" http_auth="$5" |
| 132 | + |
| 133 | + TMPDIR_RUN=$(mktemp -d) |
| 134 | + local out="$TMPDIR_RUN/inst.out" |
| 135 | + |
| 136 | + log "am instrument -> $out" |
| 137 | + # Pass instrumentation args via -e KEY VALUE pairs. |
| 138 | + adb -s "$serial" shell am instrument -w -r \ |
| 139 | + -e socksHost 10.0.2.2 \ |
| 140 | + -e socksPort "$socks_noauth" \ |
| 141 | + -e socksAuthPort "$socks_auth" \ |
| 142 | + -e socksAuthUser "$AUTH_USER" \ |
| 143 | + -e socksAuthPass "$AUTH_PASS" \ |
| 144 | + -e httpProxyHost 10.0.2.2 \ |
| 145 | + -e httpProxyPort "$http_noauth" \ |
| 146 | + -e httpProxyAuthPort "$http_auth" \ |
| 147 | + -e httpProxyAuthUser "$AUTH_USER" \ |
| 148 | + -e httpProxyAuthPass "$AUTH_PASS" \ |
| 149 | + "$TEST_PACKAGE/$RUNNER" | tee "$out" |
| 150 | + |
| 151 | + # `am instrument -w -r` prints a flat key/value stream. Tests passed iff |
| 152 | + # the final INSTRUMENTATION_CODE is -1 and no INSTRUMENTATION_STATUS_CODE |
| 153 | + # is -2 (failure). |
| 154 | + if grep -q '^INSTRUMENTATION_STATUS_CODE: -2' "$out"; then |
| 155 | + echo |
| 156 | + log "FAIL: at least one test reported failure" |
| 157 | + grep -E '^INSTRUMENTATION_STATUS: (test|class|stack|stream)=' "$out" | sed 's/^/ /' |
| 158 | + return 1 |
| 159 | + fi |
| 160 | + if grep -q '^INSTRUMENTATION_CODE: -1' "$out"; then |
| 161 | + local n |
| 162 | + n=$(grep -c '^INSTRUMENTATION_STATUS_CODE: 0' "$out" || true) |
| 163 | + log "PASS: $n tests" |
| 164 | + return 0 |
| 165 | + fi |
| 166 | + log "FAIL: instrumentation did not report success" |
| 167 | + return 1 |
| 168 | +} |
| 169 | + |
| 170 | +# ----------------------------------------------------------------------------- |
| 171 | +# Main |
| 172 | +# ----------------------------------------------------------------------------- |
| 173 | + |
| 174 | +main() { |
| 175 | + : "${ANDROID_HOME:?ANDROID_HOME must be set}" |
| 176 | + : "${JAVA_HOME:?JAVA_HOME must be set}" |
| 177 | + |
| 178 | + local serial; serial=$(require_emulator) |
| 179 | + log "target emulator: $serial" |
| 180 | + |
| 181 | + local socks_noauth socks_auth http_noauth http_auth |
| 182 | + socks_noauth=$(pick_port "$DEFAULT_SOCKS_NOAUTH") |
| 183 | + socks_auth=$( pick_port "$DEFAULT_SOCKS_AUTH" ) |
| 184 | + http_noauth=$( pick_port "$DEFAULT_HTTP_NOAUTH" ) |
| 185 | + http_auth=$( pick_port "$DEFAULT_HTTP_AUTH" ) |
| 186 | + log "ports: socks=$socks_noauth/$socks_auth http=$http_noauth/$http_auth" |
| 187 | + |
| 188 | + start_bg "socks5-noauth" \ |
| 189 | + python3 "$SCRIPT_DIR/socks5_test_server.py" --port "$socks_noauth" --quiet |
| 190 | + start_bg "socks5-auth" \ |
| 191 | + python3 "$SCRIPT_DIR/socks5_test_server.py" --port "$socks_auth" --auth "$AUTH_USER:$AUTH_PASS" --quiet |
| 192 | + start_bg "http-connect-noauth" \ |
| 193 | + python3 "$SCRIPT_DIR/http_connect_test_server.py" --port "$http_noauth" --quiet |
| 194 | + start_bg "http-connect-auth" \ |
| 195 | + python3 "$SCRIPT_DIR/http_connect_test_server.py" --port "$http_auth" --auth "$AUTH_USER:$AUTH_PASS" --quiet |
| 196 | + |
| 197 | + # Give listeners a moment to bind. |
| 198 | + sleep 2 |
| 199 | + for p in "$socks_noauth" "$socks_auth" "$http_noauth" "$http_auth"; do |
| 200 | + if port_free "$p"; then die "nothing listening on 127.0.0.1:$p"; fi |
| 201 | + done |
| 202 | + log "all four upstreams listening" |
| 203 | + |
| 204 | + build_apks |
| 205 | + install_apks "$serial" |
| 206 | + |
| 207 | + if run_instrumentation "$serial" \ |
| 208 | + "$socks_noauth" "$socks_auth" "$http_noauth" "$http_auth"; then |
| 209 | + log "DONE: emulator instrumentation green" |
| 210 | + exit 0 |
| 211 | + else |
| 212 | + log "DONE: emulator instrumentation red" |
| 213 | + exit 1 |
| 214 | + fi |
| 215 | +} |
| 216 | + |
| 217 | +main "$@" |
0 commit comments