Skip to content

Commit 1e2ab0e

Browse files
madeyeclaude
andcommitted
Add UTP-bypass local emulator instrumentation runner
`./gradlew connectedDebugAndroidTest` on AGP 8.1.2 + JDK 17 dies with java.lang.IllegalAccessError: class com.google.protobuf.GeneratedMessageV3 tried to access method com.google.protobuf.CodedInputStream.shouldDiscardUnknownFields() (GeneratedMessageV3 in URLClassLoader; CodedInputStream in loader 'app') before any test runs (tests=0 in the JUnit XML, APK uninstall failures in the UTP cleanup phase). Root cause: UTP host plugins live in their own URLClassLoader while ddmlib lives in Gradle's "app" classloader, and each pulls its own protobuf-java version; the JVM treats the two copies as distinct types and the cross-classloader method dispatch fails. AGP 8.1.2 has no `useUnifiedTestPlatform=false` opt-out (Google removed that flag in 8.x), and the obvious "upgrade AGP" path retriggers the rust-android-gradle 0.9.6 mergeJniLibFolders duplicate-resources bug we already had to dodge. Bypass UTP entirely with `adb shell am instrument -w` -- the same path CI's android-emulator-runner action uses, which is why CI runs the suite green while local does not. The script: - Builds debug + androidTest APKs via gradle (no UTP touched). - Auto-picks free ports if 1080/1081/8081/8082 collide locally (e.g. sslocal already on 8081). - Boots the four fake-upstream Python proxies on the host. - Reinstalls both APKs on the emulator. - Runs the instrumentation directly via adb am instrument. - Parses the INSTRUMENTATION_STATUS_CODE / INSTRUMENTATION_CODE stream to report PASS / FAIL. - Tears everything down on exit. Verified locally on AVD meow_api35 (arm64-v8a, API 35): 8/8 tests green in 0.4 s of test time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e529caa commit 1e2ab0e

1 file changed

Lines changed: 217 additions & 0 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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

Comments
 (0)