diff --git a/CLAUDE.md b/CLAUDE.md index bb81270..28f589e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ - strict no backward compatibility (0.0.x): change formats and APIs freely, no migrations. - make changes on the sibling project ../ezvpn (the Rust core this app loads) when needed; its design notes for this app are in ../ezvpn/docs/Android-App.md. Run its `cargo ndk -t arm64-v8a --platform 29 clippy --lib -- -D warnings` after Rust changes touching the Android build. -- always test on the real device over adb (`adb devices`); a VpnService cannot be exercised meaningfully on the JVM and the emulator's network stack differs. `scripts/run-device.sh` builds the local core, installs, launches, and tails logcat. +- always test on the development emulator over adb (`10.22.35.66:5555`, an arm64 Android VM; `adb connect 10.22.35.66`); a VpnService cannot be exercised meaningfully on the JVM. `scripts/run-device.sh` targets it by default (`ADB_SERIAL` overrides), builds the local core, installs, launches, and tails logcat. Never use the physical device (`10.22.38.204:51035`, Nexus 7) for development, debug installs, or testing: it only receives the signed release APK via `scripts/install-release-apk.sh`. When several devices are attached, always pass `-s ` to adb / set `ANDROID_SERIAL` for Gradle install tasks. `scrcpy -s 10.22.35.66:5555` mirrors/controls the emulator screen. - the Rust artifact (`libezvpn.so` per ABI, zipped as `libezvpn-android.zip`) is delivered by download + sha256 pin in `gradle.properties` (`app/build.gradle.kts` `fetchEzvpnJniLibs`). Bump with `scripts/bump-jnilibs.sh ` after the ezvpn release workflow publishes the asset. For FFI dev against a local build run `../ezvpn/build-android.sh` then set `EZVPN_LOCAL_JNILIBS=1` for every gradle invocation — only the exact value `1` opts in. - `EzvpnNative` must stay at `dev.flexaccess.ezvpn.EzvpnNative`: the JNI symbol names in ../ezvpn/src/ffi_android.rs encode that class. `EzvpnNative.init(context)` must run once before anything else (Application.onCreate): it registers the JVM/context that iroh's Android DNS/interface discovery needs, or the first connect aborts the process. - pure logic (CIDR math, the bypass-by-subtraction route plan, profile model/validation, split-DNS rules, JSON shapes) lives in `tunnelcore` (no Android deps) so it is unit-testable with `./gradlew :tunnelcore:test`. Put new pure helpers there, not in the service. diff --git a/README.md b/README.md index 338c73e..d97b21b 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,11 @@ Design and the JNI contract are documented in the core repo: - JDK 17, Android SDK with platform 37 and build-tools 37 (the Gradle wrapper brings Gradle itself; AGP 9 with built-in Kotlin). -- A device running Android 10+ (`minSdk` 29). Development is done against a - physical device over adb — a VPN needs the real network stack. +- An Android 10+ (`minSdk` 29) target. Development is done against the + emulator (`10.22.35.66:5555`, an arm64 Android VM bridged onto the LAN like a + phone; `adb connect 10.22.35.66`) — a `VpnService` cannot be exercised on the + JVM. The physical device (`10.22.38.204:51035`) is reserved for installing + the signed release APK and is never used for development. - For FFI work: the sibling `../ezvpn` checkout, the Android NDK and `cargo-ndk` (see that repo's `build-android.sh`). @@ -40,7 +43,7 @@ the core repo (tag + sha256 in `gradle.properties`) and unpacks the ./gradlew :tunnelcore:test # pure-Kotlin unit tests ./gradlew :app:testDebugUnitTest # app-module JVM unit tests ./gradlew :app:assembleDebug # app/build/outputs/apk/debug/app-debug.apk -./gradlew :app:installDebug # install on the connected device +ANDROID_SERIAL=10.22.35.66:5555 ./gradlew :app:installDebug # debug install on the emulator ``` Pin a newer core release with `scripts/bump-jnilibs.sh ` (rewrites the @@ -63,6 +66,18 @@ first use — back it up, devices only accept updates signed with the same key. A release build cannot be installed over a debug build of the app (different signature); uninstall the other one first. +The signed APK is the only thing that goes on the physical device: + +```bash +scripts/install-release-apk.sh # dist/ezvpn-android-.apk → 10.22.38.204:51035 +scripts/install-release-apk.sh --build # build it first +scripts/install-release-apk.sh --launch # and start the app +``` + +It verifies the signature with `apksigner` and refuses unsigned or +debug-signed APKs, and refuses to target the emulator +(`RELEASE_DEVICE_SERIAL` / `EMULATOR_SERIAL` override the serials). + ### Local FFI development To run against a local build of the core instead of the pinned release, build @@ -70,14 +85,29 @@ it in the sibling checkout and set `EZVPN_LOCAL_JNILIBS=1` (only the exact value `1` opts in; anything else uses the release): ```bash -(cd ../ezvpn && ./build-android.sh release) # or ABIS="arm64-v8a" ./build-android.sh debug -EZVPN_LOCAL_JNILIBS=1 ./gradlew :app:installDebug +(cd ../ezvpn && ABIS="arm64-v8a" ./build-android.sh release) # the emulator is arm64 +EZVPN_LOCAL_JNILIBS=1 ANDROID_SERIAL=10.22.35.66:5555 ./gradlew :app:installDebug +``` + +`scripts/run-device.sh` does all of it on the emulator — builds the core for +its ABI, installs the debug APK, launches the app, and tails `logcat` for the +`ezvpn` tag (`--pinned` skips the local core and uses the release, `--no-core` +skips rebuilding it, `ADB_SERIAL` picks another emulator). It refuses to target +the physical device. + +### Watching the emulator screen + +[scrcpy](https://github.com/Genymobile/scrcpy) mirrors and controls the +emulator (tap, type, paste) from the desktop; it is a LAN device like a phone, +so it is reached by serial: + +```bash +adb connect 10.22.35.66 +scrcpy -s 10.22.35.66:5555 ``` -`scripts/run-device.sh` does all of it — builds the core for the connected -device's ABI, installs, launches the app, and tails `logcat` for the `ezvpn` -tag (`--pinned` skips the local core and uses the release, `--no-core` skips -rebuilding it). +Always pass `-s`: with the physical device attached too, scrcpy would otherwise +refuse to pick one. ## Using the app @@ -112,7 +142,7 @@ the servers answer every name, as on any VPN app. ## Logs ```bash -adb logcat -s ezvpn +adb -s 10.22.35.66:5555 logcat -s ezvpn ``` Both the Kotlin side and the Rust core log under the `ezvpn` tag. diff --git a/gradle.properties b/gradle.properties index 489faa5..48fc550 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,9 +11,9 @@ android.nonTransitiveRClass=true # Bump both together with scripts/bump-jnilibs.sh , which also sets the # app versionName below. For local FFI dev set EZVPN_LOCAL_JNILIBS=1 to use # ../ezvpn/dist/android/jniLibs instead (see README). -ezvpn.releaseTag=v0.0.41 -ezvpn.releaseSha256=ca83a731664d23bff6f0d0c7f0ca565bfb72c1f7c5cb5963af8ed2bcff9bbeab +ezvpn.releaseTag=v0.0.42 +ezvpn.releaseSha256=e8526172a452ce8b20ef4407174be2ffb2e89d1a1e81e7579607c9bdc45b4ebf # App version; versionName follows the pinned ezvpn release (numeric part). -ezvpn.versionName=0.0.41 -ezvpn.versionCode=2 +ezvpn.versionName=0.0.42 +ezvpn.versionCode=3 diff --git a/scripts/install-release-apk.sh b/scripts/install-release-apk.sh new file mode 100755 index 0000000..628c6cd --- /dev/null +++ b/scripts/install-release-apk.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# Install the signed release APK on the physical device. +# +# This is the only thing the physical device (RELEASE_DEVICE_SERIAL below) is +# used for; all development, debug installs, and testing happen on the emulator +# via scripts/run-device.sh. The script refuses unsigned APKs and refuses to +# target the emulator. +# +# Usage: +# scripts/install-release-apk.sh # dist/ezvpn-android-.apk +# scripts/install-release-apk.sh path/to.apk # a specific signed APK +# scripts/install-release-apk.sh --build # run scripts/build-release-apk.sh first +# scripts/install-release-apk.sh --launch # also start the app afterwards +# RELEASE_DEVICE_SERIAL= scripts/install-release-apk.sh # another phone +# +# Note: a release-signed build cannot be installed over a debug build of the +# same applicationId; uninstall the other one first (adb uninstall ...). +# +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +EMULATOR_SERIAL="${EMULATOR_SERIAL:-10.22.35.66:5555}" +RELEASE_DEVICE_SERIAL="${RELEASE_DEVICE_SERIAL:-10.22.38.204:51035}" + +BUILD=0 +LAUNCH=0 +apk="" +for arg in "$@"; do + case "$arg" in + --build) BUILD=1 ;; + --launch) LAUNCH=1 ;; + -h|--help) sed -n '2,19p' "$0"; exit 0 ;; + -*) echo "unknown option: $arg" >&2; exit 1 ;; + *) apk="$arg" ;; + esac +done + +if [ "$RELEASE_DEVICE_SERIAL" = "$EMULATOR_SERIAL" ]; then + echo "refusing to install the release APK on the emulator $EMULATOR_SERIAL; it is for development only" >&2 + exit 1 +fi + +if [ "$BUILD" = 1 ]; then + scripts/build-release-apk.sh +fi + +if [ -z "$apk" ]; then + version="$(sed -n 's/^ezvpn\.versionName=//p' gradle.properties)" + apk="dist/ezvpn-android-$version.apk" +fi +[ -f "$apk" ] || { echo "no APK at $apk (build one with scripts/build-release-apk.sh or pass --build)" >&2; exit 1; } +case "$apk" in + *unsigned*) echo "refusing to install an unsigned APK: $apk" >&2; exit 1 ;; +esac + +# Require a verified signature: an unsigned or debug-signed APK must not reach +# the release device. +sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" +if [ -z "$sdk" ] && [ -f local.properties ]; then + sdk="$(sed -n 's/^sdk\.dir=//p' local.properties)" +fi +apksigner="$(ls -d "$sdk"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -n1 || true)" +[ -n "$apksigner" ] || { echo "apksigner not found under $sdk/build-tools; cannot verify $apk" >&2; exit 1; } +echo "== verifying signature of $apk" +certs="$("$apksigner" verify --print-certs "$apk")" || { echo "$apk is not validly signed" >&2; exit 1; } +echo "$certs" | grep -E 'certificate (DN|SHA-256)' +if echo "$certs" | grep -q 'CN=Android Debug'; then + echo "refusing to install a debug-signed APK on the release device" >&2 + exit 1 +fi + +# The physical device is attached over adb-over-TCP; (re)connect if needed. +case "$RELEASE_DEVICE_SERIAL" in + *:*) adb connect "$RELEASE_DEVICE_SERIAL" >/dev/null 2>&1 || true ;; +esac +ADB=(adb -s "$RELEASE_DEVICE_SERIAL") +state="$("${ADB[@]}" get-state 2>/dev/null || true)" +if [ "$state" != "device" ]; then + echo "release device $RELEASE_DEVICE_SERIAL is not ready (state: ${state:-absent}); check 'adb devices'" >&2 + exit 1 +fi + +echo "== installing $apk on $RELEASE_DEVICE_SERIAL ($("${ADB[@]}" shell getprop ro.product.model | tr -d '\r'))" +# adb-over-Wi-Fi occasionally drops a streamed install part-way with no reason +# given; reconnect and retry once before giving up. +if ! "${ADB[@]}" install -r "$apk"; then + echo "== install failed; reconnecting to $RELEASE_DEVICE_SERIAL and retrying once" + adb connect "$RELEASE_DEVICE_SERIAL" >/dev/null 2>&1 || true + "${ADB[@]}" install -r "$apk" +fi + +if [ "$LAUNCH" = 1 ]; then + echo "== launching" + "${ADB[@]}" shell am start -n dev.flexaccess.ezvpn/.MainActivity >/dev/null +fi diff --git a/scripts/run-device.sh b/scripts/run-device.sh index 4f9dfff..31b1c38 100755 --- a/scripts/run-device.sh +++ b/scripts/run-device.sh @@ -1,9 +1,14 @@ #!/usr/bin/env bash # -# Build, install, launch, and watch the app on the connected Android device. +# Build, install, launch, and watch the app on the development emulator. +# +# All development happens on the adb-connected emulator (EMULATOR_SERIAL below, +# an arm64 Android VM reachable over TCP). The physical device is reserved for +# installing the signed release APK (scripts/install-release-apk.sh) and this +# script refuses to target it. # # By default the Rust core is rebuilt from the sibling ../ezvpn checkout for the -# device's primary ABI (release profile) and the app links it via +# emulator's primary ABI (release profile) and the app links it via # EZVPN_LOCAL_JNILIBS=1. Then the debug APK is installed, the app launched, and # logcat tailed for the `ezvpn` tag (Ctrl-C to stop watching; the app keeps # running). @@ -13,7 +18,7 @@ # scripts/run-device.sh --no-core # local core as last built, skip rebuild # scripts/run-device.sh --pinned # the pinned release core instead # scripts/run-device.sh --no-log # don't tail logcat -# ADB_SERIAL=10.22.38.204:51035 scripts/run-device.sh # pick a device +# ADB_SERIAL= scripts/run-device.sh # another emulator # set -euo pipefail @@ -27,24 +32,34 @@ for arg in "$@"; do --no-core) REBUILD_CORE=0 ;; --pinned) LOCAL=0; REBUILD_CORE=0 ;; --no-log) TAIL_LOG=0 ;; - -h|--help) sed -n '2,18p' "$0"; exit 0 ;; + -h|--help) sed -n '2,23p' "$0"; exit 0 ;; *) echo "unknown option: $arg" >&2; exit 1 ;; esac done -ADB=(adb) -if [ -n "${ADB_SERIAL:-}" ]; then - ADB=(adb -s "$ADB_SERIAL") -fi -adb devices -if ! "${ADB[@]}" get-state >/dev/null 2>&1; then - echo "no device: check 'adb devices' (or set ADB_SERIAL)" >&2 +# The development emulator and the physical device the signed APK goes to. +EMULATOR_SERIAL="${EMULATOR_SERIAL:-10.22.35.66:5555}" +RELEASE_DEVICE_SERIAL="${RELEASE_DEVICE_SERIAL:-10.22.38.204:51035}" + +ADB_SERIAL="${ADB_SERIAL:-$EMULATOR_SERIAL}" +if [ "$ADB_SERIAL" = "$RELEASE_DEVICE_SERIAL" ]; then + echo "refusing to target the physical device $RELEASE_DEVICE_SERIAL: development runs on the emulator ($EMULATOR_SERIAL);" >&2 + echo "the physical device only gets the signed release APK via scripts/install-release-apk.sh" >&2 exit 1 fi -# A VPN needs the real network stack: this script targets physical devices only. -if [ "$("${ADB[@]}" shell getprop ro.kernel.qemu | tr -d '\r')" = "1" ] || - [ "$("${ADB[@]}" shell getprop ro.boot.qemu | tr -d '\r')" = "1" ]; then - echo "the selected target is an emulator; connect a physical device (or set ADB_SERIAL to one)" >&2 +# ANDROID_SERIAL makes Gradle's installDebug (and plain adb) use the same target +# instead of failing/fanning out when several devices are attached. +export ANDROID_SERIAL="$ADB_SERIAL" +ADB=(adb -s "$ADB_SERIAL") + +# The emulator is reachable over TCP; (re)connect if adb has lost it. +case "$ADB_SERIAL" in + *:*) adb connect "${ADB_SERIAL}" >/dev/null 2>&1 || true ;; +esac +adb devices +state="$("${ADB[@]}" get-state 2>/dev/null || true)" +if [ "$state" != "device" ]; then + echo "emulator $ADB_SERIAL is not ready (state: ${state:-absent}); start it / accept its USB-debugging prompt, or set ADB_SERIAL" >&2 exit 1 fi @@ -53,7 +68,7 @@ if [ "$REBUILD_CORE" = 1 ]; then abi="${abilist%%,*}" case "$abi" in arm64-v8a|armeabi-v7a|x86_64|x86) ;; - *) echo "unsupported device ABI '$abi'" >&2; exit 1 ;; + *) echo "unsupported emulator ABI '$abi'" >&2; exit 1 ;; esac echo "== building libezvpn.so for $abi in ../ezvpn" (cd ../ezvpn && ABIS="$abi" ./build-android.sh release)