Files
2026-02-10 20:16:55 +01:00

391 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# End-to-end test: shadowsocks-android on Android emulator
#
# Boots an emulator, starts ssserver on the host, installs the debug APK,
# modifies the default profile via run-as + sqlite3 to point to our server,
# taps the FAB to connect VPN, then verifies connectivity.
#
set -euo pipefail
# ── Paths ───────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
EMULATOR="${EMULATOR:-/Volumes/Data/workspace/android/emulator/emulator}"
ADB="${ADB:-/Volumes/Data/workspace/android/platform-tools/adb}"
AVD="${AVD:-Medium_Phone_API_36.1}"
APK="${APK:-$SCRIPT_DIR/mobile/build/outputs/apk/debug/mobile-arm64-v8a-debug.apk}"
SSSERVER="${SSSERVER:-$SCRIPT_DIR/core/src/main/rust/shadowsocks-rust/target/release/ssserver}"
PKG="com.github.shadowsocks"
# ── SS config ───────────────────────────────────────────────────────────────
SS_ADDR="0.0.0.0:8388"
SS_PASSWORD="testpassword123"
SS_METHOD="aes-256-gcm"
SS_HOST_FROM_EMU="10.0.2.2"
SS_PORT=8388
# ── Cleanup trap ────────────────────────────────────────────────────────────
SSSERVER_PID=""
cleanup() {
echo ""
echo "=== Cleanup ==="
if [[ -n "$SSSERVER_PID" ]] && kill -0 "$SSSERVER_PID" 2>/dev/null; then
echo "Killing ssserver (PID $SSSERVER_PID)"
kill "$SSSERVER_PID" 2>/dev/null || true
wait "$SSSERVER_PID" 2>/dev/null || true
fi
if [[ "${SKIP_EMULATOR_BOOT:-}" != "true" ]] && "$ADB" get-state &>/dev/null; then
echo "Shutting down emulator..."
"$ADB" emu kill 2>/dev/null || true
fi
echo "Cleanup done."
}
trap cleanup EXIT
# ── Helpers ─────────────────────────────────────────────────────────────────
fail() { echo "FAIL: $*" >&2; exit 1; }
info() { echo "--- $*"; }
wait_for_boot() {
info "Waiting for emulator to boot..."
"$ADB" wait-for-device
local n=0
while [[ $n -lt 120 ]]; do
local val
val=$("$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r\n')
if [[ "$val" == "1" ]]; then
info "Emulator booted."
return 0
fi
sleep 2
n=$((n + 2))
done
fail "Emulator did not boot within 120s"
}
screenshot() {
local name="$1"
"$ADB" shell screencap -p /sdcard/screen_${name}.png 2>/dev/null || true
"$ADB" pull /sdcard/screen_${name}.png "$SCRIPT_DIR/screen_${name}.png" 2>/dev/null || true
info " Screenshot saved: screen_${name}.png"
}
# ────────────────────────────────────────────────────────────────────────────
# Step 1: Verify prerequisites
# ────────────────────────────────────────────────────────────────────────────
info "Step 1: Verify prerequisites"
[[ -f "$SSSERVER" ]] || command -v "$SSSERVER" &>/dev/null || fail "ssserver not found at $SSSERVER"
[[ -f "$APK" ]] || fail "APK not found at $APK"
[[ "${SKIP_EMULATOR_BOOT:-}" == "true" ]] || [[ -x "$EMULATOR" ]] || command -v "$EMULATOR" &>/dev/null || fail "Emulator not found at $EMULATOR"
[[ -x "$ADB" ]] || command -v "$ADB" &>/dev/null || fail "adb not found at $ADB"
info "All prerequisites OK."
# ────────────────────────────────────────────────────────────────────────────
# Step 2: Start ssserver
# ────────────────────────────────────────────────────────────────────────────
info "Step 2: Starting ssserver on $SS_ADDR ..."
"$SSSERVER" -s "$SS_ADDR" -k "$SS_PASSWORD" -m "$SS_METHOD" -U &
SSSERVER_PID=$!
sleep 1
kill -0 "$SSSERVER_PID" 2>/dev/null || fail "ssserver failed to start"
info "ssserver running (PID $SSSERVER_PID)"
# ────────────────────────────────────────────────────────────────────────────
# Step 3: Boot emulator
# ────────────────────────────────────────────────────────────────────────────
if [[ "${SKIP_EMULATOR_BOOT:-}" == "true" ]]; then
info "Step 3: Skipping emulator boot (SKIP_EMULATOR_BOOT=true)"
"$ADB" wait-for-device
info " Emulator already running."
else
info "Step 3: Booting emulator ($AVD) ..."
"$EMULATOR" -avd "$AVD" -no-snapshot-load -no-audio -gpu auto &
wait_for_boot
sleep 5
"$ADB" shell input keyevent KEYCODE_HOME
sleep 2
fi
# Disable animations for reliable UI automation
"$ADB" shell settings put global window_animation_scale 0
"$ADB" shell settings put global transition_animation_scale 0
"$ADB" shell settings put global animator_duration_scale 0
# ────────────────────────────────────────────────────────────────────────────
# Step 4: Install APK
# ────────────────────────────────────────────────────────────────────────────
info "Step 4: Installing debug APK ..."
# Uninstall any existing version first (release vs debug signatures differ)
"$ADB" uninstall "$PKG" 2>/dev/null || true
"$ADB" install -g "$APK" || fail "APK install failed"
info "APK installed."
# ────────────────────────────────────────────────────────────────────────────
# Step 5: Configure server profile via run-as + sqlite3
# ────────────────────────────────────────────────────────────────────────────
info "Step 5: Configuring profile..."
# 5a. Launch app once to initialize databases.
# ensureNotEmpty() creates a default profile (id=1) and sets profileId=1.
# serviceMode defaults to "vpn".
info " Launching app to initialize databases..."
"$ADB" shell am start -W -n "$PKG/.MainActivity"
sleep 8
screenshot "01_init"
# Force a checkpoint to flush WAL into main database file
"$ADB" shell am force-stop "$PKG"
sleep 2
# 5b. Update the default profile (id=1) to point to our test server.
# Profile table columns: id, name, host, remotePort, password, method, ...
# With debug build, we can use run-as to copy the database out, modify it, and copy it back.
info " Updating default profile via sqlite3..."
# Extract profile.db and WAL/SHM files using exec-out (binary-safe)
"$ADB" exec-out run-as "$PKG" cat databases/profile.db > /tmp/profile.db
"$ADB" exec-out run-as "$PKG" cat databases/profile.db-wal > /tmp/profile.db-wal 2>/dev/null || true
"$ADB" exec-out run-as "$PKG" cat databases/profile.db-shm > /tmp/profile.db-shm 2>/dev/null || true
# Verify and checkpoint WAL into main DB
file /tmp/profile.db
sqlite3 /tmp/profile.db "PRAGMA wal_checkpoint(TRUNCATE);" 2>/dev/null || true
# Check tables
info " Tables in profile.db:"
sqlite3 /tmp/profile.db ".tables" | while IFS= read -r line; do
info " $line"
done
if ! sqlite3 /tmp/profile.db "SELECT count(*) FROM Profile;" >/dev/null 2>&1; then
fail "Profile table not found — database may not have been initialized"
fi
# Modify the profile using host sqlite3
sqlite3 /tmp/profile.db "UPDATE Profile SET host='$SS_HOST_FROM_EMU', remotePort=$SS_PORT, password='$SS_PASSWORD', method='$SS_METHOD', name='Test Server' WHERE id=1;"
# Verify update
info " Verifying profile update..."
sqlite3 /tmp/profile.db "SELECT id, name, host, remotePort, method FROM Profile;" | while IFS= read -r line; do
info " Profile: $line"
done
# Push modified database back (without WAL — clean state)
rm -f /tmp/profile.db-wal /tmp/profile.db-shm
"$ADB" push /tmp/profile.db /data/local/tmp/profile.db
"$ADB" shell "cat /data/local/tmp/profile.db | run-as $PKG sh -c 'cat > databases/profile.db'"
# Remove old WAL/SHM so Room starts fresh
"$ADB" shell "run-as $PKG rm -f databases/profile.db-wal databases/profile.db-shm"
"$ADB" shell rm /data/local/tmp/profile.db
info " Profile configuration done."
# ────────────────────────────────────────────────────────────────────────────
# Step 6: Enable VPN
# ────────────────────────────────────────────────────────────────────────────
info "Step 6: Enabling VPN..."
# Launch the app
"$ADB" shell am start -W -n "$PKG/.MainActivity"
sleep 3
screenshot "02_app_launched"
# Get screen dimensions
SCREEN_SIZE=$("$ADB" shell wm size | grep -oE '[0-9]+x[0-9]+' | tail -1)
SCREEN_W=$(echo "$SCREEN_SIZE" | cut -dx -f1)
SCREEN_H=$(echo "$SCREEN_SIZE" | cut -dx -f2)
info " Screen: ${SCREEN_W}x${SCREEN_H}"
# Tap the FAB (connect button) — centered horizontally, near bottom.
# On 1080x2400 @420dpi the FAB center is at ~93.5% of screen height.
FAB_X=$((SCREEN_W / 2))
FAB_Y=$((SCREEN_H * 93 / 100))
info " Tapping FAB at ($FAB_X, $FAB_Y)..."
"$ADB" shell input tap "$FAB_X" "$FAB_Y"
sleep 2
screenshot "03_after_fab_tap"
# Handle VPN consent dialog
info " Checking for VPN consent dialog..."
VPN_ACCEPTED=false
for i in $(seq 1 15); do
ACTIVITIES=$("$ADB" shell dumpsys activity activities 2>/dev/null || true)
if echo "$ACTIVITIES" | grep -qi "vpndialogs\|com.android.vpndialogs"; then
info " VPN consent dialog detected, accepting..."
screenshot "04_vpn_dialog"
sleep 1
# Use uiautomator dump to find the exact OK button coordinates
"$ADB" shell uiautomator dump /sdcard/ui_dump.xml 2>/dev/null || true
"$ADB" pull /sdcard/ui_dump.xml /tmp/ui_dump.xml 2>/dev/null || true
UI_XML=$(cat /tmp/ui_dump.xml 2>/dev/null || true)
info " UI dump obtained (${#UI_XML} chars)"
# Log all button texts found for debugging
BUTTONS=$(echo "$UI_XML" | tr '>' '\n' | grep -o 'text="[^"]*".*bounds="[^"]*"' || true)
info " Buttons found:"
echo "$BUTTONS" | while IFS= read -r line; do
[[ -n "$line" ]] && info " $line"
done
# Extract OK button bounds from XML: text="OK" ... bounds="[x1,y1][x2,y2]"
# Search for text="OK" or text="Allow" or text="확인" (various system locales)
OK_LINE=$(echo "$UI_XML" | tr '>' '\n' | grep -E 'text="OK"|text="Allow"' | head -1 || true)
if [[ -n "$OK_LINE" ]]; then
OK_BOUNDS=$(echo "$OK_LINE" | grep -o 'bounds="\[[0-9]*,[0-9]*\]\[[0-9]*,[0-9]*\]"' || true)
info " OK button bounds: $OK_BOUNDS"
if [[ -n "$OK_BOUNDS" ]]; then
# Parse [x1,y1][x2,y2]
NUMS=$(echo "$OK_BOUNDS" | grep -o '[0-9]*')
X1=$(echo "$NUMS" | sed -n '1p')
Y1=$(echo "$NUMS" | sed -n '2p')
X2=$(echo "$NUMS" | sed -n '3p')
Y2=$(echo "$NUMS" | sed -n '4p')
TAP_X=$(( (X1 + X2) / 2 ))
TAP_Y=$(( (Y1 + Y2) / 2 ))
info " Tapping OK at ($TAP_X, $TAP_Y)..."
"$ADB" shell input tap "$TAP_X" "$TAP_Y"
sleep 2
fi
else
info " OK button not found in UI dump, trying coordinate-based tap..."
# From screenshot analysis: OK button is at ~82% x, ~59% y on 1080x2400
OK_TAP_X=$((SCREEN_W * 82 / 100))
OK_TAP_Y=$((SCREEN_H * 59 / 100))
info " Tapping estimated OK at ($OK_TAP_X, $OK_TAP_Y)..."
"$ADB" shell input tap "$OK_TAP_X" "$OK_TAP_Y"
sleep 2
fi
# Fallback: if dialog still showing, try keyboard approach
if "$ADB" shell dumpsys activity activities 2>/dev/null | grep -qi "vpndialogs"; then
info " Dialog still showing, trying DPAD_RIGHT + ENTER..."
"$ADB" shell input keyevent KEYCODE_DPAD_RIGHT
sleep 0.3
"$ADB" shell input keyevent KEYCODE_ENTER
sleep 2
fi
# Second fallback: try TAB + ENTER
if "$ADB" shell dumpsys activity activities 2>/dev/null | grep -qi "vpndialogs"; then
info " Dialog still showing, trying TAB + ENTER..."
"$ADB" shell input keyevent KEYCODE_TAB
sleep 0.3
"$ADB" shell input keyevent KEYCODE_TAB
sleep 0.3
"$ADB" shell input keyevent KEYCODE_ENTER
sleep 2
fi
VPN_ACCEPTED=true
screenshot "05_after_vpn_accept"
break
fi
sleep 1
done
if [[ "$VPN_ACCEPTED" != "true" ]]; then
info " No VPN consent dialog detected"
screenshot "04_no_vpn_dialog"
fi
# ────────────────────────────────────────────────────────────────────────────
# Step 7: Verify VPN is connected
# ────────────────────────────────────────────────────────────────────────────
info "Step 7: Verifying VPN connection..."
sleep 8
screenshot "06_vpn_status"
VPN_UP=false
# Check tun0 interface (must exist for VPN to be working)
info " Checking tun0 interface..."
TUN_CHECK=$("$ADB" shell ip addr show tun0 2>&1 || true)
if echo "$TUN_CHECK" | grep -q "inet "; then
info " tun0 interface exists with IP address."
echo "$TUN_CHECK"
VPN_UP=true
else
echo "$TUN_CHECK"
info " tun0 not found — VPN is NOT connected."
info " Dumping relevant logcat..."
"$ADB" logcat -d 2>/dev/null | grep -iE "shadowsocks|vpn|sslocal|tun|StartService" | tail -40 || true
fi
# Check service
info " Checking shadowsocks service..."
SVC_CHECK=$("$ADB" shell dumpsys activity services "$PKG" 2>&1 || true)
if echo "$SVC_CHECK" | grep -qi "shadowsocks\|VpnService"; then
info " Shadowsocks service is running."
else
info " WARNING: Could not confirm service state"
fi
# Dump sslocal-related logcat for debugging
info " Recent sslocal/VPN logcat:"
"$ADB" logcat -d 2>/dev/null | grep -iE "shadowsocks|sslocal|ssservice|vpn|tun" | tail -20 || true
# ────────────────────────────────────────────────────────────────────────────
# Step 8: Test connectivity from inside the emulator
# ────────────────────────────────────────────────────────────────────────────
info "Step 8: Testing connectivity through VPN..."
PASS=0
TOTAL=4
# Test 1: VPN tunnel must be up
info " Test 1: VPN tunnel (tun0) is active..."
if [[ "$VPN_UP" == "true" ]]; then
info " PASS: tun0 exists"
PASS=$((PASS + 1))
else
echo " FAIL: tun0 does not exist — VPN never connected"
fi
# Test 2: DNS resolution via ping (ping resolves hostname even if ICMP is dropped)
info " Test 2: DNS resolution (ping -c1 google.com)..."
DNS_OUT=$("$ADB" shell "ping -c 1 -W 5 google.com 2>&1" || true)
if echo "$DNS_OUT" | grep -qE "PING google\.com \([0-9]+\.[0-9]+"; then
info " PASS: DNS resolution succeeded (hostname resolved)"
echo "$DNS_OUT" | head -1
PASS=$((PASS + 1))
else
echo " FAIL: DNS resolution failed"
echo "$DNS_OUT"
fi
# Test 3: TCP connect to 1.1.1.1:80 (proves TCP tunneling works)
# Note: toybox nc on some Android versions lacks -z; use "echo | nc" instead.
info " Test 3: TCP connect to 1.1.1.1:80..."
NC1_EXIT=$("$ADB" shell "echo '' | nc -w 5 1.1.1.1 80 >/dev/null 2>&1; echo \$?" | tr -d '\r' | tail -1)
if [[ "$NC1_EXIT" == "0" ]]; then
info " PASS: TCP connect to 1.1.1.1:80 succeeded"
PASS=$((PASS + 1))
else
echo " FAIL: TCP connect to 1.1.1.1:80 failed (exit=$NC1_EXIT)"
fi
# Test 4: TCP connect to a different host (confirms full routing)
info " Test 4: TCP connect to 8.8.8.8:443..."
NC2_EXIT=$("$ADB" shell "echo '' | nc -w 5 8.8.8.8 443 >/dev/null 2>&1; echo \$?" | tr -d '\r' | tail -1)
if [[ "$NC2_EXIT" == "0" ]]; then
info " PASS: TCP connect to 8.8.8.8:443 succeeded"
PASS=$((PASS + 1))
else
echo " FAIL: TCP connect to 8.8.8.8:443 failed (exit=$NC2_EXIT)"
fi
# ────────────────────────────────────────────────────────────────────────────
# Summary
# ────────────────────────────────────────────────────────────────────────────
echo ""
echo "========================================"
echo " E2E Test Results: $PASS/$TOTAL passed"
echo "========================================"
if [[ $PASS -eq $TOTAL ]]; then
echo " ALL TESTS PASSED"
exit 0
else
echo " SOME TESTS FAILED"
exit 1
fi