From 962a2898edca35f04c2c339b2076402fe6502263 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Mon, 9 Feb 2026 11:08:55 +0800 Subject: [PATCH 1/2] Bump version to 5.3.5-nightly Co-Authored-By: Claude Opus 4.6 --- buildSrc/src/main/kotlin/Helpers.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/buildSrc/src/main/kotlin/Helpers.kt b/buildSrc/src/main/kotlin/Helpers.kt index 83715e6ce..7c9304dcd 100644 --- a/buildSrc/src/main/kotlin/Helpers.kt +++ b/buildSrc/src/main/kotlin/Helpers.kt @@ -48,8 +48,8 @@ fun Project.setupCore() { setupCommon() android.apply { defaultConfig { - versionCode = 5030450 - versionName = "5.3.4-nightly" + versionCode = 5030550 + versionName = "5.3.5-nightly" } compileOptions.isCoreLibraryDesugaringEnabled = true lint.apply { From 14cd970848a99358b86e6050e494e6a29ba7d7e3 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Tue, 10 Feb 2026 10:17:41 +0800 Subject: [PATCH 2/2] Add E2E test GitHub Actions workflow Parameterize test-e2e.sh with env-var defaults so it works both locally (macOS/ARM64) and on CI (Linux/x86_64). Add TARGET_ABI Gradle property to build a single-architecture APK, and create an e2e-test workflow that runs the VPN connectivity tests on an x86_64 emulator with KVM. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e-test.yml | 81 +++++++ core/build.gradle.kts | 8 +- test-e2e.sh | 390 +++++++++++++++++++++++++++++++++ 3 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/e2e-test.yml create mode 100755 test-e2e.sh diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml new file mode 100644 index 000000000..e1c3eda7b --- /dev/null +++ b/.github/workflows/e2e-test.yml @@ -0,0 +1,81 @@ +name: E2E Test +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +jobs: + e2e-test: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-java@v4 + with: + distribution: jetbrains + java-version: 21 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-linux-android + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Gradle cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ hashFiles('**/*.gradle*', 'gradle/wrapper/gradle-wrapper.properties') }} + + - name: Cargo cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + core/src/main/rust/shadowsocks-rust/target + key: cargo-${{ hashFiles('core/src/main/rust/shadowsocks-rust/Cargo.lock') }} + + - name: Build debug APK (x86_64) + run: ./gradlew assembleDebug -PCARGO_PROFILE=debug -PTARGET_ABI=x86_64 + + - name: Build ssserver + run: | + cd core/src/main/rust/shadowsocks-rust + cargo build --release --bin ssserver --features "server,aead-cipher,logging" + + - name: E2E Test + uses: ReactiveCircus/android-emulator-runner@v2 + with: + api-level: 34 + target: google_apis + arch: x86_64 + force-avd-creation: true + emulator-options: >- + -no-snapshot-save -no-window -gpu swiftshader_indirect + -noaudio -no-boot-anim + disable-animations: true + script: bash test-e2e.sh + env: + SKIP_EMULATOR_BOOT: "true" + ADB: adb + APK: ${{ github.workspace }}/mobile/build/outputs/apk/debug/mobile-x86_64-debug.apk + SSSERVER: ${{ github.workspace }}/core/src/main/rust/shadowsocks-rust/target/release/ssserver + + - name: Upload screenshots + uses: actions/upload-artifact@v4 + if: always() + with: + name: e2e-screenshots + path: screen_*.png diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 051741c7d..ffe8e4d14 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -10,6 +10,9 @@ plugins { setupCore() +val allAbis = mapOf("arm" to "armeabi-v7a", "arm64" to "arm64-v8a", "x86" to "x86", "x86_64" to "x86_64") +val targetAbi = findProperty("TARGET_ABI")?.toString() + android { namespace = "com.github.shadowsocks.core" @@ -17,7 +20,8 @@ android { consumerProguardFiles("proguard-rules.pro") externalNativeBuild.ndkBuild { - abiFilters("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + val abis = if (targetAbi != null) listOf(allAbis.getValue(targetAbi)) else allAbis.values.toList() + abiFilters(*abis.toTypedArray()) arguments("-j${Runtime.getRuntime().availableProcessors()}") } @@ -39,7 +43,7 @@ android { cargo { module = "src/main/rust/shadowsocks-rust" libname = "sslocal" - targets = listOf("arm", "arm64", "x86", "x86_64") + targets = if (targetAbi != null) listOf(targetAbi) else listOf("arm", "arm64", "x86", "x86_64") profile = findProperty("CARGO_PROFILE")?.toString() ?: currentFlavor extraCargoBuildArguments = listOf("--bin", libname!!) featureSpec.noDefaultBut(arrayOf( diff --git a/test-e2e.sh b/test-e2e.sh new file mode 100755 index 000000000..ee12f9c75 --- /dev/null +++ b/test-e2e.sh @@ -0,0 +1,390 @@ +#!/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