A native Kotlin/Jetpack Compose client for Android that connects to an
ezvpn server: dual-stack split
tunnel, optional tunnel DNS with split-DNS match domains (implemented by an
in-tunnel forwarder, since Android has no per-domain VPN DNS), underlay bypass,
always-on support. The Rust core (libezvpn.so, built from the ezvpn repo)
runs the data plane inside a VpnService; this repo is the app, the service,
and the pure-Kotlin tunnelcore module around it.
Design and the JNI contract are documented in the core repo:
docs/Android-App.md.
| Module | What |
|---|---|
app |
The Compose app (MainActivity, screens under ui/), EzvpnVpnService, TunnelsManager, the encrypted secret/profile stores, and EzvpnNative (the JNI binding — its package and name are fixed by the symbols in libezvpn.so). |
tunnelcore |
Pure Kotlin, no Android dependency: IP/CIDR math (IpPrefix, RouteMath.subtract for the bypass on Android < 13, which has no excludeRoute), the profile model + editor validation, the VpnService.Builder plan (TunnelPlan), split-DNS rules (SplitDns, DnsProxy), and the core's JSON shapes. Unit-tested on the JVM. |
- JDK 17, Android SDK with platform 37 and build-tools 37 (the Gradle wrapper brings Gradle itself; AGP 9 with built-in Kotlin).
- An Android 10+ (
minSdk29) 64-bit device: arm64-v8a or x86_64 (32-bit is not supported, per Play's 64-bit requirement). Development is done against an adb-connected device — a phone, or an Android VM/emulator bridged onto the LAN like a phone (adb connect <host>) — since aVpnServicecannot be exercised on the JVM. The Android Studio emulator is NATed twice by default and only ever gets relay paths; see Emulator networking for the flags that put it on the LAN. - For FFI work: the sibling
../ezvpncheckout, the Android NDK andcargo-ndk(see that repo'sbuild-android.sh).
local.properties (git-ignored) points Gradle at the SDK:
sdk.dir=/path/to/Android/Sdk.
By default the app downloads the pinned libezvpn-android.zip release asset of
the core repo (tag + sha256 in gradle.properties) and unpacks the
jniLibs/<abi>/libezvpn.so tree into app/build/ezvpn-jnilibs:
./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
ANDROID_SERIAL=<serial> ./gradlew :app:installDebug # debug install on that devicePin a newer core release with scripts/bump-jnilibs.sh <tag> (rewrites the
tag, the sha256, and the app version in gradle.properties).
CI (.github/workflows/ci.yml) runs the tests and uploads the debug APK of
every push/PR as the app-debug workflow artifact.
scripts/build-release-apk.sh # → dist/ezvpn-android-<version>.apk
scripts/build-release-apk.sh --bundle # → dist/ezvpn-android-<version>.aab (for Play Console)
scripts/build-release-apk.sh --unsigned # no signing (not installable as is)Google Play only accepts an App Bundle (.aab), on every track including
internal testing; --bundle builds one signed with the same keystore (Play
App Signing uses it as the upload key). Every upload needs a higher
ezvpn.versionCode in gradle.properties. The app's ezvpn.versionName /
ezvpn.versionCode are its own and independent of the pinned ezvpn release:
scripts/bump-jnilibs.sh only re-pins the core, whose version the app shows
next to its own.
The release key is a keystore outside the repo (default
~/.config/ezvpn-android/release.jks, override with EZVPN_KEYSTORE;
password from EZVPN_KEYSTORE_PASSWORD or prompted). The script creates it on
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.
Install the signed APK on a real device (the serial is required — there is no default device):
RELEASE_DEVICE_SERIAL=<serial> scripts/install-release-apk.sh # dist/ezvpn-android-<version>.apk
RELEASE_DEVICE_SERIAL=<serial> scripts/install-release-apk.sh --build # build it first
RELEASE_DEVICE_SERIAL=<serial> scripts/install-release-apk.sh --launch # and start the appIt verifies the signature with apksigner and refuses unsigned or
debug-signed APKs, and refuses to target the emulator (EMULATOR_SERIAL
overrides that serial).
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):
(cd ../ezvpn && ABIS="arm64-v8a" ./build-android.sh release)
EZVPN_LOCAL_JNILIBS=1 ANDROID_SERIAL=<serial> ./gradlew :app:installDebugscripts/run-device.sh does all of it — builds the core for the device's 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). It targets ADB_SERIAL, else EMULATOR_SERIAL (a convenient
shell default for a development emulator/VM), else the single attached device;
a host:port serial is (re)connected first.
- Auth keys (key icon): generate a key on the device or paste an
ed25519-sec:…secret from another device. Put the showned25519-pub:…line on the server'sauthorized_keysfile. Keys are shared by all profiles; deleting one does not affect profiles already saved with it. - Add a profile (+): server node id, the auth key, optional custom relay URLs (+ token), split-tunnel CIDRs (the server gateway is always routed), and optionally DNS servers with match domains.
- Toggle the profile to connect. The first connect shows Android's VPN consent dialog. The detail screen shows the applied addresses, routes, bypass set, DNS setup, and a live "Connection path" readout (direct vs relay).
Network changes disconnect the tunnel (reconnect on the new network). A profile whose split-tunnel prefix overlaps the Wi-Fi/Ethernet subnet the device is on is refused, since routing the local subnet into the tunnel would cut the tunnel's own underlay off. For always-on VPN (Settings › Network › VPN › ezvpn), the service connects the last-used profile when the system starts it.
With DNS servers and match domains set, names under the match domains resolve
through the profile's servers over the tunnel and everything else keeps the
network's normal resolvers. Android's VpnService cannot express that, so the
app points the VPN's DNS at a proxy address inside the tunnel (198.18.0.53 /
fd7e:7a00:d45::53) and the Rust core forwards each query to the right
resolver (Tailscale's MagicDNS approach). With servers but no match domains,
the servers answer every name, as on any VPN app.
adb logcat -s ezvpn # add -s <serial> with several devices attachedBoth the Kotlin side and the Rust core log under the ezvpn tag.
Out of the box the Android Studio emulator (36.5+) is relay-only for ezvpn:
its Wi-Fi goes through the network simulator's own user-mode NAT (netsim,
feature WiFiPacketStream), and the host usually NATs once more, so iroh never
finds a direct path and every "Connection path" readout says relay. Routing,
bypass and split-DNS logic still work, but latency/path testing does not.
-vmnet-bridged on its own does not fix it: it only re-backs the emulated
cellular interface (eth0, hard-wired to 10.0.2.x), because Wi-Fi frames
never reach the QEMU network device while WiFiPacketStream is on. Turn that
feature off as well and Wi-Fi (wlan0) is bridged onto the host's LAN, gets a
real DHCP lease / SLAAC prefix, and Android makes it the validated default
network — with a stock Play Store image, no root needed:
# macOS, Apple silicon (vmnet bridged mode needs root); en0/en8 = the host's LAN interface
sudo ~/Library/Android/sdk/emulator/emulator -avd Pixel_9_API_37 -port 5554 -grpc 8554 -gpu host \
-no-snapshot-load -no-snapshot-save \
-feature -WiFiPacketStream -vmnet-bridged en0On Linux the equivalent is -feature -WiFiPacketStream -wifi-tap tap0 with
tap0 bridged to the LAN interface (per emulator -help-all; not exercised
here). Verify from the guest:
adb shell ip -br addr show wlan0 # a LAN address, not 10.0.2.x
adb shell 'dumpsys connectivity | grep "Active default"' # the WIFI network
adb shell ping -c 2 <lan-gateway>then connect a profile and open its "Connection path" readout — it should show
a direct path. Verified with emulator 37.1.11 and the API 37
google_apis_playstore arm64 image.
Notes:
- Running under
sudoleaves root-owned files in the AVD directory (~/.android/avd/<name>.avd: snapshots, locks,hardware-qemu.ini); a later non-sudo start that fails on permissions is that. - Always pass both
-no-snapshot-*flags: a stale Quick Boot snapshot fails to load with agoldfish_pipeerror. - A remote emulator's adb port is loopback-only; forward it
(
ssh -f -N -L 15555:127.0.0.1:5555 <host>, thenadb connect 127.0.0.1:15555). - If
adb rootis needed as well, use auserdebugimage:system-images;android-37.0;google_apis;arm64-v8aor the pure-AOSPsystem-images;android-36;default;arm64-v8a(no API 37defaultimage exists). Thegoogle_apis_playstoreimages areuserbuilds.
Useful when the device is remote (wireless adb, a VM, an emulator reached over an ssh port forward) or when a script needs to watch the screen.
Screenshots — adb exec-out screencap -p > shot.png works everywhere and
needs no display. If an X display is available (a desktop session on the build
host), mirror the device with scrcpy
and capture its window with scrot:
DISPLAY=:0 scrcpy -s <serial> --window-title dev --max-size 1000 & # live mirror (always -s: scrcpy refuses to pick among several devices)
WID=$(DISPLAY=:0 xdotool search --name '^dev$' | head -1)
DISPLAY=:0 scrot -o -w "$WID" shot.png # just the device window (-o overwrites)
DISPLAY=:0 scrot -o shot.png # the whole desktopscrcpy keyboard shortcuts sent to that window reach the device (xdotool key --window "$WID" alt+n opens the notification shade), but synthetic xdotool
mouse clicks do not register in scrcpy's SDL window — drive the device with
adb instead:
adb shell input tap X Y # coordinates in device pixels
adb shell input text 'hello%sworld' # %s for a space
adb shell input keyevent KEYCODE_BACK # keyevent 111 (ESCAPE) hides the keyboard without navigating back
adb shell 'uiautomator dump /sdcard/ui.xml >/dev/null; cat /sdcard/ui.xml' # widget tree with boundsuiautomator dump sees Compose text and content descriptions with their
bounds, which is enough to find a field or button to tap. Two Compose quirks:
an unfocused text field is best tapped a little below its label's top edge, and
pressing BACK to dismiss the keyboard also dismisses a Compose dialog — tap the
dialog's button instead. Emulators with Gboard show a one-time "Try out your
stylus" sheet over the first text field; it swallows taps until cancelled.