Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 <serial>` 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 <tag>` 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.
Expand Down
50 changes: 40 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand All @@ -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 <tag>` (rewrites the
Expand All @@ -63,21 +66,48 @@ 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-<version>.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
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

Expand Down Expand Up @@ -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.
8 changes: 4 additions & 4 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ android.nonTransitiveRClass=true
# Bump both together with scripts/bump-jnilibs.sh <tag>, 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
97 changes: 97 additions & 0 deletions scripts/install-release-apk.sh
Original file line number Diff line number Diff line change
@@ -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-<versionName>.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=<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
47 changes: 31 additions & 16 deletions scripts/run-device.sh
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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=<serial> scripts/run-device.sh # another emulator
#
set -euo pipefail

Expand All @@ -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

Expand All @@ -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)
Expand Down
Loading