From e98f3c99591482f11493d0119d6f3d97e7450d26 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Fri, 21 Aug 2026 08:57:55 -0700 Subject: [PATCH 1/3] Add the Android app: Compose UI, VpnService, tunnelcore, split DNS A native Kotlin/Jetpack Compose client for ezvpn. The app, EzvpnVpnService, TunnelsManager, the AndroidKeyStore-encrypted secret and profile stores, the shared auth-key manager, and EzvpnNative (the JNI binding to libezvpn.so, whose symbols fix the class at dev.flexaccess.ezvpn.EzvpnNative) live in :app; the pure-Kotlin :tunnelcore module holds the CIDR math (including the bypass-by-subtraction route plan, since VpnService has no excludeRoute before API 33), the profile model and editor validation, the VpnService.Builder plan, and the split-DNS rules, all unit-tested on the JVM. Split DNS (match domains) is implemented by pointing the VPN's DNS at the core's in-tunnel forwarder address, routed as a host route, with the underlying network's resolvers and protect()ed fallback sockets handed to the core at connect time. Network changes disconnect; split-tunnel prefixes that overlap the current Wi-Fi/Ethernet subnet are refused; always-on starts connect the last-used profile. libezvpn.so comes from the pinned ezvpn release zip (tag + sha256 in gradle.properties, scripts/bump-jnilibs.sh) or, with EZVPN_LOCAL_JNILIBS=1, from the sibling ../ezvpn/dist/android build; scripts/run-device.sh builds, installs, launches, and tails logcat on the connected device. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WZxQCX8Kwr4yZV6R96TaYP --- .github/workflows/ci.yml | 40 ++ .gitignore | 17 + AGENTS.md | 1 + CLAUDE.md | 9 + README.md | 100 ++++ app/build.gradle.kts | 150 ++++++ app/src/main/AndroidManifest.xml | 47 ++ .../flexaccess/ezvpn/AndroidLocalNetworks.kt | 40 ++ .../kotlin/dev/flexaccess/ezvpn/AuthKey.kt | 32 ++ .../dev/flexaccess/ezvpn/AuthKeyStore.kt | 169 +++++++ .../dev/flexaccess/ezvpn/EzvpnApplication.kt | 14 + .../dev/flexaccess/ezvpn/EzvpnNative.kt | 77 ++++ .../dev/flexaccess/ezvpn/EzvpnVpnService.kt | 426 ++++++++++++++++++ .../dev/flexaccess/ezvpn/MainActivity.kt | 58 +++ .../dev/flexaccess/ezvpn/ProfileStore.kt | 110 +++++ .../dev/flexaccess/ezvpn/SecretStore.kt | 103 +++++ .../dev/flexaccess/ezvpn/TunnelState.kt | 36 ++ .../dev/flexaccess/ezvpn/TunnelsManager.kt | 206 +++++++++ .../dev/flexaccess/ezvpn/ui/ConnPathSheet.kt | 111 +++++ .../dev/flexaccess/ezvpn/ui/EzvpnRoot.kt | 106 +++++ .../dev/flexaccess/ezvpn/ui/KeysScreen.kt | 261 +++++++++++ .../kotlin/dev/flexaccess/ezvpn/ui/Shared.kt | 96 ++++ .../kotlin/dev/flexaccess/ezvpn/ui/Theme.kt | 46 ++ .../flexaccess/ezvpn/ui/TunnelDetailScreen.kt | 190 ++++++++ .../flexaccess/ezvpn/ui/TunnelEditScreen.kt | 220 +++++++++ .../flexaccess/ezvpn/ui/TunnelListScreen.kt | 125 +++++ .../res/drawable/ic_launcher_foreground.xml | 19 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/colors.xml | 4 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 5 + .../dev/flexaccess/ezvpn/TunnelStateTest.kt | 30 ++ build.gradle.kts | 8 + gradle.properties | 19 + gradle/libs.versions.toml | 35 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 +++++++++++ gradlew.bat | 94 ++++ scripts/bump-jnilibs.sh | 38 ++ scripts/run-device.sh | 71 +++ settings.gradle.kts | 22 + tunnelcore/build.gradle.kts | 27 ++ .../flexaccess/ezvpn/tunnelcore/IpPrefix.kt | 234 ++++++++++ .../ezvpn/tunnelcore/LocalNetworks.kt | 48 ++ .../flexaccess/ezvpn/tunnelcore/SplitDns.kt | 69 +++ .../ezvpn/tunnelcore/TunnelNameValidation.kt | 72 +++ .../flexaccess/ezvpn/tunnelcore/TunnelPlan.kt | 231 ++++++++++ .../ezvpn/tunnelcore/TunnelProfile.kt | 95 ++++ .../ezvpn/tunnelcore/TunnelProfileForm.kt | 106 +++++ .../ezvpn/tunnelcore/TunnelSnapshots.kt | 77 ++++ .../ezvpn/tunnelcore/IpPrefixTest.kt | 123 +++++ .../ezvpn/tunnelcore/LocalNetworksTest.kt | 50 ++ .../ezvpn/tunnelcore/TunnelPlanTest.kt | 153 +++++++ .../ezvpn/tunnelcore/TunnelProfileFormTest.kt | 136 ++++++ 56 files changed, 4828 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 120000 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 app/build.gradle.kts create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/AndroidLocalNetworks.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/AuthKey.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/AuthKeyStore.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnApplication.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnNative.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnVpnService.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/MainActivity.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/ProfileStore.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/SecretStore.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/TunnelState.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/TunnelsManager.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/ui/ConnPathSheet.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/ui/EzvpnRoot.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/ui/KeysScreen.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/ui/Shared.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/ui/Theme.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/ui/TunnelDetailScreen.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/ui/TunnelEditScreen.kt create mode 100644 app/src/main/kotlin/dev/flexaccess/ezvpn/ui/TunnelListScreen.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/test/kotlin/dev/flexaccess/ezvpn/TunnelStateTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100755 scripts/bump-jnilibs.sh create mode 100755 scripts/run-device.sh create mode 100644 settings.gradle.kts create mode 100644 tunnelcore/build.gradle.kts create mode 100644 tunnelcore/src/main/kotlin/dev/flexaccess/ezvpn/tunnelcore/IpPrefix.kt create mode 100644 tunnelcore/src/main/kotlin/dev/flexaccess/ezvpn/tunnelcore/LocalNetworks.kt create mode 100644 tunnelcore/src/main/kotlin/dev/flexaccess/ezvpn/tunnelcore/SplitDns.kt create mode 100644 tunnelcore/src/main/kotlin/dev/flexaccess/ezvpn/tunnelcore/TunnelNameValidation.kt create mode 100644 tunnelcore/src/main/kotlin/dev/flexaccess/ezvpn/tunnelcore/TunnelPlan.kt create mode 100644 tunnelcore/src/main/kotlin/dev/flexaccess/ezvpn/tunnelcore/TunnelProfile.kt create mode 100644 tunnelcore/src/main/kotlin/dev/flexaccess/ezvpn/tunnelcore/TunnelProfileForm.kt create mode 100644 tunnelcore/src/main/kotlin/dev/flexaccess/ezvpn/tunnelcore/TunnelSnapshots.kt create mode 100644 tunnelcore/src/test/kotlin/dev/flexaccess/ezvpn/tunnelcore/IpPrefixTest.kt create mode 100644 tunnelcore/src/test/kotlin/dev/flexaccess/ezvpn/tunnelcore/LocalNetworksTest.kt create mode 100644 tunnelcore/src/test/kotlin/dev/flexaccess/ezvpn/tunnelcore/TunnelPlanTest.kt create mode 100644 tunnelcore/src/test/kotlin/dev/flexaccess/ezvpn/tunnelcore/TunnelProfileFormTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4564f93 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@v4 + + - name: tunnelcore unit tests + run: ./gradlew :tunnelcore:test --console=plain + + # The APK needs a jniLibs tree. Until a release with the pinned sha256 + # exists, point the build at an empty local tree (the .so is loaded at + # runtime only, so the build and the JVM tests do not need it). + - name: Stage jniLibs + run: | + if grep -qE '^ezvpn.releaseSha256=[0-9a-f]{64}$' gradle.properties; then + echo "EZVPN_LOCAL_JNILIBS=" >> "$GITHUB_ENV" + else + mkdir -p ../ezvpn/dist/android/jniLibs + echo "EZVPN_LOCAL_JNILIBS=1" >> "$GITHUB_ENV" + fi + + - name: App unit tests + debug APK + run: ./gradlew :app:testDebugUnitTest :app:assembleDebug --console=plain + + - uses: actions/upload-artifact@v4 + with: + name: app-debug + path: app/build/outputs/apk/debug/app-debug.apk diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..34d9dec --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Gradle +.gradle/ +build/ +local.properties +.kotlin/ + +# IDE +.idea/ +*.iml +.DS_Store + +# Signing +*.jks +*.keystore + +tmp/ +CLAUDE.local.md diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bb81270 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,9 @@ +- 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. +- 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. +- no foreground-service notification: the system binds the VpnService while its interface is established, which keeps the process alive (WireGuard does the same). Don't add one. +- the in-tunnel split-DNS forwarder is an Android-only workaround for the platform having no per-domain VPN DNS; the proxy addresses are `DnsProxy.ADDRESS4/6` in tunnelcore and must match what the core intercepts (they are passed in the config JSON, so changing them here is enough). +- run `./gradlew :tunnelcore:test :app:testDebugUnitTest` and build the APK after Kotlin changes. Gradle needs JDK 17 (`JAVA_HOME`). diff --git a/README.md b/README.md index e69de29..ec5a25c 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,100 @@ +# ezvpn-android + +A native Kotlin/Jetpack Compose client for Android that connects to an +[`ezvpn`](https://github.com/flexaccessdev/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`](https://github.com/flexaccessdev/ezvpn/blob/main/docs/Android-App.md). + +## Layout + +| 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 no-`excludeRoute` bypass), 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. | + +## Requirements + +- 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. +- For FFI work: the sibling `../ezvpn` checkout, the Android NDK and + `cargo-ndk` (see that repo's `build-android.sh`). + +`local.properties` (git-ignored) points Gradle at the SDK: +`sdk.dir=/path/to/Android/Sdk`. + +## Building + +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//libezvpn.so` tree into `app/build/ezvpn-jnilibs`: + +```bash +./gradlew :tunnelcore:test # pure-Kotlin unit tests +./gradlew :app:assembleDebug # app/build/outputs/apk/debug/app-debug.apk +./gradlew :app:installDebug # install on the connected device +``` + +Pin a newer core release with `scripts/bump-jnilibs.sh ` (rewrites the +tag, the sha256, and the app version in `gradle.properties`). + +### 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 +``` + +`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). + +## Using the app + +1. **Auth keys** (key icon): generate a key on the device or paste an + `ed25519-sec:…` secret from another device. Put the shown `ed25519-pub:…` + line on the server's `authorized_keys` file. Keys are shared by all + profiles; deleting one does not affect profiles already saved with it. +2. **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. +3. 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. + +### Split DNS + +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. + +## Logs + +```bash +adb logcat -s ezvpn +``` + +Both the Kotlin side and the Rust core log under the `ezvpn` tag. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..aca4818 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,150 @@ +import java.io.FileOutputStream +import java.net.URI +import java.security.MessageDigest +import java.util.zip.ZipInputStream + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +// --------------------------------------------------------------------------- +// libezvpn.so delivery. +// +// Default: download the pinned ezvpn release zip (libezvpn-android.zip, the +// jniLibs//libezvpn.so tree built by ../ezvpn/build-android.sh) by +// URL + sha256 into build/, mirroring how ezvpn-apple pins its xcframework. +// Local FFI dev: EZVPN_LOCAL_JNILIBS=1 (exactly) points the jniLibs source set +// at ../ezvpn/dist/android/jniLibs instead. Any other value selects the release. +val ezvpnLocalJniLibs = System.getenv("EZVPN_LOCAL_JNILIBS") == "1" +val ezvpnReleaseTag = providers.gradleProperty("ezvpn.releaseTag").get() +val ezvpnReleaseSha256 = providers.gradleProperty("ezvpn.releaseSha256").getOrElse("") +val ezvpnReleaseUrl = + "https://github.com/flexaccessdev/ezvpn/releases/download/$ezvpnReleaseTag/libezvpn-android.zip" +val ezvpnLocalDir = rootProject.file("../ezvpn/dist/android/jniLibs") +val ezvpnFetchedDir = layout.buildDirectory.dir("ezvpn-jnilibs") +val ezvpnJniLibsDir: File = + if (ezvpnLocalJniLibs) ezvpnLocalDir else ezvpnFetchedDir.get().asFile.resolve("jniLibs") + +val fetchEzvpnJniLibs by tasks.registering { + description = "Downloads the pinned libezvpn-android.zip release and verifies its sha256." + val url = ezvpnReleaseUrl + val sha256 = ezvpnReleaseSha256 + val outDir = ezvpnFetchedDir + val skip = ezvpnLocalJniLibs + inputs.property("url", url) + inputs.property("sha256", sha256) + outputs.dir(outDir) + onlyIf { !skip } + doLast { + require(sha256.matches(Regex("[0-9a-f]{64}"))) { + "ezvpn.releaseSha256 is not set in gradle.properties: run " + + "scripts/bump-jnilibs.sh to pin a published ezvpn release, " + + "or build ../ezvpn with ./build-android.sh and set EZVPN_LOCAL_JNILIBS=1." + } + val dir = outDir.get().asFile + val stamp = dir.resolve("sha256.txt") + if (stamp.isFile && stamp.readText().trim() == sha256 && + dir.resolve("jniLibs").isDirectory + ) { + return@doLast + } + dir.deleteRecursively() + dir.mkdirs() + logger.lifecycle("Downloading $url") + val bytes = URI(url).toURL().openStream().use { it.readBytes() } + val actual = MessageDigest.getInstance("SHA-256").digest(bytes) + .joinToString("") { "%02x".format(it) } + require(actual == sha256) { + "sha256 mismatch for $url: expected $sha256, got $actual" + } + ZipInputStream(bytes.inputStream()).use { zip -> + generateSequence { zip.nextEntry }.forEach { entry -> + val target = dir.resolve(entry.name).canonicalFile + require(target.path.startsWith(dir.canonicalPath)) { "zip entry escapes dir: ${entry.name}" } + if (entry.isDirectory) { + target.mkdirs() + } else { + target.parentFile.mkdirs() + FileOutputStream(target).use { zip.copyTo(it) } + } + } + } + require(dir.resolve("jniLibs").isDirectory) { "zip did not contain a jniLibs/ tree" } + stamp.writeText(sha256) + } +} + +android { + namespace = "dev.flexaccess.ezvpn" + compileSdk = 37 + + defaultConfig { + // The JNI symbols in libezvpn.so (ezvpn/src/ffi_android.rs) are bound to + // the class dev.flexaccess.ezvpn.EzvpnNative; the applicationId can + // change, the package of that class cannot. + applicationId = "dev.flexaccess.ezvpn" + minSdk = 29 + targetSdk = 37 + versionCode = providers.gradleProperty("ezvpn.versionCode").get().toInt() + versionName = providers.gradleProperty("ezvpn.versionName").get() + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + sourceSets["main"].jniLibs.srcDir(ezvpnJniLibsDir) + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + buildConfig = true + } + + packaging { + // The .so files are built against the NDK with 16 KiB page alignment; + // keep them uncompressed and aligned as the system expects. + jniLibs.useLegacyPackaging = false + } + + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +tasks.named("preBuild") { + dependsOn(fetchEzvpnJniLibs) +} + +tasks.withType().configureEach { + testLogging { + events("passed", "failed", "skipped") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } +} + +dependencies { + implementation(project(":tunnelcore")) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.kotlinx.coroutines.android) + debugImplementation(libs.androidx.compose.ui.tooling) + + testImplementation(libs.junit) + testImplementation(libs.org.json) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c876d7c --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/AndroidLocalNetworks.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/AndroidLocalNetworks.kt new file mode 100644 index 0000000..df97d8e --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/AndroidLocalNetworks.kt @@ -0,0 +1,40 @@ +package dev.flexaccess.ezvpn + +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import dev.flexaccess.ezvpn.tunnelcore.IpPrefix +import dev.flexaccess.ezvpn.tunnelcore.LocalNetwork +import dev.flexaccess.ezvpn.tunnelcore.LocalNetworks + +/** + * The on-link subnets of the networks the device is attached to, for the + * split-tunnel conflict check ([LocalNetworks.splitTunnelConflict]). Only + * broadcast networks (Wi-Fi, Ethernet) carry an on-link subnet; cellular is + * point-to-point and our own VPN interface must not count. Host addresses + * (/32, /128) and IPv6 link-local are skipped as they never conflict. + */ +object AndroidLocalNetworks { + fun current(cm: ConnectivityManager): List { + @Suppress("DEPRECATION") + val networks = cm.allNetworks + return networks.flatMap { network -> + val caps = cm.getNetworkCapabilities(network) ?: return@flatMap emptyList() + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return@flatMap emptyList() + if (!caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + !caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + ) { + return@flatMap emptyList() + } + val link = cm.getLinkProperties(network) ?: return@flatMap emptyList() + val name = link.interfaceName ?: "?" + link.linkAddresses.mapNotNull { la -> + val bytes = la.address.address + when { + bytes.size == 4 && la.prefixLength >= 32 -> null + bytes.size == 16 && (la.prefixLength >= 128 || LocalNetworks.isLinkLocalV6(bytes)) -> null + else -> IpPrefix.of(bytes, la.prefixLength)?.let { LocalNetwork(name, it) } + } + } + } + } +} diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/AuthKey.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/AuthKey.kt new file mode 100644 index 0000000..7595711 --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/AuthKey.kt @@ -0,0 +1,32 @@ +package dev.flexaccess.ezvpn + +import org.json.JSONObject + +/** + * Client auth keypair primitives, via the Rust FFI. A secret key + * ("ed25519-sec:…") authenticates the tunnel handshake; its public key + * ("ed25519-pub:…") is not a secret — it's what the user puts on the server's + * authorized_keys file, and it's re-derived from the secret whenever needed + * rather than stored. The app's named key list lives in [AuthKeyStore]. + * Keys are never generated or parsed in Kotlin: the shared FlexAccess key + * format is owned by the Rust side. + */ +object AuthKey { + data class Keypair(val secretKey: String, val publicKey: String) + + /** Generate a fresh ed25519 keypair; null only if the system RNG failed. */ + fun generate(): Keypair? { + val json = runCatching { EzvpnNative.generateClientKey() }.getOrNull() ?: return null + val obj = runCatching { JSONObject(json) }.getOrNull() ?: return null + val secret = obj.optString("secret_key", "") + val public = obj.optString("public_key", "") + if (secret.isEmpty() || public.isEmpty()) return null + return Keypair(secret, public) + } + + /** The public key of `secret`, or null when it isn't a valid secret key. */ + fun publicKey(secret: String): String? { + if (secret.isEmpty()) return null + return EzvpnNative.clientPublicKey(secret) + } +} diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/AuthKeyStore.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/AuthKeyStore.kt new file mode 100644 index 0000000..7a6d3cd --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/AuthKeyStore.kt @@ -0,0 +1,169 @@ +package dev.flexaccess.ezvpn + +import dev.flexaccess.ezvpn.tunnelcore.NameResult +import dev.flexaccess.ezvpn.tunnelcore.TunnelNameError +import dev.flexaccess.ezvpn.tunnelcore.TunnelNames +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONArray +import org.json.JSONObject +import java.util.UUID + +/** + * The app's shared, named client auth keys — the same model the desktop and + * Apple apps use: one list of keypairs that profiles reference by id, so + * several profiles can authenticate with one device identity instead of + * pasting the same secret into each. + * + * The whole list persists as one JSON document in the [SecretStore] (names + * ride along with the secrets; public halves are never stored — each is + * re-derived via the FFI on load). The tunnel never reads this list: saving a + * profile copies the selected key's secret into that profile's own secret, which + * is what the service resolves (see [ProfileStore]). + */ +class AuthKeyStore(private val secrets: SecretStore) { + /** One named keypair. `publicKey` is derived, not persisted. */ + data class Key(val id: String, val name: String, val secret: String, val publicKey: String) + + private val _keys = MutableStateFlow>(emptyList()) + val keys: StateFlow> = _keys.asStateFlow() + + /** + * Why the stored list couldn't be read, or null once it was (an absent + * entry counts: a fresh install genuinely has no keys). While set, every + * write is refused — persisting would replace the real list with this + * partial view. + */ + private var loadError: String? = null + + init { + val json = try { + secrets.get(KEY_LIST) + } catch (e: SecretStoreException) { + loadError = "Couldn't read the key list: ${e.message}. Keys can't be changed until it can be read." + null + } + if (loadError == null && json != null) { + val parsed = runCatching { JSONArray(json) }.getOrNull() + if (parsed == null) { + // Undecodable JSON is a load failure too, never an empty list. + loadError = "The stored key list couldn't be decoded. Keys can't be changed until it can be read." + } else { + val stored = (0 until parsed.length()).mapNotNull { parsed.optJSONObject(it) } + // A record whose secret no longer derives a public key is corrupt — + // drop it rather than carry an entry that can never connect. + val keys = stored.mapNotNull { obj -> + val secret = obj.optString("secret", "") + AuthKey.publicKey(secret)?.let { + Key(obj.optString("id"), obj.optString("name", "Unnamed"), secret, it) + } + } + _keys.value = keys + // Make the pruning stick so a corrupt record doesn't linger. + if (keys.size != stored.size) persist() + } + } + } + + fun key(id: String): Key? = _keys.value.firstOrNull { it.id == id } + + /** + * Validate and add a key: the name follows the profile-name rules and the + * secret must parse. The same keypair twice under two names is an + * accidental re-add, not a use case. Returns the key, or an error message. + */ + fun add(rawName: String, rawSecret: String): Result { + val name = when (val r = validated(rawName, excluding = null)) { + is Validated.Ok -> r.name + is Validated.Err -> return Result.failure(AuthKeyStoreException(r.message)) + } + val secret = rawSecret.trim() + val publicKey = AuthKey.publicKey(secret) + ?: return Result.failure(AuthKeyStoreException("Not a valid secret key (expected ed25519-sec:…).")) + _keys.value.firstOrNull { it.publicKey == publicKey }?.let { + return Result.failure(AuthKeyStoreException("Key \"${it.name}\" already holds this secret.")) + } + val key = Key(UUID.randomUUID().toString(), name, secret, publicKey) + val previous = _keys.value + _keys.value = previous + key + persist()?.let { + _keys.value = previous + return Result.failure(AuthKeyStoreException(it)) + } + return Result.success(key) + } + + /** Rename `id`; returns a user-facing error message, or null on success. */ + fun rename(id: String, newName: String): String? { + val previous = _keys.value + val index = previous.indexOfFirst { it.id == id } + if (index < 0) return null + val name = when (val r = validated(newName, excluding = id)) { + is Validated.Ok -> r.name + is Validated.Err -> return r.message + } + _keys.value = previous.toMutableList().also { it[index] = it[index].copy(name = name) } + persist()?.let { + _keys.value = previous + return it + } + return null + } + + /** + * Delete `id`; returns an error message when the removal couldn't be written + * back. Profiles already saved with this key keep working: their own copy + * of the secret is what connects. + */ + fun delete(id: String): String? { + val previous = _keys.value + if (previous.none { it.id == id }) return null + _keys.value = previous.filter { it.id != id } + persist()?.let { + _keys.value = previous + return it + } + return null + } + + private sealed class Validated { + class Ok(val name: String) : Validated() + class Err(val message: String) : Validated() + } + + private fun validated(raw: String, excluding: String?): Validated { + val own = excluding?.let { key(it)?.name } + val others = _keys.value.filter { it.id != excluding }.map { it.name } + return when (val r = TunnelNames.validate(raw, others, excluding = own)) { + is NameResult.Valid -> Validated.Ok(r.name) + is NameResult.Invalid -> Validated.Err( + when (r.error) { + TunnelNameError.EMPTY -> "Key name is required." + TunnelNameError.DUPLICATE -> "Another key is already named that." + }, + ) + } + } + + /** Write the whole list back; returns a user-facing error message on failure. */ + private fun persist(): String? { + loadError?.let { return it } + val array = JSONArray() + _keys.value.forEach { + array.put(JSONObject().put("id", it.id).put("name", it.name).put("secret", it.secret)) + } + return try { + secrets.put(KEY_LIST, array.toString()) + null + } catch (e: SecretStoreException) { + "Couldn't save the key list: ${e.message}" + } + } + + private companion object { + const val KEY_LIST = "auth-keys" + } +} + +class AuthKeyStoreException(message: String) : Exception(message) diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnApplication.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnApplication.kt new file mode 100644 index 0000000..c7eaafc --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnApplication.kt @@ -0,0 +1,14 @@ +package dev.flexaccess.ezvpn + +import android.app.Application + +class EzvpnApplication : Application() { + lateinit var manager: TunnelsManager + private set + + override fun onCreate() { + super.onCreate() + EzvpnNative.init(this) + manager = TunnelsManager(this) + } +} diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnNative.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnNative.kt new file mode 100644 index 0000000..2eb1631 --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnNative.kt @@ -0,0 +1,77 @@ +package dev.flexaccess.ezvpn + +import android.content.Context + +/** + * The JNI surface of libezvpn.so (ezvpn `src/ffi_android.rs`). The symbol + * names in the Rust side are bound to exactly this class, so it must stay + * `dev.flexaccess.ezvpn.EzvpnNative` whatever the applicationId is. + * + * Lifecycle, one session at a time: + * 1. [connect] — connect + handshake (blocks; call off the main thread). + * Returns a handle and stores the network-config JSON in `out[0]`, or + * returns 0 with the error message in `out[0]`. + * 2. [run] — hand the `VpnService.Builder.establish()` fd to the data loop. + * 3. [stop] — exactly once per successful connect; the handle is dead after. + * + * When the data loop ends on its own (server closed, idle timeout, I/O error) + * the library calls [onTunnelExit] on a background thread; a [stop] never + * triggers it. All JSON shapes are documented in ezvpn's `ios/ezvpn.h`. + */ +object EzvpnNative { + init { + System.loadLibrary("ezvpn") + } + + /** + * One-time process setup: logcat logging (tag `ezvpn`) and the JVM/context + * registration iroh's Android DNS and interface discovery need. Call from + * `Application.onCreate` before any other entry point. Idempotent. + */ + @JvmStatic + external fun init(context: Context) + + /** + * A fresh ed25519 client keypair as + * `{"created":…,"public_key":"ed25519-pub:…","secret_key":"ed25519-sec:…"}`. + * Throws [RuntimeException] when the system RNG is unavailable. + */ + @JvmStatic + external fun generateClientKey(): String + + /** + * The `ed25519-pub:…` half of a secret key, or null when the secret does + * not parse — which also makes this the validator for pasted keys. + */ + @JvmStatic + external fun clientPublicKey(secret: String): String? + + @JvmStatic + external fun connect(configJson: String, out: Array): Long + + /** 0 on success, -1 on error (bad handle, no pending session, dup failure). */ + @JvmStatic + external fun run(handle: Long, tunFd: Int): Int + + /** The live iroh path / custom-relay snapshot as JSON, or null for a dead handle. */ + @JvmStatic + external fun connPath(handle: Long): String? + + @JvmStatic + external fun stop(handle: Long) + + fun interface ExitListener { + /** Called on a library thread; `error` is null for a clean end. */ + fun onTunnelExit(handle: Long, error: String?) + } + + /** The service installs itself here while it owns a session. */ + @Volatile + var exitListener: ExitListener? = null + + /** Entry point the library calls (static, signature `(JLjava/lang/String;)V`). */ + @JvmStatic + fun onTunnelExit(handle: Long, error: String?) { + exitListener?.onTunnelExit(handle, error) + } +} diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnVpnService.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnVpnService.kt new file mode 100644 index 0000000..64896d5 --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnVpnService.kt @@ -0,0 +1,426 @@ +package dev.flexaccess.ezvpn + +import android.content.Intent +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.VpnService +import android.os.Handler +import android.os.Looper +import android.os.ParcelFileDescriptor +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants +import android.util.Log +import dev.flexaccess.ezvpn.tunnelcore.DnsProxy +import dev.flexaccess.ezvpn.tunnelcore.DnsProxyRequest +import dev.flexaccess.ezvpn.tunnelcore.IpLiteral +import dev.flexaccess.ezvpn.tunnelcore.LocalNetworks +import dev.flexaccess.ezvpn.tunnelcore.NetworkConfig +import dev.flexaccess.ezvpn.tunnelcore.TunnelConfigJson +import dev.flexaccess.ezvpn.tunnelcore.TunnelPlan +import dev.flexaccess.ezvpn.tunnelcore.TunnelProfile +import java.net.Inet6Address +import java.util.UUID +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +/** + * The VPN service: the Android counterpart of the packet-tunnel provider. It + * bridges `VpnService` to the Rust core (libezvpn.so via [EzvpnNative]): + * connect + handshake first to learn the assigned addresses and bypass set, + * program the interface from the resulting [TunnelPlan], `establish()` it, and + * hand the fd to the Rust data loop. + * + * Everything that touches the session runs on one worker thread, so stop, + * the connect continuation, the exit callback, and path queries never race + * into a double `stop` (which would double-free the handle). The blocking + * `connect` itself runs on its own thread — parking the worker on it would + * also park the disconnect the user taps while a connect to an offline server + * is still timing out — and re-checks on the worker whether it was stopped + * meanwhile. + * + * No foreground notification: the system binds the service while the + * interface is established, which keeps the process alive (the WireGuard app + * relies on the same). + */ +class EzvpnVpnService : VpnService() { + private class Session(val profileId: UUID) { + var handle = 0L + var tun: ParcelFileDescriptor? = null + var stopRequested = false + var monitor: NetworkMonitor? = null + /** Our copies of the protected fallback-DNS sockets; closed once the core has its dups. */ + var dnsSockets: List = emptyList() + + fun closeDnsSockets() { + dnsSockets.forEach { runCatching { it.close() } } + dnsSockets = emptyList() + } + } + + private lateinit var manager: TunnelsManager + private lateinit var worker: ExecutorService + private var current: Session? = null + + override fun onCreate() { + super.onCreate() + manager = TunnelsManager.get(this) + worker = Executors.newSingleThreadExecutor { Thread(it, "ezvpn-vpn") } + EzvpnNative.exitListener = EzvpnNative.ExitListener { handle, error -> + worker.execute { + val s = current ?: return@execute + if (s.handle != handle) return@execute + teardown(s, error ?: "The tunnel was closed.") + } + } + instance = this + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val id = intent?.getStringExtra(EXTRA_PROFILE_ID)?.let { runCatching { UUID.fromString(it) }.getOrNull() } + ?: manager.profileStore.lastProfileId + if (intent == null) Log.i(TAG, "started by the system (always-on): profile $id") + worker.execute { startTunnel(id) } + return START_NOT_STICKY + } + + /** The user turned the VPN off in Settings, or another VPN app took over. */ + override fun onRevoke() { + worker.execute { current?.let { teardown(it, "VPN permission was revoked.") } } + } + + override fun onDestroy() { + instance = null + EzvpnNative.exitListener = null + worker.execute { current?.let { teardown(it, null) } } + worker.shutdown() + super.onDestroy() + } + + fun disconnect() { + worker.execute { current?.let { teardown(it, null) } } + } + + /** The `connPath` JSON of the running session, or null. Serialized with stop. */ + fun connPathBlocking(): String? = try { + worker.submit { + current?.takeIf { it.handle != 0L }?.let { EzvpnNative.connPath(it.handle) } + }.get(3, TimeUnit.SECONDS) + } catch (e: Exception) { + null + } + + // --------------------------------------------------------------------- + // Worker-thread session lifecycle + + private fun startTunnel(id: UUID?) { + if (current != null) { + Log.i(TAG, "connect ignored: a session is already running") + return + } + if (id == null) { + manager.onDisconnected(null, "No profile to connect.") + stopSelf() + return + } + val session = Session(id) + current = session + manager.onConnecting(id) + + val profile = manager.profileStore.profile(id) + if (profile == null) { + teardown(session, "Profile not found.") + return + } + val authKey = try { + manager.profileStore.authKey(id) + } catch (e: SecretStoreException) { + teardown(session, "Couldn't read the auth key: ${e.message}") + return + } + if (authKey.isNullOrEmpty()) { + teardown(session, "The profile has no auth key; edit it and pick one.") + return + } + val relayToken = runCatching { manager.profileStore.relayAuthToken(id) }.getOrNull() + + // Refuse to start when a configured split-tunnel prefix overlaps the + // network the device is on: routing the local subnet into the tunnel + // would cut off on-link hosts, including the gateway carrying the + // tunnel's own underlay traffic. + val cm = getSystemService(ConnectivityManager::class.java) + LocalNetworks.splitTunnelConflict(profile.routes, profile.routes6, AndroidLocalNetworks.current(cm))?.let { + Log.e(TAG, it) + teardown(session, it) + return + } + + // Split DNS (match domains) needs the in-tunnel forwarder: give the core + // the underlying network's resolvers and sockets that bypass the VPN to + // reach them (protect() works before establish()). Read the resolvers + // now, while the physical network is still this app's default one. + val dnsProxy = if (DnsProxy.isEnabled(profile)) { + session.dnsSockets = listOfNotNull( + protectedUdpSocket(OsConstants.AF_INET), + protectedUdpSocket(OsConstants.AF_INET6), + ) + DnsProxyRequest( + fallbackServers = underlyingDnsServers(cm), + fallbackFds = session.dnsSockets.map { it.fd }, + ).also { Log.i(TAG, "split DNS: fallback resolvers ${it.fallbackServers}") } + } else { + null + } + + val configJson = TunnelConfigJson.build(profile, authKey, relayToken, dnsProxy) + thread(name = "ezvpn-connect") { + val out = arrayOfNulls(1) + val handle = EzvpnNative.connect(configJson, out) + worker.execute { afterConnect(session, profile, handle, out[0] ?: "") } + } + } + + private fun afterConnect(session: Session, profile: TunnelProfile, handle: Long, result: String) { + // The core dup'ed the fallback sockets it needs during connect. + session.closeDnsSockets() + if (session.stopRequested || current !== session) { + // Stopped while the handshake was in flight: nothing published this + // handle, so it is ours to free. + if (handle != 0L) EzvpnNative.stop(handle) + return + } + if (handle == 0L) { + Log.e(TAG, "connect failed: $result") + teardown(session, "Connect failed: $result") + return + } + session.handle = handle + Log.i(TAG, "handshake result: $result") + + val net = NetworkConfig.parse(result) + if (net == null) { + teardown(session, "Bad network config from the server: $result") + return + } + val plan = TunnelPlan.from(net, profile) + plan.warnings.forEach { Log.w(TAG, it) } + if (plan.remoteAddress == null) { + teardown(session, "The server assigned no address.") + return + } + + val builder = Builder().setSession(profile.name).setMtu(plan.mtu) + plan.address4?.let { builder.addAddress(it.address, 32) } + plan.address6?.let { builder.addAddress(it.address, 128) } + (plan.routes4 + plan.routes6).forEach { builder.addRoute(it.address, it.prefixLength) } + plan.dnsServers.forEach { builder.addDnsServer(it) } + // An address family with no address on the interface is blocked for + // every app by default; we are a split tunnel, so let it bypass instead. + if (plan.address4 == null) builder.allowFamily(OsConstants.AF_INET) + if (plan.address6 == null) builder.allowFamily(OsConstants.AF_INET6) + + val tun = try { + builder.establish() + } catch (e: Exception) { + teardown(session, "Couldn't establish the VPN interface: ${e.message}") + return + } + if (tun == null) { + teardown(session, "VPN permission is missing — connect from the app to grant it.") + return + } + session.tun = tun + + val rc = EzvpnNative.run(handle, tun.fd) + if (rc != 0) { + teardown(session, "Couldn't start the tunnel data loop (rc=$rc).") + return + } + Log.i(TAG, "tunnel running on fd ${tun.fd}") + val cm = getSystemService(ConnectivityManager::class.java) + session.monitor = NetworkMonitor(cm, worker) { reason -> + val s = current ?: return@NetworkMonitor + if (s !== session) return@NetworkMonitor + Log.i(TAG, "$reason, disconnecting") + teardown(session, "Network changed ($reason), disconnected.") + }.also { it.start() } + manager.onConnected(profile.id, plan.runtimeInfo()) + } + + /** Tear the session down (idempotent per session) and report. Worker thread. */ + private fun teardown(session: Session, error: String?) { + if (current !== session) return + session.stopRequested = true + session.monitor?.stop() + session.monitor = null + if (session.handle != 0L) { + manager.onDisconnecting() + EzvpnNative.stop(session.handle) + session.handle = 0L + } + // Close our fd only after the data loop is dead; the interface goes + // away with it. + runCatching { session.tun?.close() } + session.tun = null + session.closeDnsSockets() + current = null + Log.i(TAG, "tunnel stopped" + (error?.let { ": $it" } ?: "")) + manager.onDisconnected(session.profileId, error) + stopSelf() + } + + /** + * A UDP socket of `family` marked to bypass the VPN (`protect()`), as a + * ParcelFileDescriptor we own; null when the OS refused. + */ + private fun protectedUdpSocket(family: Int): ParcelFileDescriptor? { + return try { + val fd = Os.socket(family, OsConstants.SOCK_DGRAM, 0) + try { + if (family == OsConstants.AF_INET6) { + Os.setsockoptInt(fd, OsConstants.IPPROTO_IPV6, OsConstants.IPV6_V6ONLY, 1) + } + val pfd = ParcelFileDescriptor.dup(fd) + if (!protect(pfd.fd)) { + pfd.close() + Log.w(TAG, "split DNS: protect() refused a fallback socket") + null + } else { + pfd + } + } finally { + Os.close(fd) + } + } catch (e: ErrnoException) { + Log.w(TAG, "split DNS: cannot create a fallback socket: ${e.message}") + null + } catch (e: java.io.IOException) { + Log.w(TAG, "split DNS: cannot dup a fallback socket: ${e.message}") + null + } + } + + /** + * The resolvers of the network the device would use without us: the first + * non-VPN network with Internet, Wi-Fi/Ethernet preferred over cellular. + * IPv6 link-local resolvers carry their interface index as `%` so the + * core can address them. + */ + private fun underlyingDnsServers(cm: ConnectivityManager): List { + @Suppress("DEPRECATION") + val candidates = cm.allNetworks.mapNotNull { network -> + val caps = cm.getNetworkCapabilities(network) ?: return@mapNotNull null + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return@mapNotNull null + if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) return@mapNotNull null + val link = cm.getLinkProperties(network) ?: return@mapNotNull null + val rank = if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) 1 else 0 + rank to link + } + val link = candidates.minByOrNull { it.first }?.second ?: return emptyList() + return link.dnsServers.map { addr -> + val text = IpLiteral.format(addr.address) + val scope = (addr as? Inet6Address)?.takeIf { it.isLinkLocalAddress }?.scopeId ?: 0 + if (scope > 0) "$text%$scope" else text + } + } + + /** + * Watches the physical networks while the tunnel runs and asks for a + * disconnect when the one the tunnel rides on changes — the same policy as + * the Apple app (the session is not migrated; the user reconnects). The + * callback's initial burst of `onAvailable` calls records the baseline; + * after that, a new Wi-Fi/Ethernet network, or the loss of a baseline + * network, is a change. Cellular appearing next to Wi-Fi, or a lingering + * cellular link dropping while Wi-Fi stays, does not move the default + * network and is ignored. + */ + private class NetworkMonitor( + private val cm: ConnectivityManager, + private val worker: ExecutorService, + private val onChange: (String) -> Unit, + ) { + private val baseline = HashMap() + private var settled = false + private var stopped = false + + private val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + worker.execute { + if (stopped) return@execute + val kind = transport(network) + if (!settled) { + baseline[network] = kind + return@execute + } + if (baseline.containsKey(network)) return@execute + if (kind == "cellular" && baseline.values.any { it != "cellular" }) { + baseline[network] = kind + return@execute + } + onChange("new $kind network") + } + } + + override fun onLost(network: Network) { + worker.execute { + if (stopped) return@execute + val kind = baseline.remove(network) ?: return@execute + if (!settled) return@execute + if (kind == "cellular" && baseline.values.any { it != "cellular" }) return@execute + onChange("$kind network lost") + } + } + } + + fun start() { + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) + .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET) + .build() + cm.registerNetworkCallback(request, callback) + Handler(Looper.getMainLooper()).postDelayed({ + worker.execute { + settled = true + Log.i(TAG, "network baseline: ${baseline.values.sorted().joinToString(",")}") + } + }, SETTLE_MILLIS) + } + + fun stop() { + stopped = true + runCatching { cm.unregisterNetworkCallback(callback) } + } + + private fun transport(network: Network): String { + val caps = cm.getNetworkCapabilities(network) ?: return "unknown" + return when { + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + else -> "unknown" + } + } + + private companion object { + const val SETTLE_MILLIS = 1500L + } + } + + companion object { + private const val TAG = "ezvpn" + const val ACTION_CONNECT = "dev.flexaccess.ezvpn.CONNECT" + const val EXTRA_PROFILE_ID = "profile_id" + + /** The live service, while one exists (it runs in the app's process). */ + @Volatile + var instance: EzvpnVpnService? = null + private set + } +} diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/MainActivity.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/MainActivity.kt new file mode 100644 index 0000000..5c5796b --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/MainActivity.kt @@ -0,0 +1,58 @@ +package dev.flexaccess.ezvpn + +import android.app.Activity +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dev.flexaccess.ezvpn.ui.EzvpnRoot +import dev.flexaccess.ezvpn.ui.EzvpnTheme +import java.util.UUID + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + val manager = TunnelsManager.get(this) + setContent { + EzvpnTheme { + ConsentGate(manager) { connect -> + EzvpnRoot(manager = manager, onConnect = connect) + } + } + } + } +} + +/** + * Wraps connect with the one-time system VPN consent: when the OS still needs + * the user's approval, launch its dialog and connect once it comes back OK. + */ +@Composable +private fun ConsentGate( + manager: TunnelsManager, + content: @Composable (connect: (UUID) -> Unit) -> Unit, +) { + var pending by remember { mutableStateOf(null) } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val id = pending + pending = null + if (result.resultCode == Activity.RESULT_OK && id != null) manager.connect(id) + } + content { id -> + val intent = manager.consentIntent() + if (intent == null) { + manager.connect(id) + } else { + pending = id + launcher.launch(intent) + } + } +} diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/ProfileStore.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/ProfileStore.kt new file mode 100644 index 0000000..ec8ffa0 --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/ProfileStore.kt @@ -0,0 +1,110 @@ +package dev.flexaccess.ezvpn + +import android.content.Context +import android.content.SharedPreferences +import dev.flexaccess.ezvpn.tunnelcore.TunnelNames +import dev.flexaccess.ezvpn.tunnelcore.TunnelProfile +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.UUID + +class ProfileStoreException(message: String, cause: Throwable? = null) : Exception(message, cause) + +/** + * The saved profiles (the Android counterpart of the per-profile + * `NETunnelProviderManager`s): the non-secret list as one JSON document in a + * private `SharedPreferences`, and each profile's secrets — its own copy of the + * auth key, and the optional relay token — in the [SecretStore] keyed by + * profile id. The service reads a profile plus its two secrets and never the + * shared key list. Also remembers the last profile the user connected, which + * is what an always-on start (no intent) connects. + */ +class ProfileStore(context: Context, private val secrets: SecretStore) { + private val prefs: SharedPreferences = + context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + private val _profiles = MutableStateFlow(load()) + val profiles: StateFlow> = _profiles.asStateFlow() + + fun profile(id: UUID): TunnelProfile? = _profiles.value.firstOrNull { it.id == id } + + /** + * Add or replace `profile`, writing its secrets first so a saved profile + * always has a key to connect with. A null/empty relay token removes any + * stored one. Throws [ProfileStoreException] (with the previous secrets + * restored as far as possible) when anything didn't land. + */ + @Synchronized + fun save(profile: TunnelProfile, authKey: String, relayAuthToken: String?) { + val previousKey = runCatching { secrets.get(authKeyName(profile.id)) }.getOrNull() + val previousToken = runCatching { secrets.get(relayTokenName(profile.id)) }.getOrNull() + try { + secrets.put(authKeyName(profile.id), authKey) + if (relayAuthToken.isNullOrEmpty()) { + secrets.remove(relayTokenName(profile.id)) + } else { + secrets.put(relayTokenName(profile.id), relayAuthToken) + } + } catch (e: SecretStoreException) { + restore(profile.id, previousKey, previousToken) + throw ProfileStoreException("Couldn't save the profile's secrets: ${e.message}", e) + } + val previous = _profiles.value + val next = (previous.filter { it.id != profile.id } + profile) + .sortedWith(compareBy(TunnelNames.comparator) { it.name }) + if (!prefs.edit().putString(KEY_PROFILES, TunnelProfile.listToJson(next)).commit()) { + restore(profile.id, previousKey, previousToken) + throw ProfileStoreException("Couldn't write the profile list.") + } + _profiles.value = next + } + + @Synchronized + fun delete(id: UUID) { + val next = _profiles.value.filter { it.id != id } + if (!prefs.edit().putString(KEY_PROFILES, TunnelProfile.listToJson(next)).commit()) { + throw ProfileStoreException("Couldn't write the profile list.") + } + _profiles.value = next + if (lastProfileId == id) lastProfileId = null + val errors = listOfNotNull( + runCatching { secrets.remove(authKeyName(id)) }.exceptionOrNull(), + runCatching { secrets.remove(relayTokenName(id)) }.exceptionOrNull(), + ) + errors.firstOrNull()?.let { throw ProfileStoreException("Profile removed, but its secrets weren't: ${it.message}", it) } + } + + /** The profile's own copy of its auth key, or null when none is stored. */ + fun authKey(id: UUID): String? = secrets.get(authKeyName(id)) + + fun relayAuthToken(id: UUID): String? = secrets.get(relayTokenName(id)) + + /** The profile to connect when the system starts the service with no intent (always-on). */ + var lastProfileId: UUID? + get() = prefs.getString(KEY_LAST_PROFILE, null)?.let { runCatching { UUID.fromString(it) }.getOrNull() } + set(value) { + prefs.edit().apply { + if (value == null) remove(KEY_LAST_PROFILE) else putString(KEY_LAST_PROFILE, value.toString()) + }.apply() + } + + private fun load(): List { + val json = prefs.getString(KEY_PROFILES, null) ?: return emptyList() + return runCatching { TunnelProfile.listFromJson(json) }.getOrElse { emptyList() } + .sortedWith(compareBy(TunnelNames.comparator) { it.name }) + } + + private fun restore(id: UUID, key: String?, token: String?) { + runCatching { if (key == null) secrets.remove(authKeyName(id)) else secrets.put(authKeyName(id), key) } + runCatching { if (token == null) secrets.remove(relayTokenName(id)) else secrets.put(relayTokenName(id), token) } + } + + private companion object { + const val PREFS = "ezvpn-profiles" + const val KEY_PROFILES = "profiles" + const val KEY_LAST_PROFILE = "last_profile_id" + fun authKeyName(id: UUID) = "auth-key:$id" + fun relayTokenName(id: UUID) = "relay-token:$id" + } +} diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/SecretStore.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/SecretStore.kt new file mode 100644 index 0000000..a708364 --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/SecretStore.kt @@ -0,0 +1,103 @@ +package dev.flexaccess.ezvpn + +import android.content.Context +import android.content.SharedPreferences +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class SecretStoreException(message: String, cause: Throwable? = null) : Exception(message, cause) + +/** + * Where the app keeps its secrets: the auth-key list, and each profile's own + * copy of its auth key and relay token. Values are small strings keyed by name. + * Every method throws [SecretStoreException] on failure — a dropped write here + * would silently lose keys, so callers report it. + */ +interface SecretStore { + fun get(key: String): String? + fun put(key: String, value: String) + fun remove(key: String) +} + +/** + * The Android counterpart of the Keychain: values AES-GCM-encrypted under a + * key that lives in the hardware-backed AndroidKeyStore (never exportable), + * stored in a private `SharedPreferences` file. The Jetpack + * `EncryptedSharedPreferences` did the same and is deprecated, so the few + * lines it needs live here. Writes are committed synchronously so a failure + * is observable. + */ +class KeystoreSecretStore(context: Context) : SecretStore { + private val prefs: SharedPreferences = + context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + @Synchronized + override fun get(key: String): String? { + val stored = prefs.getString(key, null) ?: return null + val parts = stored.split(':') + if (parts.size != 2) throw SecretStoreException("stored secret \"$key\" is malformed") + return try { + val iv = Base64.decode(parts[0], Base64.NO_WRAP) + val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(TAG_BITS, iv)) + String(cipher.doFinal(ciphertext), Charsets.UTF_8) + } catch (e: Exception) { + throw SecretStoreException("couldn't decrypt secret \"$key\": ${e.message}", e) + } + } + + @Synchronized + override fun put(key: String, value: String) { + val encoded = try { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, secretKey()) + val ciphertext = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + ":" + + Base64.encodeToString(ciphertext, Base64.NO_WRAP) + } catch (e: Exception) { + throw SecretStoreException("couldn't encrypt secret \"$key\": ${e.message}", e) + } + if (!prefs.edit().putString(key, encoded).commit()) { + throw SecretStoreException("couldn't write secret \"$key\"") + } + } + + @Synchronized + override fun remove(key: String) { + if (!prefs.edit().remove(key).commit()) { + throw SecretStoreException("couldn't remove secret \"$key\"") + } + } + + private fun secretKey(): SecretKey { + val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) } + (keyStore.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + generator.init( + KeyGenParameterSpec.Builder( + ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return generator.generateKey() + } + + private companion object { + const val PREFS = "ezvpn-secrets" + const val KEYSTORE = "AndroidKeyStore" + const val ALIAS = "ezvpn-secret-store" + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val TAG_BITS = 128 + } +} diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/TunnelState.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/TunnelState.kt new file mode 100644 index 0000000..be801ee --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/TunnelState.kt @@ -0,0 +1,36 @@ +package dev.flexaccess.ezvpn + +import dev.flexaccess.ezvpn.tunnelcore.TunnelRuntimeInfo +import java.util.UUID + +enum class TunnelStatus { + DISCONNECTED, + CONNECTING, + CONNECTED, + DISCONNECTING, + ; + + val isInOperation: Boolean get() = this != DISCONNECTED +} + +/** + * What the one VPN session is doing right now, as the UI sees it. The service + * owns at most one session; `profileId` names the profile it is operating (or, + * once disconnected, the one whose `lastError` this is). + */ +data class TunnelState( + val profileId: UUID? = null, + val status: TunnelStatus = TunnelStatus.DISCONNECTED, + /** What was actually applied to the interface; set while connected. */ + val runtimeInfo: TunnelRuntimeInfo? = null, + val connectedAtMillis: Long? = null, + /** Why the last session ended (or failed to start); cleared on the next connect. */ + val lastError: String? = null, + /** A profile queued to connect once the current session has stopped. */ + val pendingProfileId: UUID? = null, +) { + fun statusOf(id: UUID): TunnelStatus = + if (profileId == id) status else TunnelStatus.DISCONNECTED + + fun isWaiting(id: UUID): Boolean = pendingProfileId == id +} diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/TunnelsManager.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/TunnelsManager.kt new file mode 100644 index 0000000..ce7dfeb --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/TunnelsManager.kt @@ -0,0 +1,206 @@ +package dev.flexaccess.ezvpn + +import android.content.Context +import android.content.Intent +import android.net.VpnService +import android.os.SystemClock +import android.util.Log +import dev.flexaccess.ezvpn.tunnelcore.NameResult +import dev.flexaccess.ezvpn.tunnelcore.TunnelConnectionSnapshot +import dev.flexaccess.ezvpn.tunnelcore.TunnelNameError +import dev.flexaccess.ezvpn.tunnelcore.TunnelNames +import dev.flexaccess.ezvpn.tunnelcore.TunnelProfile +import dev.flexaccess.ezvpn.tunnelcore.TunnelRuntimeInfo +import dev.flexaccess.ezvpn.tunnelcore.TunnelSnapshotDecoder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import java.util.UUID + +class TunnelsManagerException(message: String) : Exception(message) + +/** + * Owns the saved profiles and the one VPN session: the Android counterpart of + * ezvpn-apple's `TunnelsManager`. CRUD goes to [ProfileStore]; connect and + * disconnect drive [EzvpnVpnService], which runs in this same process and + * reports back through the `on*` methods, so [state] is the single source of + * truth for the UI. At most one tunnel runs at a time: connecting another + * profile stops the current one first and starts the new one once it is down. + */ +class TunnelsManager(context: Context) { + private val appContext = context.applicationContext + val secrets: SecretStore = KeystoreSecretStore(appContext) + val profileStore = ProfileStore(appContext, secrets) + val authKeys = AuthKeyStore(secrets) + + val profiles: StateFlow> get() = profileStore.profiles + + private val _state = MutableStateFlow(TunnelState()) + val state: StateFlow = _state.asStateFlow() + + // --------------------------------------------------------------------- + // CRUD + + /** Validate the name against the other profiles and save. */ + fun add(profile: TunnelProfile, authKey: String, relayAuthToken: String?): TunnelProfile { + val named = profile.copy(name = validatedName(profile.name, excluding = null)) + save(named, authKey, relayAuthToken) + return named + } + + /** + * Rewrite an existing profile. If it is running, restart it so the change + * takes effect (the editor disables Edit while active, but the rule holds). + */ + fun modify(profile: TunnelProfile, authKey: String, relayAuthToken: String?) { + val named = profile.copy(name = validatedName(profile.name, excluding = profile.id)) + save(named, authKey, relayAuthToken) + val s = _state.value + if (s.profileId == profile.id && s.status.isInOperation) { + _state.update { it.copy(pendingProfileId = profile.id) } + stopCurrent() + } + } + + fun remove(id: UUID) { + val s = _state.value + if (s.profileId == id && s.status.isInOperation) disconnect() + if (s.pendingProfileId == id) _state.update { it.copy(pendingProfileId = null) } + try { + profileStore.delete(id) + } catch (e: ProfileStoreException) { + throw TunnelsManagerException(e.message ?: "Couldn't delete the profile.") + } + } + + private fun save(profile: TunnelProfile, authKey: String, relayAuthToken: String?) { + try { + profileStore.save(profile, authKey, relayAuthToken) + } catch (e: ProfileStoreException) { + throw TunnelsManagerException(e.message ?: "Couldn't save the profile.") + } + } + + private fun validatedName(raw: String, excluding: UUID?): String { + val own = excluding?.let { profileStore.profile(it)?.name } + val others = profiles.value.filter { it.id != excluding }.map { it.name } + return when (val r = TunnelNames.validate(raw, others, excluding = own)) { + is NameResult.Valid -> r.name + is NameResult.Invalid -> throw TunnelsManagerException( + when (r.error) { + TunnelNameError.EMPTY -> "Name can't be empty." + TunnelNameError.DUPLICATE -> "A profile with that name already exists." + }, + ) + } + } + + // --------------------------------------------------------------------- + // Activation + + /** + * The system consent screen the user must accept before this app may run a + * VPN, or null when already granted. The activity launches it and calls + * [connect] again on RESULT_OK. + */ + fun consentIntent(): Intent? = VpnService.prepare(appContext) + + /** + * Start `id`. If another session is up (or still connecting), stop it and + * queue `id` to start once it has fully stopped. Requires consent (see + * [consentIntent]); without it the service's `establish()` fails and the + * error lands in [TunnelState.lastError]. + */ + fun connect(id: UUID) { + val s = _state.value + if (s.status.isInOperation) { + if (s.profileId == id && s.pendingProfileId == null) return + _state.update { it.copy(pendingProfileId = id) } + stopCurrent() + return + } + _state.update { TunnelState(profileId = id, status = TunnelStatus.CONNECTING) } + val intent = Intent(appContext, EzvpnVpnService::class.java) + .setAction(EzvpnVpnService.ACTION_CONNECT) + .putExtra(EzvpnVpnService.EXTRA_PROFILE_ID, id.toString()) + try { + appContext.startService(intent) + } catch (e: Exception) { + // Background-start restrictions, mostly. Nothing is running, so + // report it right here. + Log.e(TAG, "startService failed", e) + _state.update { it.copy(status = TunnelStatus.DISCONNECTED, lastError = "Couldn't start the VPN service: ${e.message}") } + } + } + + /** Stop whatever is running (and drop any queued connect). */ + fun disconnect() { + _state.update { it.copy(pendingProfileId = null) } + stopCurrent() + } + + /** Stop the running session, keeping any queued connect. */ + private fun stopCurrent() { + val service = EzvpnVpnService.instance + if (service == null) { + // Nothing running: make sure the UI agrees. + _state.update { if (it.status.isInOperation) it.copy(status = TunnelStatus.DISCONNECTED) else it } + return + } + service.disconnect() + } + + /** Point-in-time snapshot of the live iroh path(s); empty when not connected. */ + suspend fun queryConnPath(): TunnelConnectionSnapshot = withContext(Dispatchers.IO) { + TunnelSnapshotDecoder.connectionSnapshot(EzvpnVpnService.instance?.connPathBlocking()) + } + + // --------------------------------------------------------------------- + // Service callbacks (any thread) + + internal fun onConnecting(id: UUID) { + _state.update { TunnelState(profileId = id, status = TunnelStatus.CONNECTING, pendingProfileId = it.pendingProfileId) } + } + + internal fun onConnected(id: UUID, info: TunnelRuntimeInfo) { + profileStore.lastProfileId = id + _state.update { + it.copy( + profileId = id, + status = TunnelStatus.CONNECTED, + runtimeInfo = info, + connectedAtMillis = SystemClock.elapsedRealtime(), + lastError = null, + ) + } + } + + internal fun onDisconnecting() { + _state.update { if (it.status.isInOperation) it.copy(status = TunnelStatus.DISCONNECTING) else it } + } + + /** The session is fully down; start a queued profile if there is one. */ + internal fun onDisconnected(id: UUID?, error: String?) { + if (error != null) Log.w(TAG, "tunnel ended: $error") + val pending = _state.value.pendingProfileId + _state.update { + TunnelState( + profileId = id ?: it.profileId, + status = TunnelStatus.DISCONNECTED, + lastError = error, + pendingProfileId = null, + ) + } + if (pending != null) connect(pending) + } + + companion object { + private const val TAG = "ezvpn" + + fun get(context: Context): TunnelsManager = + (context.applicationContext as EzvpnApplication).manager + } +} diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/ConnPathSheet.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/ConnPathSheet.kt new file mode 100644 index 0000000..02002b0 --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/ConnPathSheet.kt @@ -0,0 +1,111 @@ +package dev.flexaccess.ezvpn.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import dev.flexaccess.ezvpn.tunnelcore.TunnelConnectionPath +import dev.flexaccess.ezvpn.tunnelcore.TunnelConnectionSnapshot +import dev.flexaccess.ezvpn.tunnelcore.TunnelCustomRelay + +/** + * On-demand "connection path" readout: a point-in-time snapshot of how the + * running tunnel reaches the server (the live iroh relay/direct paths), like + * `ezvpn client status` and the Apple app's sheet of the same name. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConnPathSheet(query: suspend () -> TunnelConnectionSnapshot, onDismiss: () -> Unit) { + var snapshot by remember { mutableStateOf(TunnelConnectionSnapshot()) } + var refreshToken by remember { mutableIntStateOf(0) } + LaunchedEffect(refreshToken) { snapshot = query() } + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Connection path", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.weight(1f)) + IconButton(onClick = { refreshToken++ }) { Icon(Icons.Default.Refresh, contentDescription = "Refresh") } + } + if (snapshot.paths.isEmpty()) { + Footnote("No path yet — still establishing. Close this and try again in a moment.") + } else { + snapshot.paths.forEach { ConnPathRow(it) } + } + Footnote( + "Snapshot taken just now — how this session reaches the server. Direct paths are " + + "peer-to-peer; relay paths hop through an iroh relay.", + ) + if (snapshot.customRelays.isNotEmpty()) { + SectionTitle("Custom relays") + snapshot.customRelays.forEach { RelayRow(it) } + Footnote("Health is reported by the running iroh endpoint; unavailable means it has not observed this relay yet.") + } + } + } +} + +@Composable +private fun ConnPathRow(path: TunnelConnectionPath) { + Row( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + StatusDot( + when (path.kind) { + TunnelConnectionPath.Kind.DIRECT -> Color(0xFF2E7D32) + TunnelConnectionPath.Kind.RELAY -> Color(0xFFEF6C00) + TunnelConnectionPath.Kind.OTHER -> Color.Gray + }, + size = 8, + ) + Text(path.display, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + if (path.selected) { + Text("active", style = MaterialTheme.typography.labelSmall, color = Color(0xFF2E7D32)) + } + } +} + +@Composable +private fun RelayRow(relay: TunnelCustomRelay) { + Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text(relay.url, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + val (text, color) = when (relay.working) { + true -> "Working" to Color(0xFF2E7D32) + false -> (relay.error?.let { "Not working — $it" } ?: "Not working") to MaterialTheme.colorScheme.onSurfaceVariant + null -> "Status unavailable" to MaterialTheme.colorScheme.onSurfaceVariant + } + Text(text, style = MaterialTheme.typography.labelSmall, color = color) + } +} diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/EzvpnRoot.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/EzvpnRoot.kt new file mode 100644 index 0000000..d237a6a --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/EzvpnRoot.kt @@ -0,0 +1,106 @@ +package dev.flexaccess.ezvpn.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.flexaccess.ezvpn.TunnelsManager +import java.util.UUID + +/** The app's screens; kept as plain state so no navigation library is needed. */ +sealed interface Screen { + data object List : Screen + data class Detail(val id: UUID) : Screen + data class Edit(val id: UUID?) : Screen + data object Keys : Screen + + companion object { + val saver: Saver = Saver( + save = { + when (it) { + List -> "list" + is Detail -> "detail:${it.id}" + is Edit -> "edit:${it.id ?: ""}" + Keys -> "keys" + } + }, + restore = { s -> + when { + s == "list" -> List + s == "keys" -> Keys + s.startsWith("detail:") -> runCatching { Detail(UUID.fromString(s.removePrefix("detail:"))) }.getOrNull() + s.startsWith("edit:") -> Edit(s.removePrefix("edit:").takeIf { it.isNotEmpty() }?.let { runCatching { UUID.fromString(it) }.getOrNull() }) + else -> List + } + }, + ) + } +} + +@Composable +fun EzvpnRoot(manager: TunnelsManager, onConnect: (UUID) -> Unit) { + var stack by rememberSaveable(stateSaver = stackSaver) { mutableStateOf(listOf(Screen.List)) } + val screen = stack.last() + val profiles by manager.profiles.collectAsStateWithLifecycle() + val state by manager.state.collectAsStateWithLifecycle() + val keys by manager.authKeys.keys.collectAsStateWithLifecycle() + + fun push(s: Screen) { stack = stack + s } + fun pop() { if (stack.size > 1) stack = stack.dropLast(1) } + + BackHandler(enabled = stack.size > 1) { pop() } + + when (screen) { + Screen.List -> TunnelListScreen( + profiles = profiles, + state = state, + onOpen = { push(Screen.Detail(it)) }, + onAdd = { push(Screen.Edit(null)) }, + onKeys = { push(Screen.Keys) }, + onConnect = onConnect, + onDisconnect = { manager.disconnect() }, + ) + is Screen.Detail -> { + val profile = profiles.firstOrNull { it.id == screen.id } + if (profile == null) { + // Deleted underneath us (or a stale restored route). + pop() + } else { + TunnelDetailScreen( + profile = profile, + state = state, + manager = manager, + onBack = { pop() }, + onEdit = { push(Screen.Edit(profile.id)) }, + onConnect = onConnect, + onDisconnect = { manager.disconnect() }, + onDeleted = { pop() }, + ) + } + } + is Screen.Edit -> TunnelEditScreen( + profile = screen.id?.let { id -> profiles.firstOrNull { it.id == id } }, + keys = keys, + manager = manager, + onManageKeys = { push(Screen.Keys) }, + onDone = { pop() }, + ) + Screen.Keys -> KeysScreen( + keys = keys, + store = manager.authKeys, + onBack = { pop() }, + ) + } +} + +private val stackSaver: Saver, Any> = Saver( + save = { list -> ArrayList(list.map { with(Screen.saver) { save(it) } as String }) }, + restore = { saved -> + @Suppress("UNCHECKED_CAST") + (saved as List).mapNotNull { Screen.saver.restore(it) }.ifEmpty { listOf(Screen.List) } + }, +) diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/KeysScreen.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/KeysScreen.kt new file mode 100644 index 0000000..3126a8d --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/KeysScreen.kt @@ -0,0 +1,261 @@ +package dev.flexaccess.ezvpn.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.flexaccess.ezvpn.AuthKey +import dev.flexaccess.ezvpn.AuthKeyStore + +/** + * The auth-key manager: the app's shared, named ed25519 keys, with generate, + * import (paste a secret from another device), rename, copy, and delete. + * Public keys show unmasked (they are not secrets); secrets never render — + * export copies straight to the clipboard, behind a confirmation. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KeysScreen(keys: List, store: AuthKeyStore, onBack: () -> Unit) { + val context = LocalContext.current + var addMenu by remember { mutableStateOf(false) } + var dialog by remember { mutableStateOf(null) } + var errorMessage by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Auth keys") }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, + actions = { + Box { + IconButton(onClick = { addMenu = true }) { Icon(Icons.Default.Add, contentDescription = "Add key") } + DropdownMenu(expanded = addMenu, onDismissRequest = { addMenu = false }) { + DropdownMenuItem(text = { Text("Generate new key…") }, onClick = { addMenu = false; dialog = KeyDialog.Generate }) + DropdownMenuItem(text = { Text("Enter existing key…") }, onClick = { addMenu = false; dialog = KeyDialog.Import }) + } + } + }, + ) + }, + ) { padding -> + if (keys.isEmpty()) { + Box(Modifier.fillMaxSize().padding(padding).padding(24.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("No auth keys", style = MaterialTheme.typography.titleMedium) + Text( + "Generate a key (or paste one from another device), then put its public key on the server's authorized_keys file.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(keys, key = { it.id }) { key -> + KeyRow( + key, + onCopyPublic = { Clipboard.copy(context, "ezvpn public key", key.publicKey) }, + onCopySecret = { dialog = KeyDialog.Export(key) }, + onRename = { dialog = KeyDialog.Rename(key) }, + onDelete = { dialog = KeyDialog.Delete(key) }, + ) + HorizontalDivider() + } + item { + Footnote( + "A profile authenticates with the key it selects. Deleting a key here doesn't " + + "disconnect profiles already saved with it — re-save a profile to change the key it uses.", + ) + } + } + } + } + + when (val d = dialog) { + null -> {} + KeyDialog.Generate -> NameDialog( + title = "Name the new key", + message = "Names only exist in this app's key list.", + confirm = "Generate", + onDismiss = { dialog = null }, + ) { name -> + dialog = null + val pair = AuthKey.generate() + if (pair == null) errorMessage = "Key generation failed." else store.add(name, pair.secretKey).onFailure { errorMessage = it.message } + } + KeyDialog.Import -> ImportDialog(onDismiss = { dialog = null }) { name, secret -> + dialog = null + store.add(name, secret).onFailure { errorMessage = it.message } + } + is KeyDialog.Rename -> NameDialog( + title = "Rename key", + message = null, + confirm = "Rename", + initial = d.key.name, + onDismiss = { dialog = null }, + ) { name -> + dialog = null + store.rename(d.key.id, name)?.let { errorMessage = it } + } + is KeyDialog.Export -> AlertDialog( + onDismissRequest = { dialog = null }, + title = { Text("Copy the secret key?") }, + text = { Text("Anyone holding the secret key can connect as \"${d.key.name}\". Paste it into another device's key import.") }, + confirmButton = { + TextButton(onClick = { + dialog = null + Clipboard.copy(context, "ezvpn secret key", d.key.secret, isSecret = true) + }) { Text("Copy secret key") } + }, + dismissButton = { TextButton(onClick = { dialog = null }) { Text("Cancel") } }, + ) + is KeyDialog.Delete -> AlertDialog( + onDismissRequest = { dialog = null }, + title = { Text("Delete \"${d.key.name}\"?") }, + text = { Text("The secret key is removed from this device's key list. The server keeps trusting its public key until that's taken off the authorized_keys file.") }, + confirmButton = { + TextButton(onClick = { + dialog = null + store.delete(d.key.id)?.let { errorMessage = it } + }) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = { dialog = null }) { Text("Cancel") } }, + ) + } + + errorMessage?.let { message -> + AlertDialog( + onDismissRequest = { errorMessage = null }, + title = { Text("Can't do that") }, + text = { Text(message) }, + confirmButton = { TextButton(onClick = { errorMessage = null }) { Text("OK") } }, + ) + } +} + +private sealed interface KeyDialog { + data object Generate : KeyDialog + data object Import : KeyDialog + data class Rename(val key: AuthKeyStore.Key) : KeyDialog + data class Export(val key: AuthKeyStore.Key) : KeyDialog + data class Delete(val key: AuthKeyStore.Key) : KeyDialog +} + +@Composable +private fun KeyRow( + key: AuthKeyStore.Key, + onCopyPublic: () -> Unit, + onCopySecret: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, +) { + var menu by remember { mutableStateOf(false) } + Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(key.name, style = MaterialTheme.typography.bodyLarge) + Text( + key.publicKey, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Box { + IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, contentDescription = "Actions for ${key.name}") } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem(text = { Text("Copy public key") }, onClick = { menu = false; onCopyPublic() }) + DropdownMenuItem(text = { Text("Copy secret key…") }, onClick = { menu = false; onCopySecret() }) + DropdownMenuItem(text = { Text("Rename…") }, onClick = { menu = false; onRename() }) + DropdownMenuItem(text = { Text("Delete…", color = MaterialTheme.colorScheme.error) }, onClick = { menu = false; onDelete() }) + } + } + } +} + +@Composable +private fun NameDialog( + title: String, + message: String?, + confirm: String, + initial: String = "", + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var name by remember { mutableStateOf(initial) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column { + message?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } + OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Name") }, singleLine = true, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + } + }, + confirmButton = { TextButton(onClick = { onConfirm(name) }, enabled = name.isNotBlank()) { Text(confirm) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Composable +private fun ImportDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) { + var name by remember { mutableStateOf("") } + var secret by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Enter existing key") }, + text = { + Column { + Text( + "Paste a secret key generated elsewhere — copied from another device, or by \"flexaccess-keys generate-auth-key\" — to reuse its identity.", + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Name") }, singleLine = true, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + OutlinedTextField( + value = secret, + onValueChange = { secret = it }, + label = { Text("ed25519-sec:…") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + }, + confirmButton = { TextButton(onClick = { onConfirm(name, secret) }, enabled = name.isNotBlank() && secret.isNotBlank()) { Text("Add key") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/Shared.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/Shared.kt new file mode 100644 index 0000000..63d8485 --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/Shared.kt @@ -0,0 +1,96 @@ +package dev.flexaccess.ezvpn.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.PersistableBundle +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import dev.flexaccess.ezvpn.TunnelStatus + +val TunnelStatus.displayText: String + get() = when (this) { + TunnelStatus.DISCONNECTED -> "Disconnected" + TunnelStatus.CONNECTING -> "Connecting…" + TunnelStatus.CONNECTED -> "Connected" + TunnelStatus.DISCONNECTING -> "Disconnecting…" + } + +val TunnelStatus.indicatorColor: Color + get() = when (this) { + TunnelStatus.CONNECTED -> Color(0xFF2E7D32) + TunnelStatus.CONNECTING, TunnelStatus.DISCONNECTING -> Color(0xFFF9A825) + TunnelStatus.DISCONNECTED -> Color(0xFF9E9E9E) + } + +@Composable +fun StatusDot(color: Color, size: Int = 10) { + Box( + Modifier + .size(size.dp) + .background(color, CircleShape), + ) +} + +/** A titled block with one line per value (the "Active routes" readout). */ +@Composable +fun ValueRows(title: String, values: List) { + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Text(title, style = MaterialTheme.typography.labelLarge) + if (values.isEmpty()) { + Text("none", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + values.forEach { + Text(it, style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace) + } + } + } +} + +@Composable +fun SectionTitle(text: String) { + Text( + text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp), + ) +} + +@Composable +fun Footnote(text: String, color: Color = MaterialTheme.colorScheme.onSurfaceVariant) { + Text(text, style = MaterialTheme.typography.bodySmall, color = color, modifier = Modifier.padding(top = 4.dp)) +} + +val ScreenPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + +/** + * The one clipboard call the key screen needs. Secrets are flagged sensitive + * so the system clipboard preview hides them (Android 13+ honors the flag; + * older versions ignore it). + */ +object Clipboard { + fun copy(context: Context, label: String, value: String, isSecret: Boolean = false) { + val cm = context.getSystemService(ClipboardManager::class.java) + val clip = ClipData.newPlainText(label, value) + if (isSecret) { + clip.description.extras = PersistableBundle().apply { + putBoolean("android.content.extra.IS_SENSITIVE", true) + } + } + cm.setPrimaryClip(clip) + } +} diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/Theme.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/Theme.kt new file mode 100644 index 0000000..e0fcfbc --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/Theme.kt @@ -0,0 +1,46 @@ +package dev.flexaccess.ezvpn.ui + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +/** The app icon's teal, used as the seed where dynamic color is unavailable. */ +private val Teal = Color(0xFF12A682) +private val TealDark = Color(0xFF05665C) + +private val LightColors = lightColorScheme( + primary = TealDark, + onPrimary = Color.White, + primaryContainer = Color(0xFFB6F0DE), + onPrimaryContainer = Color(0xFF00201A), + secondary = Teal, +) + +private val DarkColors = darkColorScheme( + primary = Color(0xFF6BD8BB), + onPrimary = Color(0xFF00382E), + primaryContainer = TealDark, + onPrimaryContainer = Color(0xFFB6F0DE), + secondary = Teal, +) + +@Composable +fun EzvpnTheme(content: @Composable () -> Unit) { + val dark = isSystemInDarkTheme() + val colors = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + dark -> DarkColors + else -> LightColors + } + MaterialTheme(colorScheme = colors, content = content) +} diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/TunnelDetailScreen.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/TunnelDetailScreen.kt new file mode 100644 index 0000000..8193bd1 --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/TunnelDetailScreen.kt @@ -0,0 +1,190 @@ +package dev.flexaccess.ezvpn.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.flexaccess.ezvpn.TunnelState +import dev.flexaccess.ezvpn.TunnelStatus +import dev.flexaccess.ezvpn.TunnelsManager +import dev.flexaccess.ezvpn.TunnelsManagerException +import dev.flexaccess.ezvpn.tunnelcore.TunnelProfile +import kotlinx.coroutines.delay +import java.util.UUID + +/** One profile: status, the applied routing state while connected, connect/disconnect, edit, delete. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TunnelDetailScreen( + profile: TunnelProfile, + state: TunnelState, + manager: TunnelsManager, + onBack: () -> Unit, + onEdit: () -> Unit, + onConnect: (UUID) -> Unit, + onDisconnect: () -> Unit, + onDeleted: () -> Unit, +) { + val status = state.statusOf(profile.id) + val waiting = state.isWaiting(profile.id) + val isActive = status.isInOperation || waiting + val isConnecting = status == TunnelStatus.CONNECTING || waiting + val lastError = state.lastError?.takeIf { state.profileId == profile.id } + var confirmingDelete by remember { mutableStateOf(false) } + var showingConnPath by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(profile.name) }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } + }, + ) + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(ScreenPadding), + ) { + SectionTitle("Status") + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + StatusDot(if (waiting) TunnelStatus.CONNECTING.indicatorColor else status.indicatorColor) + Text(if (waiting) "Waiting…" else status.displayText) + Spacer(Modifier.weight(1f)) + if (status == TunnelStatus.CONNECTED && state.connectedAtMillis != null) { + ConnectedSince(state.connectedAtMillis) + } + } + (error ?: lastError)?.let { + Footnote(it, color = MaterialTheme.colorScheme.error) + } + + // Live routing state reported by the service, so what's on screen + // is what the interface actually got. + if (status == TunnelStatus.CONNECTED && state.runtimeInfo != null) { + val info = state.runtimeInfo + SectionTitle("Active routes") + info.assignedIp?.let { ValueRows("Assigned IPv4", listOf(it)) } + info.assignedIp6?.let { ValueRows("Assigned IPv6", listOf(it)) } + info.mtu?.let { ValueRows("MTU", listOf(it.toString())) } + ValueRows("Tunnel routes (IPv4)", info.includedRoutes) + ValueRows("Tunnel routes (IPv6)", info.includedRoutes6) + ValueRows("Bypass routes (IPv4)", info.bypassRoutes) + ValueRows("Bypass routes (IPv6)", info.bypassRoutes6) + if (info.dnsServers.isNotEmpty()) { + ValueRows("DNS servers", info.dnsServers) + ValueRows("DNS match domains", info.dnsMatchDomains.ifEmpty { listOf("all domains") }) + if (info.dnsProxyAddresses.isNotEmpty()) ValueRows("DNS forwarder (in-tunnel)", info.dnsProxyAddresses) + } + Footnote( + "Bypass routes are server underlay/relay addresses carved out of the tunnel " + + "routes so its own transport is never captured.", + ) + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = { showingConnPath = true }) { Text("Connection path…") } + } + + Spacer(Modifier.height(24.dp)) + when { + isConnecting -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + Text(if (waiting) "Reconnecting…" else "Connecting…", color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.weight(1f)) + OutlinedButton(onClick = onDisconnect) { Text("Cancel") } + } + isActive -> OutlinedButton( + onClick = onDisconnect, + enabled = status != TunnelStatus.DISCONNECTING, + modifier = Modifier.fillMaxWidth(), + ) { Text("Disconnect") } + else -> Button(onClick = { onConnect(profile.id) }, modifier = Modifier.fillMaxWidth()) { Text("Connect") } + } + + Spacer(Modifier.height(24.dp)) + OutlinedButton(onClick = onEdit, enabled = !isActive, modifier = Modifier.fillMaxWidth()) { Text("Edit") } + Spacer(Modifier.height(8.dp)) + TextButton(onClick = { confirmingDelete = true }, modifier = Modifier.fillMaxWidth()) { + Text("Delete profile", color = MaterialTheme.colorScheme.error) + } + } + } + + if (confirmingDelete) { + AlertDialog( + onDismissRequest = { confirmingDelete = false }, + title = { Text("Delete ${profile.name}?") }, + text = { Text("This removes the VPN profile and its keys from this device.") }, + confirmButton = { + TextButton(onClick = { + confirmingDelete = false + try { + manager.remove(profile.id) + onDeleted() + } catch (e: TunnelsManagerException) { + error = e.message + } + }) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = { confirmingDelete = false }) { Text("Cancel") } }, + ) + } + + if (showingConnPath) { + ConnPathSheet(query = { manager.queryConnPath() }, onDismiss = { showingConnPath = false }) + } +} + +/** "connected 3m 12s" style counter from an elapsedRealtime timestamp. */ +@Composable +private fun ConnectedSince(sinceElapsedMillis: Long) { + var now by remember { mutableLongStateOf(android.os.SystemClock.elapsedRealtime()) } + LaunchedEffect(sinceElapsedMillis) { + while (true) { + now = android.os.SystemClock.elapsedRealtime() + delay(1000) + } + } + val secs = ((now - sinceElapsedMillis) / 1000).coerceAtLeast(0) + val text = when { + secs >= 3600 -> "%dh %02dm".format(secs / 3600, (secs % 3600) / 60) + secs >= 60 -> "%dm %02ds".format(secs / 60, secs % 60) + else -> "${secs}s" + } + Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) +} diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/TunnelEditScreen.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/TunnelEditScreen.kt new file mode 100644 index 0000000..21bcbef --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/TunnelEditScreen.kt @@ -0,0 +1,220 @@ +package dev.flexaccess.ezvpn.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import dev.flexaccess.ezvpn.AuthKeyStore +import dev.flexaccess.ezvpn.TunnelsManager +import dev.flexaccess.ezvpn.TunnelsManagerException +import dev.flexaccess.ezvpn.tunnelcore.TunnelProfile +import dev.flexaccess.ezvpn.tunnelcore.TunnelProfileForm +import dev.flexaccess.ezvpn.tunnelcore.TunnelProfileFormException +import java.util.UUID + +/** Add (`profile == null`) or edit a profile. Save validates and, on success, leaves. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TunnelEditScreen( + profile: TunnelProfile?, + keys: List, + manager: TunnelsManager, + onManageKeys: () -> Unit, + onDone: () -> Unit, +) { + val isAdd = profile == null + var form by rememberSaveable(profile?.id, stateSaver = FormSaver) { + mutableStateOf( + if (profile == null) { + TunnelProfileForm() + } else { + TunnelProfileForm.from(profile, runCatching { manager.profileStore.relayAuthToken(profile.id) }.getOrNull() ?: "") + }, + ) + } + var error by rememberSaveable { mutableStateOf(null) } + val selectedKey = keys.firstOrNull { it.id == form.authKeyId } + // The profile keeps its own copy of the secret, so a key deleted from the + // list still connects — but there is nothing to preselect and nothing to + // re-save with, so say so instead of showing an empty picker. + val missingKeyNotice = if (form.authKeyId.isNotEmpty() && selectedKey == null) { + "The auth key this profile used is no longer in the key list. Pick a key before saving." + } else { + null + } + + fun save() { + error = null + val id = profile?.id ?: UUID.randomUUID() + try { + val submission = form.makeSubmission(id) + val key = keys.firstOrNull { it.id == submission.profile.authKeyId } + if (key == null) { + error = "Pick an auth key for this profile." + return + } + // The profile stores only the key's id; its secret is copied into + // the profile's own secret so the service can read it without the + // key list. + if (isAdd) { + manager.add(submission.profile, key.secret, submission.relayAuthToken) + } else { + manager.modify(submission.profile, key.secret, submission.relayAuthToken) + } + onDone() + } catch (e: TunnelProfileFormException) { + error = e.message + } catch (e: TunnelsManagerException) { + error = e.message + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (isAdd) "New profile" else "Edit profile") }, + navigationIcon = { IconButton(onClick = onDone) { Icon(Icons.Default.Close, contentDescription = "Cancel") } }, + actions = { TextButton(onClick = { save() }, enabled = form.hasRequiredFields) { Text("Save") } }, + ) + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(ScreenPadding), + ) { + SectionTitle("Profile") + Field("Name", form.name, { form = form.copy(name = it) }, capitalize = true) + + SectionTitle("Server") + Field("Server node id", form.serverNodeId, { form = form.copy(serverNodeId = it) }, monospace = true) + KeyPicker(keys, selectedKey, onPick = { form = form.copy(authKeyId = it.id) }) + selectedKey?.let { + Footnote("Public key (put this on the server):") + Text(it.publicKey, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + } + missingKeyNotice?.let { Footnote(it, color = MaterialTheme.colorScheme.tertiary) } + TextButton(onClick = onManageKeys) { Text("Manage keys…") } + Field("Relay URLs", form.relayUrls, { form = form.copy(relayUrls = it) }, hint = "comma-separated, optional", monospace = true) + Field( + "Relay token", + form.relayAuthToken, + { form = form.copy(relayAuthToken = it) }, + hint = "optional, custom relays only", + secret = true, + enabled = form.relayUrls.isNotBlank() || form.relayAuthToken.isNotEmpty(), + ) + + SectionTitle("Split tunnel") + Field("IPv4 routes", form.routes, { form = form.copy(routes = it) }, hint = "comma-separated CIDRs, optional", monospace = true) + Field("IPv6 routes", form.routes6, { form = form.copy(routes6 = it) }, hint = "comma-separated CIDRs, optional", monospace = true) + Footnote("The server gateway is always routed automatically; add CIDRs here to route more.") + + SectionTitle("Split DNS (conditional forwarding)") + Field("DNS servers", form.dnsServers, { form = form.copy(dnsServers = it) }, hint = "comma-separated IPs, optional", monospace = true) + Field("Match domains", form.dnsMatchDomains, { form = form.copy(dnsMatchDomains = it) }, hint = "comma-separated, optional", monospace = true) + Footnote( + "Names under the match domains resolve via these DNS servers through the tunnel; " + + "everything else keeps the network's normal DNS. Android has no split DNS of its own, so " + + "ezvpn forwards in-tunnel (like Tailscale's MagicDNS). Servers should sit inside a tunnel " + + "route. Empty match domains send all DNS through the servers.", + ) + + error?.let { + Spacer(Modifier.height(12.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(32.dp)) + } + } +} + +@Composable +private fun Field( + label: String, + value: String, + onChange: (String) -> Unit, + hint: String? = null, + monospace: Boolean = false, + secret: Boolean = false, + capitalize: Boolean = false, + enabled: Boolean = true, +) { + OutlinedTextField( + value = value, + onValueChange = onChange, + label = { Text(label) }, + placeholder = hint?.let { { Text(it) } }, + singleLine = true, + enabled = enabled, + visualTransformation = if (secret) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None, + keyboardOptions = KeyboardOptions( + keyboardType = if (secret) KeyboardType.Password else if (monospace) KeyboardType.Ascii else KeyboardType.Text, + capitalization = if (capitalize) KeyboardCapitalization.Sentences else KeyboardCapitalization.None, + autoCorrectEnabled = false, + ), + textStyle = if (monospace) MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) else MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) +} + +@Composable +private fun KeyPicker(keys: List, selected: AuthKeyStore.Key?, onPick: (AuthKeyStore.Key) -> Unit) { + var open by rememberSaveable { mutableStateOf(false) } + Box(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth(), enabled = keys.isNotEmpty()) { + Text( + selected?.let { "Auth key: ${it.name}" } ?: if (keys.isEmpty()) "No auth keys yet" else "Choose an auth key…", + modifier = Modifier.weight(1f), + ) + Icon(Icons.Default.ArrowDropDown, contentDescription = null) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + keys.forEach { key -> + DropdownMenuItem(text = { Text(key.name) }, onClick = { open = false; onPick(key) }) + } + } + } +} + +/** Keeps the editor's text across rotation (the form is a plain data class). */ +private val FormSaver = androidx.compose.runtime.saveable.listSaver( + save = { listOf(it.name, it.serverNodeId, it.authKeyId, it.relayUrls, it.relayAuthToken, it.routes, it.routes6, it.dnsServers, it.dnsMatchDomains) }, + restore = { TunnelProfileForm(it[0], it[1], it[2], it[3], it[4], it[5], it[6], it[7], it[8]) }, +) diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/TunnelListScreen.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/TunnelListScreen.kt new file mode 100644 index 0000000..893ac76 --- /dev/null +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/ui/TunnelListScreen.kt @@ -0,0 +1,125 @@ +package dev.flexaccess.ezvpn.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.VpnKey +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.flexaccess.ezvpn.BuildConfig +import dev.flexaccess.ezvpn.TunnelState +import dev.flexaccess.ezvpn.TunnelStatus +import dev.flexaccess.ezvpn.tunnelcore.TunnelProfile +import java.util.UUID + +/** Root screen: the saved profiles (WireGuard-app style), each with a connect switch. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TunnelListScreen( + profiles: List, + state: TunnelState, + onOpen: (UUID) -> Unit, + onAdd: () -> Unit, + onKeys: () -> Unit, + onConnect: (UUID) -> Unit, + onDisconnect: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("ezvpn") }, + actions = { + // The auth keys are shared across profiles, so they are + // managed from the root screen (and the editor's picker). + IconButton(onClick = onKeys) { Icon(Icons.Default.VpnKey, contentDescription = "Auth keys") } + IconButton(onClick = onAdd) { Icon(Icons.Default.Add, contentDescription = "Add profile") } + }, + ) + }, + bottomBar = { + Text( + "ezvpn ${BuildConfig.VERSION_NAME}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(8.dp), + ) + }, + ) { padding -> + if (profiles.isEmpty()) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("No profiles", style = MaterialTheme.typography.titleMedium) + Text("Tap + to add a VPN profile.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(profiles, key = { it.id }) { profile -> + TunnelRow(profile, state, onOpen, onConnect, onDisconnect) + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun TunnelRow( + profile: TunnelProfile, + state: TunnelState, + onOpen: (UUID) -> Unit, + onConnect: (UUID) -> Unit, + onDisconnect: () -> Unit, +) { + val status = state.statusOf(profile.id) + val waiting = state.isWaiting(profile.id) + val isOn = status.isInOperation || waiting + Row( + Modifier + .fillMaxWidth() + .clickable { onOpen(profile.id) } + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + StatusDot(if (waiting) TunnelStatus.CONNECTING.indicatorColor else status.indicatorColor) + Column(Modifier.weight(1f)) { + Text(profile.name, style = MaterialTheme.typography.bodyLarge) + Text( + if (waiting) "Waiting…" else status.displayText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(4.dp)) + Switch( + checked = isOn, + onCheckedChange = { on -> if (on) onConnect(profile.id) else onDisconnect() }, + enabled = status != TunnelStatus.DISCONNECTING, + ) + } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..c459fbc --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..02cb281 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #12A682 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7015c09 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + ezvpn + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..bcf7c10 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +