diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4efcf2f
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,87 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ native-tests:
+ name: Native Go tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v6
+
+ - name: Set up Go
+ uses: actions/setup-go@v7
+ with:
+ go-version-file: native/go.mod
+ cache-dependency-path: native/go.sum
+
+ - name: Run native tests with the race detector
+ working-directory: native
+ run: go test -race ./...
+
+ android-tests:
+ name: Android tests and checks
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v6
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "17"
+ cache: gradle
+
+ - name: Set up Go
+ uses: actions/setup-go@v7
+ with:
+ go-version-file: native/go.mod
+ cache-dependency-path: native/go.sum
+
+ - name: Install Android SDK components
+ run: |
+ sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
+ if [[ ! -x "$sdkmanager" ]]; then
+ echo "sdkmanager is unavailable at $sdkmanager" >&2
+ exit 1
+ fi
+ yes | "$sdkmanager" --licenses >/dev/null || true
+ "$sdkmanager" \
+ "platforms;android-35" \
+ "build-tools;34.0.0" \
+ "ndk;29.0.14206865"
+ echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"
+
+ - name: Install gomobile
+ run: |
+ go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20260821190718-4776eadac327
+ gomobile init
+
+ - name: Build arm64 Android AAR
+ working-directory: native
+ run: |
+ mkdir -p ../app/libs
+ gomobile bind \
+ -target=android/arm64 \
+ -androidapi 26 \
+ -trimpath \
+ -o ../app/libs/megaproxy.aar \
+ ./mobile
+
+ - name: Run Android unit tests, lint, and debug build
+ run: ./gradlew --no-daemon testDebugUnitTest lintDebug assembleDebug
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..5041f13
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,106 @@
+name: Release APKs
+
+on:
+ push:
+ tags:
+ - "v*"
+
+permissions:
+ contents: write
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ release:
+ name: Build and publish signed APKs
+ runs-on: ubuntu-latest
+ timeout-minutes: 90
+ steps:
+ - name: Check out release tag
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "17"
+ cache: gradle
+
+ - name: Set up Go
+ uses: actions/setup-go@v7
+ with:
+ go-version-file: native/go.mod
+ cache-dependency-path: native/go.sum
+
+ - name: Install Android SDK components
+ run: |
+ sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
+ if [[ ! -x "$sdkmanager" ]]; then
+ echo "sdkmanager is unavailable at $sdkmanager" >&2
+ exit 1
+ fi
+ yes | "$sdkmanager" --licenses >/dev/null || true
+ "$sdkmanager" \
+ "platforms;android-35" \
+ "build-tools;34.0.0" \
+ "ndk;29.0.14206865"
+ echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"
+
+ - name: Install gomobile
+ run: |
+ go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20260821190718-4776eadac327
+ gomobile init
+
+ - name: Validate release tag
+ run: |
+ version_name="$(sed -nE 's/^[[:space:]]*versionName = "([^"]+)"/\1/p' app/build.gradle.kts | head -n 1)"
+ expected_tag="v${version_name}"
+ if [[ -z "$version_name" || "$GITHUB_REF_NAME" != "$expected_tag" ]]; then
+ echo "Release tag $GITHUB_REF_NAME does not match app version $expected_tag" >&2
+ exit 1
+ fi
+
+ - name: Restore Android signing key
+ env:
+ SIGNING_KEY_BASE64: ${{ secrets.ANDROID_SIGNING_KEY_BASE64 }}
+ KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
+ run: |
+ if [[ -z "$SIGNING_KEY_BASE64" || -z "$KEYSTORE_PASSWORD" ]]; then
+ echo "Android signing secrets are not configured" >&2
+ exit 1
+ fi
+ keystore="$RUNNER_TEMP/megaproxy-release.p12"
+ password_file="$RUNNER_TEMP/megaproxy-keystore-password"
+ printf '%s' "$SIGNING_KEY_BASE64" | base64 --decode > "$keystore"
+ printf '%s' "$KEYSTORE_PASSWORD" > "$password_file"
+ chmod 600 "$keystore" "$password_file"
+ echo "MEGAPROXY_KEYSTORE_PATH=$keystore" >> "$GITHUB_ENV"
+ echo "MEGAPROXY_KEY_PASSWORD_FILE=$password_file" >> "$GITHUB_ENV"
+
+ - name: Build optimized signed APKs
+ env:
+ MEGAPROXY_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
+ MEGAPROXY_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
+ MEGAPROXY_GIT_COMMIT: ${{ github.sha }}
+ run: ./scripts/build-release-apks.sh
+
+ - name: Publish GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ assets=(dist/release/*.apk dist/release/SHA256SUMS)
+ if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
+ gh release upload "$GITHUB_REF_NAME" "${assets[@]}" \
+ --repo "$GITHUB_REPOSITORY" \
+ --clobber
+ else
+ gh release create "$GITHUB_REF_NAME" "${assets[@]}" \
+ --repo "$GITHUB_REPOSITORY" \
+ --verify-tag \
+ --generate-notes \
+ --title "MegaProxy ${GITHUB_REF_NAME}"
+ fi
diff --git a/.gitignore b/.gitignore
index e5cbb64..847df0e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,34 +1,15 @@
-# Gradle files
.gradle/
-build/
-
-# Local configuration file (sdk path, etc)
+.idea/
local.properties
-
-# Log/OS Files
-*.log
-
-# Android Studio generated files and folders
-captures/
-.externalNativeBuild/
-.cxx/
-*.aab
-*.apk
-output-metadata.json
-
-# IntelliJ
+**/build/
*.iml
-.idea/
-misc.xml
-deploymentTargetDropDown.xml
-render.experimental.xml
-
-# Keystore files
+.DS_Store
+*.apk
+*.aab
*.jks
*.keystore
-
-# Google Services (e.g. APIs or Firebase)
-google-services.json
-
-# Android Profiling
-*.hprof
+/dist/
+.externalNativeBuild/
+.cxx/
+native/*.aar
+app/libs/*.aar
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..3fefce4
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,7 @@
+{
+ "recommendations": [
+ "vscjava.vscode-java-pack",
+ "fwcd.kotlin",
+ "golang.go"
+ ]
+}
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..a17cd2d
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,24 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "type": "node-terminal",
+ "name": "Run MegaProxy on Emulator",
+ "request": "launch",
+ "command": "${workspaceFolder}/scripts/run-without-debugging.sh",
+ "cwd": "${workspaceFolder}"
+ },
+ {
+ "type": "java",
+ "name": "Attach MegaProxy (JDWP)",
+ "request": "attach",
+ "hostName": "127.0.0.1",
+ "port": 8700,
+ "timeout": 60000,
+ "sourcePaths": [
+ "${workspaceFolder}/app/src/main/java"
+ ],
+ "preLaunchTask": "Android: Prepare JDWP"
+ }
+ ]
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..2d0c53f
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,148 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "Android: Create emulator",
+ "type": "shell",
+ "command": "source \"$HOME/.zshrc.extra\"; ./scripts/create-android-emulator.sh",
+ "problemMatcher": []
+ },
+ {
+ "label": "Android: List emulators",
+ "type": "shell",
+ "command": "source \"$HOME/.zshrc.extra\"; emulator -list-avds",
+ "problemMatcher": []
+ },
+ {
+ "label": "Android: Start emulator",
+ "type": "shell",
+ "command": "source \"$HOME/.zshrc.extra\"; emulator @MegaProxy_API_35 -gpu auto",
+ "isBackground": true,
+ "problemMatcher": [],
+ "presentation": {
+ "reveal": "always",
+ "panel": "dedicated"
+ }
+ },
+ {
+ "label": "Android: Run on emulator",
+ "type": "shell",
+ "command": "./scripts/run-without-debugging.sh",
+ "problemMatcher": [],
+ "presentation": {
+ "reveal": "always",
+ "panel": "dedicated"
+ }
+ },
+ {
+ "label": "Android: Build native AAR",
+ "type": "shell",
+ "command": "source \"$HOME/.zshrc.extra\"; mkdir -p ../app/libs; gomobile bind -target=android -androidapi 26 -o ../app/libs/megaproxy.aar ./mobile",
+ "options": {
+ "cwd": "${workspaceFolder}/native"
+ },
+ "problemMatcher": []
+ },
+ {
+ "label": "Test: Go core",
+ "type": "shell",
+ "command": "source \"$HOME/.zshrc.extra\"; go test ./...",
+ "options": {
+ "cwd": "${workspaceFolder}/native"
+ },
+ "group": "test",
+ "problemMatcher": []
+ },
+ {
+ "label": "Test: Android unit tests",
+ "type": "shell",
+ "command": "source \"$HOME/.zshrc.extra\"; ./gradlew testDebugUnitTest",
+ "group": "test",
+ "problemMatcher": []
+ },
+ {
+ "label": "Test: All",
+ "dependsOn": [
+ "Test: Go core",
+ "Test: Android unit tests"
+ ],
+ "dependsOrder": "parallel",
+ "group": {
+ "kind": "test",
+ "isDefault": true
+ },
+ "problemMatcher": []
+ },
+ {
+ "label": "Android: Build debug APK",
+ "type": "shell",
+ "command": "source \"$HOME/.zshrc.extra\"; ./gradlew assembleDebug",
+ "dependsOn": ["Android: Build native AAR"],
+ "dependsOrder": "sequence",
+ "group": {
+ "kind": "build",
+ "isDefault": true
+ },
+ "problemMatcher": []
+ },
+ {
+ "label": "Android: Install debug APK",
+ "type": "shell",
+ "command": "source \"$HOME/.zshrc.extra\"; adb wait-for-device; adb install -r app/build/outputs/apk/debug/app-debug.apk",
+ "dependsOn": ["Android: Build debug APK"],
+ "dependsOrder": "sequence",
+ "problemMatcher": []
+ },
+ {
+ "label": "Android: Launch app",
+ "type": "shell",
+ "command": "source \"$HOME/.zshrc.extra\"; adb shell am start -n net.megaproxy487/.MainActivity",
+ "dependsOn": ["Android: Install debug APK"],
+ "dependsOrder": "sequence",
+ "problemMatcher": []
+ },
+ {
+ "label": "Android: Logcat (app)",
+ "type": "shell",
+ "command": "source \"$HOME/.zshrc.extra\"; ./scripts/android-logcat.sh",
+ "isBackground": true,
+ "problemMatcher": [],
+ "presentation": {
+ "reveal": "always",
+ "panel": "dedicated"
+ }
+ },
+ {
+ "label": "Android: Prepare JDWP",
+ "type": "shell",
+ "command": "MEGAPROXY_JDWP_KEEP_ALIVE=1 ./scripts/debug-android-app.sh",
+ "isBackground": true,
+ "problemMatcher": [
+ {
+ "owner": "megaproxy-jdwp",
+ "pattern": {
+ "regexp": "^(?!)$"
+ },
+ "background": {
+ "activeOnStart": true,
+ "beginsPattern": "^Starting MegaProxy JDWP setup$",
+ "endsPattern": "^JDWP ready:"
+ }
+ }
+ ],
+ "presentation": {
+ "reveal": "always",
+ "focus": true,
+ "panel": "dedicated",
+ "clear": true,
+ "showReuseMessage": false
+ }
+ },
+ {
+ "label": "Android: Clear JDWP",
+ "type": "shell",
+ "command": "source \"$HOME/.zshrc.extra\"; adb -e shell am clear-debug-app; adb -e forward --remove tcp:8700 || true",
+ "problemMatcher": []
+ }
+ ]
+}
diff --git a/README.md b/README.md
index 0f077d1..febd0d2 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,245 @@
-# AndroidMegaProxy
\ No newline at end of file
+# MegaProxy
+
+[](https://github.com/andre487/AndroidMegaProxy/actions/workflows/ci.yml)
+[](LICENSE)
+[](https://developer.android.com/about/versions/oreo)
+
+
+
+
+
+MegaProxy is an open-source Android VPN client for reliable, secure connections through proxy
+servers you control or trust. It supports HTTPS and SSH transports, per-app routing, encrypted DNS,
+connection diagnostics, and automatic failover in one privacy-focused application.
+
+MegaProxy contains no advertising, analytics SDKs, tracking, or remote telemetry. Connection
+statistics and diagnostic logs stay on the device unless you explicitly choose to share them.
+
+> MegaProxy is under active development. Review the current limitations before relying on it for
+> critical connectivity.
+
+## Why MegaProxy
+
+- **Private by design.** No account, ads, analytics, tracking identifiers, or background telemetry.
+- **Your infrastructure.** Connect to HTTPS, SSH, or SSH-with-jump servers that you configure.
+- **End-to-end application encryption.** HTTPS proxying uses CONNECT without intercepting or
+ decrypting application traffic.
+- **Flexible routing.** Route the whole device or only selected applications through the VPN.
+- **Resilient connections.** Profile failover, encrypted DNS fallback, SSH keepalives, and
+ connection health reporting help recover from network and server failures.
+- **Fully open source.** The Android application and Go network core are available under the MIT
+ License.
+
+## Features
+
+### Connection profiles
+
+- Multiple named, colored, reorderable profiles.
+- HTTPS proxies over TLS with Basic authentication.
+- HTTP/2 CONNECT multiplexing when supported by the proxy, with automatic HTTP/1.1 fallback.
+- SSH `direct-tcpip` transport and SSH through a jump host.
+- SSH password and unencrypted private-key authentication.
+- SSH host-key verification with trust-on-first-use confirmation.
+- Profile cloning, import, export, and configurable failover.
+- Import from MegaProxy JSON, ProxyList, FoxyProxy JSON, and supported SuperProxy text files.
+
+### VPN and routing
+
+- Global VPN and per-app split tunneling.
+- Local-network bypass enabled by default.
+- Per-profile IPv6 support; IPv4-only operation is the default.
+- Android Always-on VPN integration and a persistent foreground-service notification.
+- Automatic reconnect when the active profile or pending connection settings change.
+- Approximate upload speed, download speed, proxy latency, and recent connection-error rate.
+
+### DNS and transport
+
+- DNS-over-HTTPS through the configured transport.
+- Cloudflare, Google, Quad9, Yandex Basic, Yandex Safe, Yandex Family, and custom DoH endpoints.
+- DNS-provider fallback where it does not weaken an explicitly selected filtering policy.
+- HTTPS ClientHello profiles powered by uTLS, plus manual JA3 configuration.
+- Configurable SSH client profiles, keepalives, channel limits, and session rotation.
+- Arbitrary UDP is intentionally not forwarded; QUIC/HTTP/3 clients normally fall back to TCP.
+
+### Diagnostics
+
+- A staged connection test for proxy setup, `example.com`, and the observed exit IP.
+- Local, size-limited, rotating diagnostic and crash logs designed to omit credentials and traffic
+ content.
+- On-device connection visibility checks and actionable connection warnings.
+- Optional feedback or crash reports opened in the user's email client; nothing is submitted
+ automatically.
+- English and Russian interfaces with an in-app language selector.
+
+## Privacy and security
+
+MegaProxy does not operate a proxy service and does not send configuration or usage data to the
+project author. Network traffic is sent only where required by the selected profile, destination,
+and DNS configuration. The explicit connection test additionally contacts `example.com` and
+`ifconfig.me`.
+
+- HTTPS proxy certificates are checked against the Android trust store, including hostname and
+ validity. Normal CA certificate renewal does not require certificate pinning.
+- Application TLS remains between the application and its destination. MegaProxy does not install
+ a CA certificate and does not perform TLS interception.
+- Proxy passwords and imported private keys are encrypted with AES-GCM using a key held by Android
+ Keystore.
+- Android cloud backup and device-to-device transfer are disabled for application data.
+- Every upstream socket is protected from recursive routing through the VPN.
+- SSH host keys are verified and unknown keys require explicit user confirmation.
+
+Two compatibility options deliberately reduce these protections: accepting an invalid HTTPS
+proxy certificate and accepting any SSH host key. MegaProxy displays a warning before enabling
+them. Use either option only when you understand and control the associated risk.
+
+The proxy or SSH server can observe connection metadata and the destinations it is asked to reach,
+even though it cannot read end-to-end encrypted application content. The operator of that server
+must therefore be trusted. No application can override an explicit Android force-stop, and device
+vendors may impose additional background-execution restrictions.
+
+## Installation
+
+Download an APK for your device architecture from
+[GitHub Releases](https://github.com/andre487/AndroidMegaProxy/releases). Most modern Android
+phones use `arm64-v8a`. Android 8.0 (API 26) or newer is required.
+
+Release APKs are signed by the project's release key. SHA-256 checksums are published alongside
+the APK files. Android will preserve application data across upgrades only when the package name
+and signing key remain unchanged.
+
+After installation:
+
+1. Create or import a connection profile.
+2. Choose global routing or select applications for split tunneling.
+3. Review DNS and fingerprint settings if the defaults are not appropriate for your server.
+4. Tap **Test** to validate the connection, then tap **Connect**.
+5. Optionally enable Always-on VPN in Android settings.
+
+For production-ready HTTPS, SSH, and SSH-with-jump server configurations, see the
+[English server guide](docs/en/index.md) or [Russian server guide](docs/ru/index.md).
+
+## Current limitations
+
+- Only TCP application traffic is forwarded. General SOCKS5 UDP and QUIC forwarding are not
+ implemented.
+- SSH private keys protected by a passphrase are not supported yet.
+- Browser and SSH fingerprint presets are version-specific approximations. A preset name is not a
+ permanent guarantee of an exact client fingerprint.
+- Edge Android, Samsung Internet, and Yandex Browser TLS presets remain unavailable until verified
+ Android ClientHello fixtures are added.
+- Always-on behavior ultimately depends on Android and the device vendor. An explicit force-stop
+ cannot be recovered from programmatically.
+
+## Building from source
+
+The command-line build does not require Android Studio. It requires:
+
+- JDK 17
+- Go 1.26 or newer
+- `gomobile`
+- Android SDK Platform 35
+- Android Build Tools 34.0.0
+- Android NDK 29.0.14206865
+
+Example environment on macOS:
+
+```shell
+export JAVA_HOME="/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home"
+export ANDROID_HOME="$HOME/Library/Android/sdk"
+export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865"
+export PATH="$JAVA_HOME/bin:$HOME/go/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH"
+```
+
+Build the Go core and Android AAR:
+
+```shell
+cd native
+go test ./...
+gomobile bind -target=android -androidapi 26 -o ../app/libs/megaproxy.aar ./mobile
+cd ..
+```
+
+Build and test the Android application:
+
+```shell
+./gradlew testDebugUnitTest assembleDebug
+```
+
+Install the debug APK on a connected device:
+
+```shell
+adb install -r app/build/outputs/apk/debug/app-debug.apk
+adb shell am start -n net.megaproxy487/.MainActivity
+```
+
+The project pins Gradle 8.9 through the checked-in wrapper. Use `./gradlew` rather than a globally
+installed Gradle version. See [native/README.md](native/README.md) for Go data-plane details.
+
+## Development workflow
+
+English is the project language for source code, comments, documentation, commit messages, UI
+copy, logs, and tooling.
+
+### Emulator
+
+Create the API 35 Google APIs ARM64 emulator:
+
+```shell
+./scripts/create-android-emulator.sh
+```
+
+The script installs missing components, configures host keyboard and mouse input, and can be run
+more than once. It creates `MegaProxy_API_35` by default; set `MEGAPROXY_AVD_NAME` to override the
+name.
+
+Start the application without a debugger:
+
+```shell
+./scripts/run-without-debugging.sh
+```
+
+The repository also includes VS Code tasks and launch configurations for creating and starting the
+emulator, building, installing, viewing app-specific Logcat output, running tests, and attaching a
+JDWP debugger. Select **Run MegaProxy on Emulator** for `Ctrl+F5` or **Attach MegaProxy (JDWP)** for
+`F5`.
+
+Run all native and Android unit tests from VS Code with **Tasks: Run Test Task**, or from a shell:
+
+```shell
+(cd native && go test ./...)
+./gradlew testDebugUnitTest
+```
+
+JDWP covers Kotlin and Java code only. Debug the Go core with its tests and privacy-safe diagnostic
+logging.
+
+### Signed release builds
+
+Build optimized and signed APKs for `arm64-v8a`, `armeabi-v7a`, `x86_64`, and `x86`:
+
+```shell
+./scripts/build-release-apks.sh
+```
+
+The script reads its default signing key from `$HOME/AndroidApkKey` and its password from
+`$HOME/.my-tokens/android-key-password`. Override these with `MEGAPROXY_KEYSTORE_PATH`,
+`MEGAPROXY_KEY_ALIAS`, `MEGAPROXY_KEY_PASSWORD_FILE`, and `MEGAPROXY_KEY_PASSWORD`. Outputs and
+`SHA256SUMS` are written to `dist/release`.
+
+Pushing a version tag runs the GitHub release workflow, builds and verifies every APK, and attaches
+the artifacts to a GitHub Release. The tag must match `versionName` exactly:
+
+```shell
+git tag v0.0.1
+git push origin v0.0.1
+```
+
+## Contributing
+
+Bug reports and focused pull requests are welcome. Please avoid including proxy credentials,
+private keys, destination history, or other personal data in issues and logs. Run both the Go and
+Android unit-test suites before opening a pull request.
+
+## License
+
+MegaProxy is released under the [MIT License](LICENSE).
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..7a43fbf
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,86 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+val releaseKeystorePath = providers.environmentVariable("MEGAPROXY_KEYSTORE_PATH").orNull
+val releaseKeystorePassword = providers.environmentVariable("MEGAPROXY_KEYSTORE_PASSWORD").orNull
+val releaseKeyAlias = providers.environmentVariable("MEGAPROXY_KEY_ALIAS").orNull
+val releaseKeyPassword = providers.environmentVariable("MEGAPROXY_KEY_PASSWORD").orNull
+val gitCommitHash = providers.environmentVariable("GITHUB_SHA")
+ .orElse(providers.environmentVariable("MEGAPROXY_GIT_COMMIT"))
+ .orElse(providers.exec {
+ commandLine("git", "rev-parse", "--short=8", "HEAD")
+ isIgnoreExitValue = true
+ }.standardOutput.asText)
+ .map { value ->
+ value.trim().take(8).takeIf { it.matches(Regex("[0-9a-fA-F]{7,8}")) } ?: "unknown"
+ }
+ .get()
+
+android {
+ namespace = "net.megaproxy487"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "net.megaproxy487"
+ minSdk = 26
+ targetSdk = 35
+ versionCode = 1
+ versionName = "0.0.1"
+ buildConfigField("String", "GIT_COMMIT_HASH", "\"$gitCommitHash\"")
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ signingConfigs {
+ if (releaseKeystorePath != null && releaseKeystorePassword != null &&
+ releaseKeyAlias != null && releaseKeyPassword != null
+ ) {
+ create("release") {
+ storeFile = file(releaseKeystorePath)
+ storePassword = releaseKeystorePassword
+ keyAlias = releaseKeyAlias
+ keyPassword = releaseKeyPassword
+ }
+ }
+ }
+ buildTypes {
+ getByName("release") {
+ signingConfig = signingConfigs.findByName("release")
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ packaging { resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" }
+ // Both bundled locales must remain available to the in-app language selector.
+ bundle { language { enableSplit = false } }
+}
+
+kotlin { jvmToolchain(17) }
+
+dependencies {
+ implementation(platform("androidx.compose:compose-bom:2025.01.01"))
+ implementation("androidx.activity:activity-compose:1.10.0")
+ implementation("androidx.core:core-ktx:1.15.0")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-extended")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.json:json:20250107")
+ runtimeOnly(files("libs/megaproxy.aar"))
+}
diff --git a/app/libs/megaproxy-sources.jar b/app/libs/megaproxy-sources.jar
new file mode 100644
index 0000000..4f2a5d7
Binary files /dev/null and b/app/libs/megaproxy-sources.jar differ
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..410fd0a
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,2 @@
+# Project-specific release rules. The gomobile AAR supplies its own keep rules
+# for the generated Go and JNI bridge classes.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..5ed3be8
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/net/megaproxy487/BatteryOptimizationReminder.kt b/app/src/main/java/net/megaproxy487/BatteryOptimizationReminder.kt
new file mode 100644
index 0000000..eba5336
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/BatteryOptimizationReminder.kt
@@ -0,0 +1,33 @@
+package net.megaproxy487
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.PowerManager
+import android.provider.Settings
+
+object BatteryOptimizationReminder {
+ private const val PREFS_NAME = "battery_optimization_reminder"
+ private const val LAST_REQUEST_AT = "last_request_at"
+ internal const val REQUEST_INTERVAL_MS = 24 * 60 * 60 * 1000L
+
+ fun maybeRequest(activity: Activity, now: Long = System.currentTimeMillis()) {
+ val powerManager = activity.getSystemService(PowerManager::class.java)
+ if (powerManager.isIgnoringBatteryOptimizations(activity.packageName)) return
+
+ val preferences = activity.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ val lastRequestAt = preferences.getLong(LAST_REQUEST_AT, 0L)
+ if (!isRequestDue(now, lastRequestAt)) return
+
+ val request = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
+ data = Uri.parse("package:${activity.packageName}")
+ }
+ if (runCatching { activity.startActivity(request) }.isSuccess) {
+ preferences.edit().putLong(LAST_REQUEST_AT, now).apply()
+ }
+ }
+
+ internal fun isRequestDue(now: Long, lastRequestAt: Long): Boolean =
+ lastRequestAt == 0L || now < lastRequestAt || now - lastRequestAt >= REQUEST_INTERVAL_MS
+}
diff --git a/app/src/main/java/net/megaproxy487/ConnectionTestActivity.kt b/app/src/main/java/net/megaproxy487/ConnectionTestActivity.kt
new file mode 100644
index 0000000..53c2914
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/ConnectionTestActivity.kt
@@ -0,0 +1,245 @@
+package net.megaproxy487
+
+import android.app.Activity
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.net.VpnService
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Box
+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.layout.WindowInsets
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+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.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.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.res.stringResource
+import net.megaproxy487.data.ConfigStore
+import net.megaproxy487.vpn.ProxyVpnService
+import net.megaproxy487.vpn.TestDiagnosticLog
+import net.megaproxy487.vpn.TestState
+import net.megaproxy487.vpn.SshHostKeyPromptState
+import net.megaproxy487.vpn.OTHER_ALWAYS_ON_VPN_MESSAGE
+import net.megaproxy487.vpn.hasOtherProvider
+import net.megaproxy487.vpn.openAndroidVpnSettings
+import net.megaproxy487.vpn.readAlwaysOnVpnStatus
+import net.megaproxy487.ui.theme.MegaProxyTheme
+
+class ConnectionTestActivity : LocalizedActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ val autoStart = savedInstanceState == null
+ if (autoStart) TestDiagnosticLog.reset()
+ setContent { MegaProxyTheme { ConnectionTestScreen(this, autoStart) } }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ConnectionTestScreen(activity: Activity, autoStart: Boolean) {
+ val state by TestDiagnosticLog.state
+ val exitIp by TestDiagnosticLog.exitIp
+ val pendingHostKey by SshHostKeyPromptState.pending
+ var vpnPermissionRequestedAt by remember { mutableStateOf(0L) }
+ var showAlwaysOnConflict by remember { mutableStateOf(false) }
+ val permission = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (VpnService.prepare(activity) == null) ProxyVpnService.test(activity)
+ else {
+ val status = readAlwaysOnVpnStatus(activity)
+ val dismissedImmediately = System.currentTimeMillis() - vpnPermissionRequestedAt < 1_000
+ if (status.hasOtherProvider || dismissedImmediately) {
+ TestDiagnosticLog.fail(OTHER_ALWAYS_ON_VPN_MESSAGE)
+ showAlwaysOnConflict = true
+ } else {
+ TestDiagnosticLog.fail("VPN access was not granted in the Android confirmation dialog")
+ }
+ }
+ }
+ val runTest = {
+ val configStore = ConfigStore(activity)
+ val error = configStore.globalConnectionSettings().applyTo(configStore.activeProfile().config)
+ .connectionValidationError()
+ if (error != null) {
+ TestDiagnosticLog.fail(error)
+ } else {
+ TestDiagnosticLog.begin()
+ val status = readAlwaysOnVpnStatus(activity)
+ if (status.hasOtherProvider) {
+ TestDiagnosticLog.fail(OTHER_ALWAYS_ON_VPN_MESSAGE)
+ showAlwaysOnConflict = true
+ } else {
+ val intent = VpnService.prepare(activity)
+ if (intent == null) {
+ ProxyVpnService.test(activity)
+ } else {
+ vpnPermissionRequestedAt = System.currentTimeMillis()
+ permission.launch(intent)
+ }
+ }
+ }
+ }
+ LaunchedEffect(autoStart) { if (autoStart) runTest() }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.connection_test)) },
+ navigationIcon = {
+ IconButton(onClick = { activity.finish() }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ )
+ },
+ contentWindowInsets = WindowInsets.safeDrawing,
+ ) { padding ->
+ Column(
+ Modifier.fillMaxSize().padding(padding).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ val statusLabel = when (state) {
+ TestState.IDLE -> "Ready"
+ TestState.RUNNING -> "Testing"
+ TestState.SUCCEEDED -> "Test passed"
+ TestState.FAILED -> "Test failed"
+ }
+ Card(Modifier.fillMaxWidth().semantics {
+ liveRegion = LiveRegionMode.Polite
+ stateDescription = statusLabel
+ }) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ val statusColor = when (state) {
+ TestState.SUCCEEDED -> MaterialTheme.colorScheme.primary
+ TestState.FAILED -> MaterialTheme.colorScheme.error
+ else -> MaterialTheme.colorScheme.onSurface
+ }
+ Text(
+ if (state == TestState.RUNNING) "Testing…" else statusLabel,
+ style = MaterialTheme.typography.titleLarge,
+ color = statusColor,
+ )
+ if (state == TestState.RUNNING) {
+ CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp))
+ }
+ exitIp?.let { Text("Proxy exit IP: $it") }
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Button(onClick = runTest, enabled = state != TestState.RUNNING) { Text(stringResource(R.string.run_again)) }
+ Button(onClick = {
+ val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ clipboard.setPrimaryClip(ClipData.newPlainText("MegaProxy connection test", TestDiagnosticLog.entries.joinToString("\n")))
+ }, enabled = TestDiagnosticLog.entries.isNotEmpty()) { Text(stringResource(R.string.copy_log)) }
+ }
+ Text(
+ "The test uses a temporary VPN when the main connection is inactive. Credentials and traffic content are not logged.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Box(Modifier.fillMaxWidth().weight(1f)) {
+ SelectionContainer {
+ Text(
+ TestDiagnosticLog.entries.joinToString("\n").ifEmpty {
+ if (state == TestState.RUNNING) "Waiting for the first diagnostic event…" else "No test events yet."
+ },
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
+ )
+ }
+ }
+ }
+ }
+
+ if (showAlwaysOnConflict) {
+ AlertDialog(
+ onDismissRequest = { showAlwaysOnConflict = false },
+ title = { Text("Always-on VPN is already in use") },
+ text = { Text(OTHER_ALWAYS_ON_VPN_MESSAGE) },
+ confirmButton = {
+ TextButton(onClick = {
+ showAlwaysOnConflict = false
+ openAndroidVpnSettings(activity)
+ }) { Text("Change settings") }
+ },
+ dismissButton = {
+ TextButton(onClick = { showAlwaysOnConflict = false }) { Text("Cancel") }
+ },
+ )
+ }
+ pendingHostKey?.takeIf { it.testOnly }?.let { pending ->
+ AlertDialog(
+ onDismissRequest = {
+ SshHostKeyPromptState.clear()
+ ProxyVpnService.dismissHostKeyPrompt(activity)
+ },
+ title = { Text(if (pending.changed) "SSH host key changed" else "Trust SSH host key?") },
+ text = { Text(buildString {
+ if (pending.changed) {
+ append("Warning: the previously trusted key for the ${pending.hop} host has changed. This may indicate a server reinstall or a man-in-the-middle attack. Verify it through a trusted channel before replacing it.\n\n")
+ } else {
+ append("This is the first connection to the ${pending.hop} SSH host. Verify its fingerprint through a trusted channel.\n\n")
+ }
+ append("Algorithm: ${pending.algorithm}\nFingerprint: ${pending.fingerprint}")
+ }) },
+ confirmButton = {
+ TextButton(onClick = {
+ val saved = ConfigStore(activity).trustSshHostKey(
+ pending.profileId, pending.hop, pending.fingerprint,
+ )
+ SshHostKeyPromptState.clear()
+ val persisted = ConfigStore(activity).profile(pending.profileId)?.config?.let { config ->
+ if (pending.hop == "jump") config.jumpTrustedHostKey else config.trustedHostKey
+ }
+ if (saved && persisted == pending.fingerprint) {
+ ProxyVpnService.test(activity)
+ } else {
+ TestDiagnosticLog.fail("The SSH host key could not be saved to the tested profile")
+ }
+ }) { Text(if (pending.changed) "Replace and test" else "Trust and test") }
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ SshHostKeyPromptState.clear()
+ ProxyVpnService.dismissHostKeyPrompt(activity)
+ }) { Text("Cancel") }
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/CrashHandler.kt b/app/src/main/java/net/megaproxy487/CrashHandler.kt
new file mode 100644
index 0000000..d1c148e
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/CrashHandler.kt
@@ -0,0 +1,35 @@
+package net.megaproxy487
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.os.Process
+import net.megaproxy487.vpn.PersistentDiagnosticLog
+import kotlin.system.exitProcess
+
+object CrashHandler {
+ private const val PREFS = "crash_state"
+ private const val PENDING = "pending"
+ private lateinit var preferences: SharedPreferences
+
+ fun install(context: Context) {
+ preferences = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ val previous = Thread.getDefaultUncaughtExceptionHandler()
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
+ runCatching {
+ PersistentDiagnosticLog.writeCrash(thread, throwable)
+ preferences.edit().putBoolean(PENDING, true).commit()
+ }
+ if (previous != null) previous.uncaughtException(thread, throwable) else {
+ Process.killProcess(Process.myPid())
+ exitProcess(10)
+ }
+ }
+ }
+
+ fun hasPendingReport(): Boolean =
+ ::preferences.isInitialized && preferences.getBoolean(PENDING, false)
+
+ fun markReportHandled() {
+ if (::preferences.isInitialized) preferences.edit().putBoolean(PENDING, false).apply()
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/DiagnosticLogActivity.kt b/app/src/main/java/net/megaproxy487/DiagnosticLogActivity.kt
new file mode 100644
index 0000000..bba768d
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/DiagnosticLogActivity.kt
@@ -0,0 +1,209 @@
+package net.megaproxy487
+
+import android.app.Activity
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.interaction.DragInteraction
+import androidx.compose.foundation.layout.Arrangement
+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.ime
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.union
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+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.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.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.repeatOnLifecycle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.launch
+import net.megaproxy487.data.ConfigStore
+import net.megaproxy487.vpn.PersistentDiagnosticLog
+import net.megaproxy487.ui.theme.MegaProxyTheme
+
+class DiagnosticLogActivity : LocalizedActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent { MegaProxyTheme { DiagnosticLogScreen(this) } }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun DiagnosticLogScreen(activity: Activity) {
+ val viewerWindowBytes = 512 * 1024
+ val store = remember { ConfigStore(activity) }
+ val listState = rememberLazyListState()
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val scope = rememberCoroutineScope()
+ var lines by remember { mutableStateOf(emptyList()) }
+ var autoScroll by remember { mutableStateOf(true) }
+ var limitText by remember { mutableStateOf(store.diagnosticLogLimitMb().toString()) }
+ var showClearConfirmation by remember { mutableStateOf(false) }
+ var message by remember { mutableStateOf(null) }
+ val isAtBottom by remember {
+ derivedStateOf {
+ lines.isEmpty() || listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == lines.lastIndex
+ }
+ }
+
+ val exportDocument = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
+ if (uri != null) {
+ scope.launch {
+ runCatching {
+ withContext(Dispatchers.IO) {
+ activity.contentResolver.openOutputStream(uri, "wt")?.buffered()?.use {
+ PersistentDiagnosticLog.copyTo(it)
+ } ?: error("Could not open the export file")
+ }
+ }.onSuccess { message = "Diagnostic log exported." }
+ .onFailure { message = it.message ?: "Could not export the diagnostic log" }
+ }
+ }
+ }
+
+ LaunchedEffect(lifecycleOwner) {
+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ while (true) {
+ val content = withContext(Dispatchers.IO) { PersistentDiagnosticLog.readTail(viewerWindowBytes) }
+ lines = content.lineSequence().filter(String::isNotEmpty).toList()
+ delay(1_000)
+ }
+ }
+ }
+ LaunchedEffect(listState) {
+ listState.interactionSource.interactions.collect { interaction ->
+ if (interaction is DragInteraction.Start) autoScroll = false
+ }
+ }
+ LaunchedEffect(isAtBottom) {
+ if (isAtBottom) autoScroll = true
+ }
+ LaunchedEffect(lines.size, autoScroll) {
+ if (autoScroll && lines.isNotEmpty()) listState.scrollToItem(lines.lastIndex)
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.diagnostic_log)) },
+ navigationIcon = {
+ IconButton(onClick = { activity.finish() }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ )
+ },
+ contentWindowInsets = WindowInsets.systemBars.union(WindowInsets.ime),
+ ) { padding ->
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ OutlinedTextField(
+ value = limitText,
+ onValueChange = { value ->
+ if (value.all(Char::isDigit) && value.length <= 3) {
+ limitText = value
+ value.toIntOrNull()?.takeIf {
+ it in PersistentDiagnosticLog.MIN_LIMIT_MB..PersistentDiagnosticLog.MAX_LIMIT_MB
+ }?.let {
+ store.setDiagnosticLogLimitMb(it)
+ PersistentDiagnosticLog.setLimitMb(it)
+ }
+ }
+ },
+ label = { Text("Rotated log limit (MB)") },
+ supportingText = { Text("Total size across both log segments; 1–100 MB.") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(onClick = { exportDocument.launch("MegaProxy-diagnostic.log") }, modifier = Modifier.weight(1f)) {
+ Text(stringResource(R.string.export_action))
+ }
+ OutlinedButton(onClick = { showClearConfirmation = true }, modifier = Modifier.weight(1f)) {
+ Text(stringResource(R.string.clear_action))
+ }
+ }
+ Text(
+ buildString {
+ append(if (autoScroll) "Auto-scroll on" else "Auto-scroll paused · scroll to the bottom to resume")
+ append(" · showing the latest 512 KB")
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxWidth().weight(1f),
+ ) {
+ if (lines.isEmpty()) item { Text("No diagnostic events yet.") }
+ itemsIndexed(lines, key = { index, _ -> index }) { _, line ->
+ Text(line, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace)
+ }
+ }
+ }
+ }
+
+ if (showClearConfirmation) {
+ AlertDialog(
+ onDismissRequest = { showClearConfirmation = false },
+ title = { Text("Clear diagnostic log?") },
+ text = { Text("Both rotated log segments will be deleted.") },
+ confirmButton = { TextButton(onClick = {
+ showClearConfirmation = false
+ PersistentDiagnosticLog.clear()
+ }) { Text("Clear") } },
+ dismissButton = { TextButton(onClick = { showClearConfirmation = false }) { Text("Cancel") } },
+ )
+ }
+ message?.let {
+ AlertDialog(
+ onDismissRequest = { message = null },
+ text = { Text(it) },
+ confirmButton = { TextButton(onClick = { message = null }) { Text("OK") } },
+ )
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/FeedbackEmail.kt b/app/src/main/java/net/megaproxy487/FeedbackEmail.kt
new file mode 100644
index 0000000..bdce2ab
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/FeedbackEmail.kt
@@ -0,0 +1,99 @@
+package net.megaproxy487
+
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import androidx.core.content.FileProvider
+import net.megaproxy487.data.ConfigStore
+import net.megaproxy487.vpn.DiagnosticLog
+import net.megaproxy487.vpn.PersistentDiagnosticLog
+import net.megaproxy487.vpn.VpnConnectionState
+import java.io.File
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+import java.util.UUID
+
+object FeedbackEmail {
+ private const val ADDRESS = "megaproxy-feedback@hotmail.com"
+ private const val SUBJECT = "[MegaProxy] Feedback"
+
+ fun createIntent(
+ context: Context,
+ connection: VpnConnectionState,
+ alwaysOn: Boolean,
+ lockdown: Boolean,
+ crashReport: Boolean = false,
+ ): Intent {
+ val store = ConfigStore(context)
+ val config = store.globalConnectionSettings().applyTo(store.connectionProfile().config)
+ val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
+ val versionCode = if (Build.VERSION.SDK_INT >= 28) packageInfo.longVersionCode else {
+ @Suppress("DEPRECATION")
+ packageInfo.versionCode.toLong()
+ }
+ val versionName = packageInfo.versionName ?: "unknown"
+ val body = buildString {
+ appendLine(if (crashReport) "Please describe what happened before the crash above this line." else "Please write your comments above this line.")
+ appendLine()
+ appendLine("--- Diagnostic information ---")
+ appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
+ appendLine("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
+ if (Build.VERSION.SECURITY_PATCH.isNotBlank()) appendLine("Security patch: ${Build.VERSION.SECURITY_PATCH}")
+ appendLine("App: $versionName ($versionCode)")
+ appendLine("ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}")
+ appendLine("Connection: ${connection.name.lowercase()}")
+ appendLine("Always-on: $alwaysOn; lockdown: $lockdown")
+ appendLine("Routing: ${if (config.routeAllApps) "global" else "split"}; selected app count: ${config.selectedPackages.size}")
+ appendLine("HTTPS fingerprint: ${config.profile.name}")
+ appendLine("SSH fingerprint: ${config.sshProfile.name}")
+ appendLine("DoH provider: ${config.dnsProvider.name}")
+ appendLine("IPv6: ${config.allowIpv6}; bypass local networks: ${config.bypassLocalNetworks}")
+ appendLine("Proxy certificate verification: ${!config.allowInvalidProxyCertificate}")
+ appendLine("Privacy-filtered logs are attached.")
+ }
+
+ val directory = File(context.cacheDir, "feedback").also { it.mkdirs() }
+ directory.listFiles { file -> file.isFile && file.extension == "zip" }
+ .orEmpty()
+ .sortedByDescending(File::lastModified)
+ .drop(2)
+ .forEach(File::delete)
+ // Do not reuse a content URI that may still be granted to an email app:
+ // a stale grant must never expose diagnostics generated in the future.
+ val logFile = File(directory, "megaproxy-diagnostics-${UUID.randomUUID()}.zip")
+ DiagnosticLog.add("event=feedback result=prepared")
+ ZipOutputStream(logFile.outputStream().buffered()).use { zip ->
+ zip.putNextEntry(ZipEntry("megaproxy-diagnostic.log"))
+ PersistentDiagnosticLog.copyTo(zip)
+ zip.closeEntry()
+ }
+ val logUri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", logFile)
+
+ val sendIntent = Intent(Intent.ACTION_SEND).apply {
+ type = "application/zip"
+ putExtra(Intent.EXTRA_EMAIL, arrayOf(ADDRESS))
+ putExtra(Intent.EXTRA_SUBJECT, if (crashReport) "[MegaProxy] Crash" else SUBJECT)
+ putExtra(Intent.EXTRA_TEXT, body)
+ putExtra(Intent.EXTRA_STREAM, logUri)
+ clipData = ClipData.newUri(context.contentResolver, "MegaProxy diagnostic log archive", logUri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ val emailPackages = context.packageManager
+ .queryIntentActivities(Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$ADDRESS")), 0)
+ .map { it.activityInfo.packageName }
+ .distinct()
+ val targeted = emailPackages.mapNotNull { packageName ->
+ Intent(sendIntent).setPackage(packageName).takeIf {
+ it.resolveActivity(context.packageManager) != null
+ }
+ }
+ if (targeted.isEmpty()) return Intent.createChooser(sendIntent, "Send feedback")
+ return Intent.createChooser(targeted.first(), "Send feedback").apply {
+ if (targeted.size > 1) {
+ putExtra(Intent.EXTRA_INITIAL_INTENTS, targeted.drop(1).toTypedArray())
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/LocalizedActivity.kt b/app/src/main/java/net/megaproxy487/LocalizedActivity.kt
new file mode 100644
index 0000000..b8ca5cd
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/LocalizedActivity.kt
@@ -0,0 +1,60 @@
+package net.megaproxy487
+
+import android.content.Context
+import android.content.res.Configuration
+import android.os.Bundle
+import android.os.LocaleList
+import androidx.activity.ComponentActivity
+import androidx.activity.enableEdgeToEdge
+import java.util.Locale
+
+enum class AppLanguage(val tag: String) {
+ ENGLISH("en"),
+ RUSSIAN("ru"),
+}
+
+object AppLanguageManager {
+ private const val PREFERENCES = "app_language"
+ private const val KEY_LANGUAGE = "language"
+
+ fun current(context: Context): AppLanguage {
+ val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
+ val stored = preferences.getString(KEY_LANGUAGE, null)
+ if (stored != null) return AppLanguage.entries.firstOrNull { it.tag == stored } ?: AppLanguage.ENGLISH
+ val initial = if (Locale.getDefault().language == AppLanguage.RUSSIAN.tag) {
+ AppLanguage.RUSSIAN
+ } else {
+ AppLanguage.ENGLISH
+ }
+ preferences.edit().putString(KEY_LANGUAGE, initial.tag).apply()
+ return initial
+ }
+
+ fun set(context: Context, language: AppLanguage) {
+ context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
+ .edit()
+ .putString(KEY_LANGUAGE, language.tag)
+ .apply()
+ }
+
+ fun wrap(context: Context): Context {
+ val locale = Locale.forLanguageTag(current(context).tag)
+ val configuration = Configuration(context.resources.configuration)
+ configuration.setLocales(LocaleList(locale))
+ configuration.setLayoutDirection(locale)
+ return context.createConfigurationContext(configuration)
+ }
+}
+
+abstract class LocalizedActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // Keep every screen on the same backward-compatible edge-to-edge path.
+ // Individual Compose roots remain responsible for consuming system and IME insets.
+ enableEdgeToEdge()
+ }
+
+ override fun attachBaseContext(newBase: Context) {
+ super.attachBaseContext(AppLanguageManager.wrap(newBase))
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/MainActivity.kt b/app/src/main/java/net/megaproxy487/MainActivity.kt
new file mode 100644
index 0000000..74d5145
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/MainActivity.kt
@@ -0,0 +1,673 @@
+package net.megaproxy487
+
+import android.Manifest
+import android.app.Activity
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.VpnService
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material.icons.outlined.RadioButtonUnchecked
+import androidx.compose.material3.Button
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.foundation.clickable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.repeatOnLifecycle
+import net.megaproxy487.vpn.ConnectionStatsReader
+import net.megaproxy487.vpn.NativeConnectionStats
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import net.megaproxy487.data.ConfigStore
+import net.megaproxy487.data.ConfigIoDispatcher
+import net.megaproxy487.vpn.ProxyVpnService
+import net.megaproxy487.vpn.SshHostKeyPromptState
+import net.megaproxy487.vpn.VpnConnectionState
+import net.megaproxy487.vpn.VpnRuntimeState
+import net.megaproxy487.vpn.VpnTransportProtocol
+import net.megaproxy487.vpn.readAlwaysOnVpnStatus
+import net.megaproxy487.vpn.AlwaysOnVpnStatus
+import net.megaproxy487.vpn.hasOtherProvider
+import net.megaproxy487.vpn.openAndroidVpnSettings
+import net.megaproxy487.vpn.OTHER_ALWAYS_ON_VPN_MESSAGE
+import net.megaproxy487.model.ProfileColors
+import net.megaproxy487.model.ProxyType
+import net.megaproxy487.model.ProxyProfile
+import net.megaproxy487.ui.theme.MegaProxyTheme
+
+class MainActivity : LocalizedActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ BatteryOptimizationReminder.maybeRequest(this)
+ setContent { MegaProxyTheme { MainScreen(this) } }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ ProxyVpnService.refreshStatus(this)
+ val status = readAlwaysOnVpnStatus(this)
+ val store = ConfigStore(this)
+ val profileId = if (status.enabled) store.alwaysOnProfileId() else store.connectionProfile().id
+ VpnRuntimeState.updateSystem(status.enabled, status.lockdown, profileId)
+ }
+}
+
+@Composable
+private fun ProfileTypeBadge(type: ProxyType, foreground: Color) {
+ Surface(
+ color = foreground.copy(alpha = 0.14f),
+ contentColor = foreground,
+ shape = RoundedCornerShape(50),
+ border = BorderStroke(1.dp, foreground.copy(alpha = 0.5f)),
+ ) {
+ Text(
+ when (type) {
+ ProxyType.HTTPS -> "HTTPS"
+ ProxyType.SSH -> "SSH"
+ ProxyType.SSH_JUMP -> "SSH + Jump"
+ },
+ style = MaterialTheme.typography.labelSmall,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun MainScreen(activity: Activity) {
+ val connection by VpnRuntimeState.connection
+ val runtimeAlwaysOn by VpnRuntimeState.alwaysOn
+ val runtimeLockdown by VpnRuntimeState.lockdown
+ val runtimeProfileId by VpnRuntimeState.connectionProfileId
+ val networkWarning by VpnRuntimeState.networkWarning
+ val transportProtocol by VpnRuntimeState.transportProtocol
+ val pendingHostKey by SshHostKeyPromptState.pending
+ val store = remember { ConfigStore(activity) }
+ var error by remember { mutableStateOf(null) }
+ var profileMenuExpanded by remember { mutableStateOf(false) }
+ var profiles by remember { mutableStateOf(store.sortedProfiles()) }
+ var activeProfileId by remember { mutableStateOf(store.activeProfileId()) }
+ var connectionProfileId by remember { mutableStateOf(store.connectionProfile().id) }
+ var connectionStats by remember { mutableStateOf(null) }
+ var systemVpnStatus by remember { mutableStateOf(readAlwaysOnVpnStatus(activity)) }
+ var vpnPermissionRequestedAt by remember { mutableStateOf(0L) }
+ var showCrashReport by remember { mutableStateOf(CrashHandler.hasPendingReport()) }
+ var showAlwaysOnConflict by remember { mutableStateOf(false) }
+ var pendingReconnect by remember { mutableStateOf(store.hasPendingReconnect()) }
+ var globalSettings by remember { mutableStateOf(store.globalConnectionSettings()) }
+ val alwaysOn = runtimeAlwaysOn || systemVpnStatus.enabled
+ val lockdown = runtimeLockdown || systemVpnStatus.lockdown
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val scope = rememberCoroutineScope()
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ scope.launch {
+ val refreshed = withContext(ConfigIoDispatcher) {
+ val status = readAlwaysOnVpnStatus(activity)
+ RefreshedMainConfig(
+ status = status,
+ profiles = store.sortedProfiles(),
+ activeProfileId = store.activeProfileId(),
+ connectionProfileId = if (status.enabled) store.alwaysOnProfileId() else store.connectionProfile().id,
+ pendingReconnect = store.hasPendingReconnect(),
+ globalSettings = store.globalConnectionSettings(),
+ )
+ }
+ systemVpnStatus = refreshed.status
+ profiles = refreshed.profiles
+ activeProfileId = refreshed.activeProfileId
+ connectionProfileId = refreshed.connectionProfileId
+ pendingReconnect = refreshed.pendingReconnect
+ globalSettings = refreshed.globalSettings
+ }
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+ LaunchedEffect(connection) {
+ pendingReconnect = store.hasPendingReconnect()
+ }
+ LaunchedEffect(lifecycleOwner, connection) {
+ if (connection != VpnConnectionState.CONNECTED) {
+ connectionStats = null
+ return@LaunchedEffect
+ }
+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ var previous: NativeConnectionStats? = null
+ var smoothedDownload = 0.0
+ var smoothedUpload = 0.0
+ while (true) {
+ // JNI reflection and JSON decoding are small but not frame work. Some
+ // vendor devices expose their cost as visible input latency, so sample
+ // away from the main dispatcher.
+ val snapshot = withContext(Dispatchers.Default) {
+ ConnectionStatsReader.snapshot()
+ }
+ if (snapshot != null) {
+ previous?.let { old ->
+ val download = (snapshot.downloadBytes - old.downloadBytes).coerceAtLeast(0).toDouble()
+ val upload = (snapshot.uploadBytes - old.uploadBytes).coerceAtLeast(0).toDouble()
+ val alpha = 0.35
+ smoothedDownload = if (smoothedDownload == 0.0) download else alpha * download + (1 - alpha) * smoothedDownload
+ smoothedUpload = if (smoothedUpload == 0.0) upload else alpha * upload + (1 - alpha) * smoothedUpload
+ }
+ previous = snapshot
+ connectionStats = DisplayedConnectionStats(snapshot, smoothedDownload, smoothedUpload)
+ }
+ delay(1_000)
+ }
+ }
+ }
+ val vpnPermission = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (VpnService.prepare(activity) == null) {
+ if (!isAlwaysOnVpnActive(activity)) ProxyVpnService.start(activity) else error = null
+ } else {
+ val status = readAlwaysOnVpnStatus(activity)
+ val dismissedImmediately = System.currentTimeMillis() - vpnPermissionRequestedAt < 1_000
+ if (status.hasOtherProvider || dismissedImmediately) {
+ error = null
+ showAlwaysOnConflict = true
+ } else {
+ error = "VPN access was not granted in the Android confirmation dialog"
+ }
+ }
+ }
+ val requestVpnAccess = {
+ val status = readAlwaysOnVpnStatus(activity)
+ if (status.hasOtherProvider) {
+ error = null
+ showAlwaysOnConflict = true
+ } else {
+ val intent = VpnService.prepare(activity)
+ if (intent == null) {
+ ProxyVpnService.start(activity)
+ } else {
+ vpnPermissionRequestedAt = System.currentTimeMillis()
+ vpnPermission.launch(intent)
+ }
+ }
+ }
+ val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
+ requestVpnAccess()
+ }
+ val connect = {
+ if (isAlwaysOnVpnActive(activity)) {
+ systemVpnStatus = readAlwaysOnVpnStatus(activity)
+ error = null
+ } else {
+ error = globalSettings.applyTo(store.activeProfile().config).validationError()
+ }
+ if (error == null && !isAlwaysOnVpnActive(activity)) {
+ if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(
+ activity,
+ Manifest.permission.POST_NOTIFICATIONS,
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ requestVpnAccess()
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = { TopAppBar(title = { Text("MegaProxy") }) },
+ contentWindowInsets = WindowInsets.safeDrawing,
+ ) { padding ->
+ Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.TopCenter) {
+ Column(
+ Modifier.widthIn(max = 720.dp).fillMaxWidth().fillMaxHeight().verticalScroll(rememberScrollState()).padding(20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ val connected = connection == VpnConnectionState.CONNECTED
+ val statusLabel = when (connection) {
+ VpnConnectionState.CONNECTED -> "Connected"
+ VpnConnectionState.CONNECTING -> "Connecting"
+ VpnConnectionState.DISCONNECTED -> "Disconnected"
+ }
+ val statusColor = if (connected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
+ Card(Modifier.fillMaxWidth().semantics {
+ liveRegion = LiveRegionMode.Polite
+ stateDescription = statusLabel
+ }) {
+ Column(
+ Modifier.fillMaxWidth().padding(vertical = 28.dp, horizontal = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ if (connected) Icons.Filled.CheckCircle else Icons.Outlined.RadioButtonUnchecked,
+ contentDescription = null,
+ tint = statusColor,
+ )
+ Text(
+ if (connection == VpnConnectionState.CONNECTING) "Connecting…" else statusLabel,
+ style = MaterialTheme.typography.headlineSmall,
+ color = statusColor,
+ )
+ if (alwaysOn) {
+ Text(
+ if (lockdown) "Always-on · Block without VPN" else "Always-on VPN",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (connected && transportProtocol != VpnTransportProtocol.UNKNOWN) {
+ val transportLabel = when (transportProtocol) {
+ VpnTransportProtocol.HTTP_1_1 -> stringResource(R.string.transport_http_1_1)
+ VpnTransportProtocol.HTTP_2 -> stringResource(R.string.transport_http_2)
+ VpnTransportProtocol.SSH_MULTIPLEXED -> stringResource(R.string.transport_ssh_multiplexed)
+ VpnTransportProtocol.UNKNOWN -> ""
+ }
+ Surface(
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ shape = RoundedCornerShape(999.dp),
+ ) {
+ Text(
+ transportLabel,
+ style = MaterialTheme.typography.labelLarge,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
+ )
+ }
+ }
+ }
+ }
+ Spacer(Modifier.height(4.dp))
+ networkWarning?.let {
+ Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), modifier = Modifier.fillMaxWidth()) {
+ Text(it, color = MaterialTheme.colorScheme.onTertiaryContainer, modifier = Modifier.padding(14.dp))
+ }
+ Spacer(Modifier.height(12.dp))
+ }
+ connectionStats?.let { stats ->
+ ConnectionStatsCard(stats)
+ Spacer(Modifier.height(12.dp))
+ }
+ val displayedProfileId = if (alwaysOn) runtimeProfileId.ifEmpty { connectionProfileId } else activeProfileId
+ val activeProfile = profiles.firstOrNull { it.id == displayedProfileId } ?: profiles.first()
+ val actualProfile = profiles.firstOrNull { it.id == runtimeProfileId }
+ val activeProfileError = globalSettings.applyTo(activeProfile.config).connectionValidationError()
+ val profileColor = Color(ProfileColors.argb[Math.floorMod(activeProfile.colorIndex, ProfileColors.argb.size)])
+ val onProfileColor = if (profileColor.luminance() > 0.45f) Color.Black else Color.White
+ Box(Modifier.fillMaxWidth()) {
+ Card(
+ Modifier.fillMaxWidth().clickable { profileMenuExpanded = true },
+ colors = CardDefaults.cardColors(
+ containerColor = profileColor,
+ contentColor = onProfileColor,
+ disabledContainerColor = profileColor,
+ disabledContentColor = onProfileColor,
+ ),
+ ) {
+ Column(Modifier.fillMaxWidth().padding(14.dp)) {
+ Text("Profile", style = MaterialTheme.typography.labelMedium)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (activeProfile.flagEmoji.isNotEmpty()) {
+ Surface(
+ color = Color.White,
+ contentColor = Color.Black,
+ shape = RoundedCornerShape(6.dp),
+ border = BorderStroke(1.dp, Color.Black.copy(alpha = 0.45f)),
+ ) {
+ Text(activeProfile.flagEmoji, modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp))
+ }
+ }
+ Text(activeProfile.displayName, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
+ ProfileTypeBadge(activeProfile.config.type, onProfileColor)
+ Icon(Icons.Filled.ArrowDropDown, contentDescription = "Select profile", tint = onProfileColor)
+ }
+ if (actualProfile != null && actualProfile.id != activeProfile.id && connection != VpnConnectionState.DISCONNECTED) {
+ Text("Connected through: ${actualProfile.displayNameWithFlag}", style = MaterialTheme.typography.bodySmall)
+ }
+ DropdownMenu(profileMenuExpanded, { profileMenuExpanded = false }) {
+ profiles.forEach { profile ->
+ DropdownMenuItem(
+ text = {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Text(profile.displayNameWithFlag, modifier = Modifier.weight(1f))
+ ProfileTypeBadge(profile.config.type, MaterialTheme.colorScheme.onSurface)
+ }
+ },
+ onClick = {
+ val useAsAlwaysOn = isAlwaysOnVpnActive(activity)
+ if (useAsAlwaysOn) {
+ connectionProfileId = profile.id
+ } else {
+ activeProfileId = profile.id
+ }
+ if (useAsAlwaysOn || connection != VpnConnectionState.DISCONNECTED) {
+ ProxyVpnService.switchProfile(activity, profile.id, useAsAlwaysOn)
+ } else {
+ scope.launch(ConfigIoDispatcher) { store.setActiveProfile(profile.id) }
+ }
+ profileMenuExpanded = false
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+ if (connection == VpnConnectionState.DISCONNECTED && activeProfileError != null) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ Modifier.fillMaxWidth().padding(14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(activeProfileError, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onErrorContainer)
+ TextButton(onClick = {
+ activity.startActivity(Intent(activity, ProfileEditorActivity::class.java).apply {
+ putExtra(ProfileEditorActivity.EXTRA_PROFILE_ID, activeProfile.id)
+ })
+ }) { Text(stringResource(R.string.configure)) }
+ }
+ }
+ }
+ Button(
+ onClick = {
+ if (isAlwaysOnVpnActive(activity)) {
+ systemVpnStatus = readAlwaysOnVpnStatus(activity)
+ } else if (connection != VpnConnectionState.DISCONNECTED) {
+ ProxyVpnService.stop(activity)
+ } else {
+ connect()
+ }
+ },
+ enabled = !alwaysOn &&
+ (connection != VpnConnectionState.DISCONNECTED || activeProfileError == null),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ when (connection) {
+ VpnConnectionState.CONNECTED -> stringResource(R.string.disconnect)
+ VpnConnectionState.CONNECTING -> stringResource(R.string.disconnect)
+ VpnConnectionState.DISCONNECTED -> stringResource(R.string.connect)
+ },
+ )
+ }
+ if (connected) {
+ FilledTonalButton(
+ onClick = { ProxyVpnService.reconnect(activity) },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(stringResource(R.string.reconnect))
+ if (pendingReconnect) {
+ Surface(
+ color = MaterialTheme.colorScheme.tertiaryContainer,
+ contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
+ shape = RoundedCornerShape(999.dp),
+ ) {
+ Text(
+ stringResource(R.string.apply_new_settings),
+ style = MaterialTheme.typography.labelSmall,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ FilledTonalButton(
+ onClick = { activity.startActivity(Intent(activity, ConnectionTestActivity::class.java)) },
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text(stringResource(R.string.test_connection)) }
+ FilledTonalButton(
+ onClick = { activity.startActivity(Intent(activity, ProxySettingsActivity::class.java)) },
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text(stringResource(R.string.settings)) }
+ FilledTonalButton(
+ onClick = {
+ scope.launch {
+ runCatching {
+ val intent = withContext(Dispatchers.IO) {
+ FeedbackEmail.createIntent(activity, connection, alwaysOn, lockdown)
+ }
+ activity.startActivity(intent)
+ }.onFailure {
+ error = "No email client is available: ${it.message ?: "unknown error"}"
+ }
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text(stringResource(R.string.feedback)) }
+ error?.let {
+ Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 12.dp))
+ }
+ if (alwaysOn) {
+ Text(
+ "Always-on VPN is used. Selecting a profile updates the Always-on profile and reconnects immediately; disconnect remains managed by Android.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 12.dp),
+ )
+ }
+ }
+ }
+ }
+
+ if (showCrashReport) {
+ AlertDialog(
+ onDismissRequest = {},
+ title = { Text("MegaProxy stopped unexpectedly") },
+ text = { Text("A privacy-filtered crash report was saved. You can send it to help diagnose the problem.") },
+ confirmButton = {
+ TextButton(onClick = {
+ scope.launch {
+ runCatching {
+ val intent = withContext(Dispatchers.IO) {
+ FeedbackEmail.createIntent(
+ activity,
+ connection,
+ alwaysOn,
+ lockdown,
+ crashReport = true,
+ )
+ }
+ activity.startActivity(intent)
+ CrashHandler.markReportHandled()
+ showCrashReport = false
+ }.onFailure {
+ error = "Could not open an email client: ${it.message ?: "unknown error"}"
+ }
+ }
+ }) { Text("Report") }
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ CrashHandler.markReportHandled()
+ showCrashReport = false
+ }) { Text("Close") }
+ },
+ )
+ }
+
+ if (showAlwaysOnConflict) {
+ AlertDialog(
+ onDismissRequest = { showAlwaysOnConflict = false },
+ title = { Text("Always-on VPN is already in use") },
+ text = { Text(OTHER_ALWAYS_ON_VPN_MESSAGE) },
+ confirmButton = {
+ TextButton(onClick = {
+ showAlwaysOnConflict = false
+ openAndroidVpnSettings(activity)
+ }) { Text("Change settings") }
+ },
+ dismissButton = {
+ TextButton(onClick = { showAlwaysOnConflict = false }) { Text("Cancel") }
+ },
+ )
+ }
+
+ pendingHostKey?.takeIf { !it.testOnly }?.let { pending ->
+ fun dismissHostKeyPrompt() {
+ SshHostKeyPromptState.clear()
+ ProxyVpnService.dismissHostKeyPrompt(activity)
+ }
+ AlertDialog(
+ onDismissRequest = ::dismissHostKeyPrompt,
+ title = { Text(if (pending.changed) "SSH host key changed" else "Trust SSH host key?") },
+ text = { Text(buildString {
+ if (pending.changed) {
+ append("Warning: the previously trusted key for the ${pending.hop} host has changed. This may indicate a server reinstall or a man-in-the-middle attack. Verify it through a trusted channel before replacing it.\n\n")
+ } else {
+ append("This is the first connection to the ${pending.hop} SSH host. Verify its fingerprint through a trusted channel.\n\n")
+ }
+ append("Algorithm: ${pending.algorithm}\nFingerprint: ${pending.fingerprint}")
+ }) },
+ confirmButton = {
+ TextButton(onClick = {
+ if (store.trustSshHostKey(pending.profileId, pending.hop, pending.fingerprint)) {
+ SshHostKeyPromptState.clear()
+ ProxyVpnService.reconnect(activity)
+ } else {
+ error = "The SSH host key could not be saved to this profile"
+ }
+ }) { Text(if (pending.changed) "Replace trusted key" else "Trust and connect") }
+ },
+ dismissButton = {
+ TextButton(onClick = ::dismissHostKeyPrompt) { Text("Cancel") }
+ },
+ )
+ }
+}
+
+private fun isAlwaysOnVpnActive(activity: Activity): Boolean =
+ ProxyVpnService.isAlwaysOnMode || readAlwaysOnVpnStatus(activity).enabled
+
+private data class DisplayedConnectionStats(
+ val native: NativeConnectionStats,
+ val downloadBytesPerSecond: Double,
+ val uploadBytesPerSecond: Double,
+)
+
+private data class RefreshedMainConfig(
+ val status: AlwaysOnVpnStatus,
+ val profiles: List,
+ val activeProfileId: String,
+ val connectionProfileId: String,
+ val pendingReconnect: Boolean,
+ val globalSettings: net.megaproxy487.model.GlobalConnectionSettings,
+)
+
+@Composable
+private fun ConnectionStatsCard(stats: DisplayedConnectionStats) {
+ Card(Modifier.fillMaxWidth()) {
+ Row(
+ Modifier.fillMaxWidth().padding(14.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ StatValue("Download", "↓ ${formatRate(stats.downloadBytesPerSecond)}")
+ StatValue("Upload", "↑ ${formatRate(stats.uploadBytesPerSecond)}")
+ val latency = stats.native.proxyLatencyMillis
+ val ageMillis = System.currentTimeMillis() - stats.native.proxyLatencyAtMillis
+ StatValue(
+ "Proxy latency",
+ if (latency <= 0) "—" else "${latency.toInt()} ms${formatAge(ageMillis)}",
+ )
+ }
+ Text(
+ if (stats.native.connectionSamples == 0) "Connection errors: no samples"
+ else "Connection errors: ${"%.1f".format(stats.native.connectionErrorRate * 100)}% · ${stats.native.connectionSamples} samples",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 12.dp),
+ )
+ }
+}
+
+@Composable
+private fun StatValue(label: String, value: String) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(value, style = MaterialTheme.typography.titleSmall)
+ Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+}
+
+private fun formatRate(bytesPerSecond: Double): String = when {
+ bytesPerSecond >= 1_000_000 -> "%.1f MB/s".format(bytesPerSecond / 1_000_000)
+ bytesPerSecond >= 1_000 -> "%.1f KB/s".format(bytesPerSecond / 1_000)
+ else -> "${bytesPerSecond.toInt()} B/s"
+}
+
+private fun formatAge(ageMillis: Long): String = when {
+ ageMillis < 30_000 -> ""
+ ageMillis < 120_000 -> " · ${ageMillis / 1_000}s ago"
+ else -> " · ${ageMillis / 60_000}m ago"
+}
diff --git a/app/src/main/java/net/megaproxy487/MegaProxyApplication.kt b/app/src/main/java/net/megaproxy487/MegaProxyApplication.kt
new file mode 100644
index 0000000..77666fc
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/MegaProxyApplication.kt
@@ -0,0 +1,19 @@
+package net.megaproxy487
+
+import android.app.Application
+import android.content.Context
+import net.megaproxy487.data.ConfigStore
+import net.megaproxy487.vpn.PersistentDiagnosticLog
+
+class MegaProxyApplication : Application() {
+ override fun attachBaseContext(base: Context) {
+ super.attachBaseContext(AppLanguageManager.wrap(base))
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ val store = ConfigStore(this)
+ PersistentDiagnosticLog.initialize(this, store.diagnosticLogLimitMb())
+ CrashHandler.install(this)
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/PackageReplacedReceiver.kt b/app/src/main/java/net/megaproxy487/PackageReplacedReceiver.kt
new file mode 100644
index 0000000..65383c6
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/PackageReplacedReceiver.kt
@@ -0,0 +1,42 @@
+package net.megaproxy487
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.net.VpnService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import net.megaproxy487.data.ConfigIoDispatcher
+import net.megaproxy487.data.ConfigStore
+import net.megaproxy487.vpn.DiagnosticLog
+import net.megaproxy487.vpn.ProxyVpnService
+
+/** Restores a user-requested VPN connection after this package is replaced. */
+class PackageReplacedReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
+ val pendingResult = goAsync()
+ val app = context.applicationContext
+ CoroutineScope(SupervisorJob() + ConfigIoDispatcher).launch {
+ try {
+ if (!ConfigStore(app).isConnectionDesired()) {
+ DiagnosticLog.add("event=package_replaced reconnect=skipped reason=connection_not_desired")
+ return@launch
+ }
+ if (VpnService.prepare(app) != null) {
+ // Android may revoke VPN consent independently of an app update. A
+ // background receiver cannot display the confirmation dialog.
+ DiagnosticLog.add("event=package_replaced reconnect=blocked reason=vpn_permission_required")
+ return@launch
+ }
+ DiagnosticLog.add("event=package_replaced reconnect=requested")
+ ProxyVpnService.restoreAfterPackageUpdate(app)
+ } catch (error: Exception) {
+ DiagnosticLog.add("event=package_replaced reconnect=failed reason=${error.javaClass.simpleName}")
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/ProfileEditorActivity.kt b/app/src/main/java/net/megaproxy487/ProfileEditorActivity.kt
new file mode 100644
index 0000000..6ffcd8b
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/ProfileEditorActivity.kt
@@ -0,0 +1,448 @@
+package net.megaproxy487
+
+import android.app.Activity
+import android.net.Uri
+import android.os.Bundle
+import android.view.WindowManager
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.union
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.MenuAnchorType
+import androidx.compose.material3.HorizontalDivider
+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.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.dp
+import net.megaproxy487.data.ConfigStore
+import net.megaproxy487.data.ConfigIoDispatcher
+import net.megaproxy487.data.readPrivateKeyText
+import net.megaproxy487.model.DnsProvider
+import net.megaproxy487.model.ProfileColorMatcher
+import net.megaproxy487.model.ProxyConfig
+import net.megaproxy487.model.ProxyType
+import net.megaproxy487.vpn.ProxyVpnService
+import net.megaproxy487.vpn.readAlwaysOnVpnStatus
+import net.megaproxy487.ui.theme.MegaProxyTheme
+import java.util.Locale
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class ProfileEditorActivity : LocalizedActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ setContent { MegaProxyTheme { ProfileEditorScreen(this) } }
+ }
+
+ companion object {
+ const val EXTRA_PROFILE_ID = "profile_id"
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ProfileEditorScreen(activity: Activity) {
+ val store = remember { ConfigStore(activity) }
+ val coroutineScope = rememberCoroutineScope()
+ val profileId = remember { activity.intent.getStringExtra(ProfileEditorActivity.EXTRA_PROFILE_ID) }
+ var profile by remember { mutableStateOf(store.profile(profileId.orEmpty()) ?: store.activeProfile()) }
+ var config by remember { mutableStateOf(profile.config) }
+ var portText by remember { mutableStateOf(config.port.toString()) }
+ var jumpPortText by remember { mutableStateOf(config.jumpPort.toString()) }
+ var error by remember { mutableStateOf(null) }
+ var countryExpanded by remember { mutableStateOf(false) }
+ var dnsExpanded by remember { mutableStateOf(false) }
+ var showInvalidCertificateWarning by remember { mutableStateOf(false) }
+ var typeExpanded by remember { mutableStateOf(false) }
+ var unsafeHostKeyHop by remember { mutableStateOf(null) }
+ var showReconnectPrompt by remember { mutableStateOf(false) }
+ var showAlwaysOnNotice by remember { mutableStateOf(false) }
+ var connectionChangeDeferred by remember { mutableStateOf(false) }
+ val countries = remember {
+ Locale.getISOCountries().map { code ->
+ code to Locale.Builder().setRegion(code).build().getDisplayCountry(Locale.getDefault())
+ }.sortedBy { it.second.lowercase(Locale.getDefault()) }
+ }
+
+ val globalSettings = remember { store.globalConnectionSettings() }
+ val editedConnectionProfileId = remember { store.connectionProfile().id }
+ val alwaysOnActive = remember { ProxyVpnService.isAlwaysOnMode || readAlwaysOnVpnStatus(activity).enabled }
+ fun saveProfile() {
+ val snapshot = profile
+ coroutineScope.launch(ConfigIoDispatcher) { store.saveProfile(snapshot) }
+ }
+ fun acceptText(value: String, maxLength: Int, update: (String) -> Unit) {
+ if (value.length <= maxLength) update(value)
+ }
+ fun updateConfig(updated: ProxyConfig) {
+ config = updated
+ profile = profile.copy(config = updated)
+ saveProfile()
+ error = globalSettings.applyTo(updated).connectionValidationError()
+ if (ProxyVpnService.isRunning && profile.id == editedConnectionProfileId) {
+ coroutineScope.launch(ConfigIoDispatcher) { store.markPendingReconnect() }
+ }
+ if (ProxyVpnService.isRunning && !connectionChangeDeferred && profile.id == editedConnectionProfileId) {
+ if (alwaysOnActive) {
+ connectionChangeDeferred = true
+ showAlwaysOnNotice = true
+ } else {
+ showReconnectPrompt = true
+ }
+ }
+ }
+ fun importPrivateKey(uri: Uri, jump: Boolean) {
+ coroutineScope.launch {
+ val result = runCatching {
+ withContext(Dispatchers.IO) {
+ activity.contentResolver.openInputStream(uri)?.use { it.readPrivateKeyText() }
+ ?: throw IllegalArgumentException("Unable to open the selected file")
+ }
+ }
+ result.onSuccess { imported ->
+ updateConfig(if (jump) config.copy(jumpPrivateKey = imported) else config.copy(privateKey = imported))
+ error = null
+ }.onFailure { failure ->
+ error = failure.message ?: "Unable to import the private key"
+ }
+ }
+ }
+ val destinationKeyPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ uri?.let { importPrivateKey(it, jump = false) }
+ }
+ val jumpKeyPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ uri?.let { importPrivateKey(it, jump = true) }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(profile.displayName) },
+ navigationIcon = {
+ IconButton(onClick = { activity.finish() }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ )
+ },
+ contentWindowInsets = WindowInsets.systemBars.union(WindowInsets.ime),
+ ) { padding ->
+ LazyColumn(
+ Modifier
+ .fillMaxSize()
+ .padding(padding),
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ item {
+ OutlinedTextField(profile.name, { name -> acceptText(name, 256) {
+ profile = profile.copy(name = name)
+ saveProfile()
+ } }, label = { Text("Profile name (optional)") }, supportingText = { Text("Defaults to the proxy host") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ }
+ item {
+ ExposedDropdownMenuBox(countryExpanded, { countryExpanded = it }) {
+ OutlinedTextField(
+ profile.countryCode.takeIf(String::isNotEmpty)?.let { code ->
+ "${profile.flagEmoji} ${countries.firstOrNull { it.first == code }?.second ?: code}"
+ } ?: "No flag",
+ {}, readOnly = true, label = { Text("Country flag") },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(countryExpanded) },
+ modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(),
+ )
+ DropdownMenu(countryExpanded, { countryExpanded = false }) {
+ DropdownMenuItem(text = { Text("No flag") }, onClick = {
+ profile = profile.copy(countryCode = "")
+ saveProfile()
+ countryExpanded = false
+ })
+ countries.forEach { (code, name) ->
+ val flag = net.megaproxy487.model.ProxyProfile("", colorIndex = 0, countryCode = code).flagEmoji
+ DropdownMenuItem(text = { Text("$flag $name") }, onClick = {
+ profile = profile.copy(
+ countryCode = code,
+ colorIndex = ProfileColorMatcher.colorIndexForFlag(code, profile.colorIndex),
+ )
+ saveProfile()
+ countryExpanded = false
+ })
+ }
+ }
+ }
+ }
+
+ item {
+ Text("Connection", style = MaterialTheme.typography.titleMedium)
+ }
+ item {
+ ExposedDropdownMenuBox(typeExpanded, { typeExpanded = it }) {
+ OutlinedTextField(config.type.title, {}, readOnly = true, label = { Text("Profile type") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth())
+ DropdownMenu(typeExpanded, { typeExpanded = false }) {
+ ProxyType.entries.forEach { type -> DropdownMenuItem(text = { Text(type.title) }, onClick = {
+ updateConfig(config.copy(type = type, port = type.defaultPort))
+ portText = type.defaultPort.toString()
+ if (type == ProxyType.SSH_JUMP) jumpPortText = config.jumpPort.toString()
+ typeExpanded = false
+ }) }
+ }
+ }
+ }
+ item {
+ OutlinedTextField(config.host, { value -> acceptText(value, 253) { updateConfig(config.copy(host = it)) } }, label = { Text(if (config.type == ProxyType.HTTPS) "HTTPS proxy hostname" else "Destination SSH hostname") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ }
+ item {
+ OutlinedTextField(portText, { value ->
+ portText = value
+ val port = value.toIntOrNull()
+ if (port == null) error = "Port must be between 1 and 65535"
+ else updateConfig(config.copy(port = port))
+ }, label = { Text("Port") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ }
+ item {
+ OutlinedTextField(config.username, { value -> acceptText(value, 4_096) { updateConfig(config.copy(username = it)) } }, label = { Text(if (config.type == ProxyType.HTTPS) "Basic Auth username" else "SSH username") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ }
+ item {
+ OutlinedTextField(config.password, { value -> acceptText(value, 16_384) { updateConfig(config.copy(password = it)) } }, label = { Text(if (config.type == ProxyType.HTTPS) "Password" else "SSH password (optional)") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
+ }
+ if (config.type == ProxyType.HTTPS) item { SettingCheckboxRow(
+ checked = config.allowInvalidProxyCertificate,
+ title = "Allow self-signed proxy certificate",
+ description = "Disables certificate verification only for the HTTPS proxy.",
+ onCheckedChange = { checked ->
+ if (checked) showInvalidCertificateWarning = true
+ else updateConfig(config.copy(allowInvalidProxyCertificate = false))
+ },
+ )
+ }
+
+ if (config.type != ProxyType.HTTPS) {
+ item {
+ OutlinedTextField(config.privateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(privateKey = it)) } }, label = { Text("Private key (optional)") }, supportingText = { Text("PEM or OpenSSH format. Passphrase-protected keys are not supported.") }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
+ }
+ item {
+ OutlinedButton(
+ onClick = { destinationKeyPicker.launch(arrayOf("*/*")) },
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text(stringResource(R.string.import_destination_key)) }
+ }
+ if (config.password.isBlank() && config.privateKey.isBlank()) {
+ item {
+ Text(
+ "No SSH password or private key is configured. Connection will only work if the server permits authentication without credentials.",
+ color = MaterialTheme.colorScheme.tertiary,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ item {
+ SettingCheckboxRow(config.acceptAnyHostKey, "Accept any destination host key", "Unsafe: disables SSH server identity verification.") { checked ->
+ if (checked) unsafeHostKeyHop = "destination" else updateConfig(config.copy(acceptAnyHostKey = false))
+ }
+ }
+ if (config.trustedHostKey.isNotBlank()) item { Text("Trusted destination key: ${config.trustedHostKey}", style = MaterialTheme.typography.bodySmall) }
+ }
+
+ if (config.type == ProxyType.SSH_JUMP) {
+ item {
+ HorizontalDivider()
+ }
+ item {
+ Text("Jump host", style = MaterialTheme.typography.titleMedium)
+ }
+ item {
+ OutlinedTextField(config.jumpHost, { value -> acceptText(value, 253) { updateConfig(config.copy(jumpHost = it)) } }, label = { Text("Jump SSH hostname") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ }
+ item {
+ OutlinedTextField(jumpPortText, { value ->
+ if (value.length <= 5 && value.all(Char::isDigit)) {
+ jumpPortText = value
+ value.toIntOrNull()?.let { updateConfig(config.copy(jumpPort = it)) }
+ }
+ }, label = { Text("Jump port") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ }
+ item {
+ SettingCheckboxRow(config.sameJumpAuthentication, "Use the same authentication", "Reuse destination username, password and private key.") {
+ updateConfig(config.copy(sameJumpAuthentication = it))
+ }
+ }
+ if (!config.sameJumpAuthentication) {
+ item {
+ OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { Text("Jump SSH username") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ }
+ item {
+ OutlinedTextField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it)) } }, label = { Text("Jump SSH password (optional)") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
+ }
+ item {
+ OutlinedTextField(config.jumpPrivateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(jumpPrivateKey = it)) } }, label = { Text("Jump private key (optional)") }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
+ }
+ item {
+ OutlinedButton(
+ onClick = { jumpKeyPicker.launch(arrayOf("*/*")) },
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text(stringResource(R.string.import_jump_key)) }
+ }
+ if (config.jumpPassword.isBlank() && config.jumpPrivateKey.isBlank()) {
+ item {
+ Text(
+ "No jump password or private key is configured. Connection will only work if the jump server permits authentication without credentials.",
+ color = MaterialTheme.colorScheme.tertiary,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ }
+ item {
+ SettingCheckboxRow(config.jumpAcceptAnyHostKey, "Accept any jump host key", "Unsafe: disables jump host identity verification.") { checked ->
+ if (checked) unsafeHostKeyHop = "jump" else updateConfig(config.copy(jumpAcceptAnyHostKey = false))
+ }
+ }
+ if (config.jumpTrustedHostKey.isNotBlank()) item { Text("Trusted jump key: ${config.jumpTrustedHostKey}", style = MaterialTheme.typography.bodySmall) }
+ }
+
+ item {
+ SettingCheckboxRow(config.allowIpv6, "Enable IPv6 destinations", "Enable only if this proxy can reach IPv6 destinations.") {
+ updateConfig(config.copy(allowIpv6 = it))
+ }
+ }
+
+ item {
+ Text("DNS over HTTPS", style = MaterialTheme.typography.titleMedium)
+ }
+ item {
+ ExposedDropdownMenuBox(dnsExpanded, { dnsExpanded = it }) {
+ OutlinedTextField(config.dnsProvider.title, {}, readOnly = true, label = { Text("DNS over HTTPS") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dnsExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth())
+ DropdownMenu(dnsExpanded, { dnsExpanded = false }) {
+ DnsProvider.entries.forEach { provider -> DropdownMenuItem(text = { Text(provider.title) }, onClick = { updateConfig(config.copy(dnsProvider = provider)); dnsExpanded = false }) }
+ }
+ }
+ }
+ if (config.dnsProvider == DnsProvider.CUSTOM) item { OutlinedTextField(config.customDohUrl, { value -> acceptText(value, 2_048) { updateConfig(config.copy(customDohUrl = it)) } }, label = { Text("Custom DoH URL") }, singleLine = true, modifier = Modifier.fillMaxWidth()) }
+ error?.let { message -> item { Text(message, color = MaterialTheme.colorScheme.error) } }
+ if (showReconnectPrompt) {
+ item {
+ Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) {
+ Column(Modifier.fillMaxWidth().padding(12.dp)) {
+ Text("Reconnect to apply these changes to the active VPN.", style = MaterialTheme.typography.bodyMedium)
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ TextButton(onClick = {
+ showReconnectPrompt = false
+ connectionChangeDeferred = true
+ }) { Text("Next connection") }
+ TextButton(onClick = {
+ showReconnectPrompt = false
+ connectionChangeDeferred = true
+ ProxyVpnService.reconnect(activity)
+ }) { Text("Reconnect now") }
+ }
+ }
+ }
+ }
+ }
+ if (showAlwaysOnNotice) {
+ item {
+ Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) {
+ Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
+ Text("Always-on is active. Changes apply on the next connection.", modifier = Modifier.weight(1f))
+ TextButton(onClick = { showAlwaysOnNotice = false }) { Text("Dismiss") }
+ }
+ }
+ }
+ }
+ item {
+ Text("Changes are saved automatically.", style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+
+ if (showInvalidCertificateWarning) {
+ AlertDialog(
+ onDismissRequest = { showInvalidCertificateWarning = false },
+ title = { Text("Allow an untrusted proxy certificate?") },
+ text = { Text("This disables certificate-chain and hostname verification between your phone and the HTTPS proxy. An attacker could impersonate the proxy and obtain its Basic Auth credentials. Destination HTTPS certificates remain verified.") },
+ confirmButton = { TextButton(onClick = {
+ updateConfig(config.copy(allowInvalidProxyCertificate = true))
+ showInvalidCertificateWarning = false
+ }) { Text("OK") } },
+ dismissButton = { TextButton(onClick = { showInvalidCertificateWarning = false }) { Text("Cancel") } },
+ )
+ }
+ unsafeHostKeyHop?.let { hop ->
+ AlertDialog(
+ onDismissRequest = { unsafeHostKeyHop = null },
+ title = { Text("Accept any SSH host key?") },
+ text = { Text("This disables identity verification for the $hop SSH host and permits man-in-the-middle attacks. Credentials and tunneled traffic could be intercepted.") },
+ confirmButton = { TextButton(onClick = { updateConfig(if (hop == "jump") config.copy(jumpAcceptAnyHostKey = true) else config.copy(acceptAnyHostKey = true)); unsafeHostKeyHop = null }) { Text("Accept any key") } },
+ dismissButton = { TextButton(onClick = { unsafeHostKeyHop = null }) { Text("Cancel") } },
+ )
+ }
+}
+
+@Composable
+private fun SettingCheckboxRow(
+ checked: Boolean,
+ title: String,
+ description: String,
+ onCheckedChange: (Boolean) -> Unit,
+) {
+ Row(
+ Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable(
+ value = checked,
+ onValueChange = onCheckedChange,
+ role = Role.Checkbox,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(title)
+ Text(description, style = MaterialTheme.typography.bodySmall)
+ }
+ Checkbox(checked, null)
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/ProxySettingsActivity.kt b/app/src/main/java/net/megaproxy487/ProxySettingsActivity.kt
new file mode 100644
index 0000000..46da028
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/ProxySettingsActivity.kt
@@ -0,0 +1,525 @@
+package net.megaproxy487
+
+import android.app.Activity
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.provider.Settings
+import androidx.activity.ComponentActivity
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.BorderStroke
+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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.HorizontalDivider
+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.RadioButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.zIndex
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.semantics.CustomAccessibilityAction
+import androidx.compose.ui.semantics.customActions
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import net.megaproxy487.data.ConfigStore
+import net.megaproxy487.data.ConfigIoDispatcher
+import net.megaproxy487.data.ConfigExportFormat
+import net.megaproxy487.data.ConfigTransfer
+import net.megaproxy487.data.FoxyProxyParser
+import net.megaproxy487.data.PortableConfiguration
+import net.megaproxy487.data.ProxyListParser
+import net.megaproxy487.data.SuperProxyParser
+import net.megaproxy487.data.readConfigText
+import net.megaproxy487.model.ProfileColors
+import net.megaproxy487.model.ProxyProfile
+import net.megaproxy487.model.ProxyType
+import net.megaproxy487.vpn.PersistentDiagnosticLog
+import net.megaproxy487.vpn.ProxyVpnService
+import net.megaproxy487.ui.theme.MegaProxyTheme
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class ProfilesActivity : LocalizedActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent { MegaProxyTheme { SettingsScreen(this) } }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SettingsScreen(activity: Activity) {
+ val store = remember { ConfigStore(activity) }
+ val scope = rememberCoroutineScope()
+ val profiles = remember { mutableStateListOf() }
+ var deleteProfile by remember { mutableStateOf(null) }
+ var importedProfileCount by remember { mutableStateOf(0) }
+ var importError by remember { mutableStateOf(null) }
+ var skippedNonHttps by remember { mutableStateOf(0) }
+ var showImportFilterNotice by remember { mutableStateOf(false) }
+ var showExportDialog by remember { mutableStateOf(false) }
+ var showPasswordExportWarning by remember { mutableStateOf(false) }
+ var exportFormat by remember { mutableStateOf(ConfigExportFormat.JSON) }
+ var includePasswords by remember { mutableStateOf(false) }
+ var includePrivateKeys by remember { mutableStateOf(false) }
+ var pendingExportContent by remember { mutableStateOf("") }
+ var transferMessage by remember { mutableStateOf(null) }
+ var pendingUnsafeImport by remember { mutableStateOf(null) }
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val listState = rememberLazyListState()
+ var draggedProfileId by remember { mutableStateOf(null) }
+
+ fun refresh() {
+ scope.launch {
+ val loaded = withContext(ConfigIoDispatcher) { store.sortedProfiles() }
+ profiles.clear()
+ profiles.addAll(loaded)
+ }
+ }
+ LaunchedEffect(Unit) { refresh() }
+ fun moveProfile(profileId: String, delta: Int): Boolean {
+ val sourceIndex = profiles.indexOfFirst { it.id == profileId }
+ val targetIndex = sourceIndex + delta
+ if (sourceIndex < 0 || targetIndex !in profiles.indices) return false
+ profiles.add(targetIndex, profiles.removeAt(sourceIndex))
+ val order = profiles.map(ProxyProfile::id)
+ scope.launch(ConfigIoDispatcher) { store.reorderProfiles(order) }
+ return true
+ }
+ fun edit(profile: ProxyProfile) {
+ activity.startActivity(Intent(activity, ProfileEditorActivity::class.java).apply {
+ putExtra(ProfileEditorActivity.EXTRA_PROFILE_ID, profile.id)
+ })
+ }
+ fun writeExport(uri: Uri?) {
+ if (uri == null) return
+ val content = pendingExportContent
+ scope.launch {
+ val result = withContext(Dispatchers.IO) {
+ runCatching {
+ activity.contentResolver.openOutputStream(uri, "wt")?.bufferedWriter()?.use { it.write(content) }
+ ?: error("Could not open the export file")
+ }
+ }
+ result.onSuccess { transferMessage = "Configuration exported successfully." }
+ .onFailure { importError = it.message ?: "Could not export the configuration" }
+ }
+ }
+ fun applyJsonImport(configuration: PortableConfiguration) {
+ scope.launch {
+ val added = withContext(ConfigIoDispatcher) {
+ store.importConfiguration(configuration).also {
+ if (ProxyVpnService.isRunning) store.markPendingReconnect()
+ PersistentDiagnosticLog.setLimitMb(store.diagnosticLogLimitMb())
+ }
+ }
+ refresh()
+ val missingPasswords = added.count { it.config.password.isEmpty() }
+ transferMessage = buildString {
+ append("Imported ${added.size} profile(s) from JSON.")
+ if (configuration.skippedProfiles > 0) append(" Skipped ${configuration.skippedProfiles} invalid profile(s).")
+ if (missingPasswords > 0) append(" $missingPasswords profile(s) require a password.")
+ if (ProxyVpnService.isAlwaysOnMode && ProxyVpnService.isRunning) append(" Reconnect the VPN to apply the imported Always-on profile selection.")
+ }
+ importedProfileCount = 0
+ }
+ }
+
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) refresh() }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ val exportTxtDocument = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain"), ::writeExport)
+ val exportJsonDocument = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json"), ::writeExport)
+ fun launchExport() {
+ scope.launch {
+ pendingExportContent = withContext(ConfigIoDispatcher) {
+ when (exportFormat) {
+ ConfigExportFormat.PROXY_LIST -> ConfigTransfer.exportProxyList(store.profiles(), includePasswords)
+ ConfigExportFormat.JSON -> ConfigTransfer.exportJson(store, includePasswords, includePrivateKeys)
+ }
+ }
+ if (exportFormat == ConfigExportFormat.PROXY_LIST) exportTxtDocument.launch("ProxyList.txt")
+ else exportJsonDocument.launch("MegaProxy-config.json")
+ }
+ }
+ val importDocument = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri != null) {
+ runCatching {
+ val text = activity.contentResolver.openInputStream(uri)?.buffered()?.use { it.readConfigText() }
+ ?: error("Could not read the selected file")
+ val isJson = activity.contentResolver.getType(uri) == "application/json" ||
+ uri.lastPathSegment.orEmpty().substringAfterLast('.', "").equals("json", true) ||
+ text.trimStart().startsWith('{')
+ if (isJson) {
+ val isMegaProxy = runCatching {
+ org.json.JSONObject(text).optString("schema") == "dev.megaproxy.config"
+ }.getOrDefault(false)
+ if (isMegaProxy) {
+ val configuration = ConfigTransfer.importJson(text)
+ if (configuration.profiles.any { it.config.allowInvalidProxyCertificate || it.config.acceptAnyHostKey || it.config.jumpAcceptAnyHostKey }) {
+ pendingUnsafeImport = configuration
+ } else {
+ applyJsonImport(configuration)
+ }
+ } else {
+ val imported = FoxyProxyParser.parse(text).getOrThrow()
+ val added = store.importProfiles(imported.proxies)
+ refresh()
+ importedProfileCount = added.size
+ skippedNonHttps = imported.skippedNonHttps
+ showImportFilterNotice = imported.skippedNonHttps > 0
+ if (imported.skippedNonHttps == 0) {
+ transferMessage = "Imported ${added.size} HTTPS proxy profile(s) from FoxyProxy."
+ }
+ }
+ } else {
+ val isSuperProxy = SuperProxyParser.matches(text)
+ val imported = if (isSuperProxy) {
+ SuperProxyParser.parse(text).getOrThrow()
+ } else {
+ ProxyListParser.parse(text).getOrThrow()
+ }
+ val added = store.importProfiles(imported.proxies)
+ refresh()
+ importedProfileCount = added.size
+ skippedNonHttps = imported.skippedNonHttps
+ showImportFilterNotice = imported.skippedNonHttps > 0
+ if (imported.skippedNonHttps == 0) {
+ transferMessage = if (isSuperProxy) {
+ "Imported ${added.size} HTTPS proxy profile(s) from Super Proxy. Certificate pins were not imported."
+ } else {
+ "Imported ${added.size} HTTPS proxy profile(s)."
+ }
+ }
+ }
+ importError = null
+ }.onFailure { importError = it.message ?: "Could not import the configuration" }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.profiles)) },
+ navigationIcon = {
+ IconButton(onClick = { activity.finish() }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ actions = {
+ TextButton(onClick = { importDocument.launch(arrayOf("text/plain", "application/json", "application/octet-stream")) }) {
+ Text(stringResource(R.string.import_action))
+ }
+ TextButton(onClick = { showExportDialog = true }) { Text(stringResource(R.string.export_action)) }
+ },
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(onClick = {
+ scope.launch {
+ val profile = withContext(ConfigIoDispatcher) { store.addProfile() }
+ refresh()
+ edit(profile)
+ }
+ }) {
+ Icon(Icons.Default.Add, contentDescription = "Add profile")
+ }
+ },
+ contentWindowInsets = WindowInsets.safeDrawing,
+ ) { padding ->
+ Box(
+ modifier = Modifier.fillMaxSize().padding(padding),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ LazyColumn(
+ Modifier.fillMaxHeight().fillMaxWidth().widthIn(max = 840.dp).padding(horizontal = 16.dp),
+ state = listState,
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ item {
+ Text(
+ "Long press and drag a profile to reorder it.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ }
+ items(profiles, key = { it.id }) { profile ->
+ var dragOffset by remember(profile.id) { mutableStateOf(0f) }
+ ProfileCard(
+ profile = profile,
+ modifier = Modifier
+ .zIndex(if (draggedProfileId == profile.id) 1f else 0f)
+ .graphicsLayer {
+ translationY = dragOffset
+ alpha = if (draggedProfileId == profile.id) 0.92f else 1f
+ }
+ .pointerInput(profile.id) {
+ detectDragGesturesAfterLongPress(
+ onDragStart = { draggedProfileId = profile.id },
+ onDragCancel = {
+ dragOffset = 0f
+ draggedProfileId = null
+ val order = profiles.map(ProxyProfile::id)
+ scope.launch(ConfigIoDispatcher) { store.reorderProfiles(order) }
+ },
+ onDragEnd = {
+ dragOffset = 0f
+ draggedProfileId = null
+ val order = profiles.map(ProxyProfile::id)
+ scope.launch(ConfigIoDispatcher) { store.reorderProfiles(order) }
+ },
+ onDrag = { change, amount ->
+ change.consume()
+ dragOffset += amount.y
+ val currentIndex = profiles.indexOfFirst { it.id == profile.id }
+ val currentInfo = listState.layoutInfo.visibleItemsInfo
+ .firstOrNull { it.key == profile.id } ?: return@detectDragGesturesAfterLongPress
+ val targetY = currentInfo.offset + currentInfo.size / 2f + dragOffset
+ val targetInfo = listState.layoutInfo.visibleItemsInfo.firstOrNull {
+ it.key != profile.id && targetY.toInt() in it.offset..(it.offset + it.size)
+ } ?: return@detectDragGesturesAfterLongPress
+ val targetIndex = profiles.indexOfFirst { it.id == targetInfo.key }
+ if (currentIndex >= 0 && targetIndex >= 0 && currentIndex != targetIndex) {
+ dragOffset += currentInfo.offset - targetInfo.offset
+ profiles.add(targetIndex, profiles.removeAt(currentIndex))
+ }
+ },
+ )
+ }
+ .semantics {
+ customActions = buildList {
+ if (profiles.firstOrNull()?.id != profile.id) {
+ add(CustomAccessibilityAction("Move up") { moveProfile(profile.id, -1) })
+ }
+ if (profiles.lastOrNull()?.id != profile.id) {
+ add(CustomAccessibilityAction("Move down") { moveProfile(profile.id, 1) })
+ }
+ }
+ },
+ onConfigure = { edit(profile) },
+ onClone = {
+ scope.launch {
+ withContext(ConfigIoDispatcher) { store.cloneProfile(profile.id) }
+ refresh()
+ }
+ },
+ onDelete = { deleteProfile = profile },
+ )
+ }
+ importError?.let { message -> item { Text(message, color = MaterialTheme.colorScheme.error) } }
+ item { Text(stringResource(R.string.changes_saved_automatically), style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 16.dp)) }
+ }
+ }
+ }
+
+ deleteProfile?.let { profile ->
+ AlertDialog(
+ onDismissRequest = { deleteProfile = null },
+ title = { Text("Delete ${profile.displayName}?") },
+ text = { Text("The profile and its encrypted credentials will be removed.") },
+ confirmButton = { TextButton(onClick = {
+ deleteProfile = null
+ scope.launch {
+ val reconnect = withContext(ConfigIoDispatcher) {
+ val needed = store.isConnectionDesired() && store.connectionProfile().id == profile.id
+ store.deleteProfile(profile.id)
+ needed
+ }
+ refresh()
+ if (reconnect) ProxyVpnService.reconnect(activity)
+ }
+ }, enabled = profiles.size > 1) { Text("Delete") } },
+ dismissButton = { TextButton(onClick = { deleteProfile = null }) { Text("Cancel") } },
+ )
+ }
+ if (showImportFilterNotice) {
+ AlertDialog(
+ onDismissRequest = { showImportFilterNotice = false },
+ title = { Text("Only HTTPS proxies were imported") },
+ text = { Text("Imported $importedProfileCount HTTPS proxy profile(s). Skipped $skippedNonHttps non-HTTPS URL(s).") },
+ confirmButton = { TextButton(onClick = { showImportFilterNotice = false }) { Text("Continue") } },
+ )
+ }
+ if (showExportDialog) {
+ AlertDialog(
+ onDismissRequest = { showExportDialog = false },
+ title = { Text("Export configuration") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Format")
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RadioButton(exportFormat == ConfigExportFormat.JSON, { exportFormat = ConfigExportFormat.JSON })
+ Text("JSON · all MegaProxy settings")
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RadioButton(exportFormat == ConfigExportFormat.PROXY_LIST, { exportFormat = ConfigExportFormat.PROXY_LIST })
+ Text("ProxyList.txt · compatible proxy fields only")
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(includePasswords, { includePasswords = it })
+ Text("Include passwords")
+ }
+ if (!includePasswords) {
+ Text("Passwords will be left empty and must be entered after import.", style = MaterialTheme.typography.bodySmall)
+ }
+ if (exportFormat == ConfigExportFormat.JSON) Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(includePrivateKeys, { includePrivateKeys = it })
+ Text("Include SSH private keys")
+ }
+ if (includePrivateKeys) Text("Private keys will be exported in plain text.", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
+ }
+ },
+ confirmButton = { TextButton(onClick = {
+ showExportDialog = false
+ if (includePasswords || includePrivateKeys) showPasswordExportWarning = true else launchExport()
+ }) { Text("Export") } },
+ dismissButton = { TextButton(onClick = { showExportDialog = false }) { Text("Cancel") } },
+ )
+ }
+ if (showPasswordExportWarning) {
+ AlertDialog(
+ onDismissRequest = { showPasswordExportWarning = false },
+ title = { Text("Export secrets in plain text?") },
+ text = { Text("Anyone with access to the exported file will be able to read the selected passwords or SSH private keys. Store and transfer it securely.") },
+ confirmButton = { TextButton(onClick = {
+ showPasswordExportWarning = false
+ launchExport()
+ }) { Text("Export anyway") } },
+ dismissButton = { TextButton(onClick = { showPasswordExportWarning = false }) { Text("Cancel") } },
+ )
+ }
+ transferMessage?.let { message ->
+ AlertDialog(
+ onDismissRequest = { transferMessage = null },
+ title = { Text("Configuration transfer") },
+ text = { Text(message) },
+ confirmButton = { TextButton(onClick = { transferMessage = null }) { Text("OK") } },
+ )
+ }
+ pendingUnsafeImport?.let { configuration ->
+ AlertDialog(
+ onDismissRequest = { pendingUnsafeImport = null },
+ title = { Text("Import insecure verification settings?") },
+ text = { Text("One or more profiles disable HTTPS certificate or SSH host-key verification. This permits server impersonation and should only be used for servers you control.") },
+ confirmButton = { TextButton(onClick = {
+ pendingUnsafeImport = null
+ applyJsonImport(configuration)
+ }) { Text("Import anyway") } },
+ dismissButton = { TextButton(onClick = { pendingUnsafeImport = null }) { Text("Cancel") } },
+ )
+ }
+}
+
+@Composable
+private fun ProfileCard(
+ profile: ProxyProfile,
+ modifier: Modifier = Modifier,
+ onConfigure: () -> Unit,
+ onClone: () -> Unit,
+ onDelete: () -> Unit,
+) {
+ val background = Color(ProfileColors.argb[Math.floorMod(profile.colorIndex, ProfileColors.argb.size)])
+ val foreground = if (background.luminance() > 0.45f) Color.Black else Color.White
+ Card(
+ colors = CardDefaults.cardColors(containerColor = background, contentColor = foreground),
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ if (profile.flagEmoji.isNotEmpty()) {
+ Surface(
+ color = Color.White, contentColor = Color.Black,
+ shape = RoundedCornerShape(6.dp),
+ border = BorderStroke(1.dp, Color.Black.copy(alpha = 0.45f)),
+ ) { Text(profile.flagEmoji, modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp)) }
+ }
+ Column(Modifier.weight(1f)) {
+ Text(profile.displayName, style = MaterialTheme.typography.titleMedium)
+ Text("${profile.config.host}:${profile.config.port}", style = MaterialTheme.typography.bodySmall)
+ }
+ Surface(
+ color = foreground.copy(alpha = 0.16f),
+ contentColor = foreground,
+ shape = RoundedCornerShape(50),
+ border = BorderStroke(1.dp, foreground.copy(alpha = 0.55f)),
+ ) {
+ Text(
+ when (profile.config.type) {
+ ProxyType.HTTPS -> "HTTPS"
+ ProxyType.SSH -> "SSH"
+ ProxyType.SSH_JUMP -> "SSH + Jump"
+ },
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
+ )
+ }
+ }
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ TextButton(onClick = onConfigure) { Text("Configure", color = foreground) }
+ TextButton(onClick = onClone) { Text("Clone", color = foreground) }
+ TextButton(onClick = onDelete) { Text("Delete", color = foreground) }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/SettingsActivities.kt b/app/src/main/java/net/megaproxy487/SettingsActivities.kt
new file mode 100644
index 0000000..7c85ce0
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/SettingsActivities.kt
@@ -0,0 +1,544 @@
+package net.megaproxy487
+
+import android.app.Activity
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.os.PowerManager
+import android.provider.Settings
+import androidx.activity.ComponentActivity
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.compose.setContent
+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.ColumnScope
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.union
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.selection.toggleable
+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.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.MenuAnchorType
+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.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import net.megaproxy487.data.ConfigStore
+import net.megaproxy487.data.ConfigIoDispatcher
+import net.megaproxy487.model.Ja3Spec
+import net.megaproxy487.model.TlsProfile
+import net.megaproxy487.model.SshProfile
+import net.megaproxy487.model.SshAuthMode
+import net.megaproxy487.model.FailoverMode
+import net.megaproxy487.vpn.ProxyVpnService
+import net.megaproxy487.vpn.VpnRuntimeState
+import net.megaproxy487.vpn.readAlwaysOnVpnStatus
+import net.megaproxy487.ui.theme.MegaProxyTheme
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class ProxySettingsActivity : LocalizedActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent { MegaProxyTheme { SettingsHomeScreen(this) } }
+ }
+}
+
+class AlwaysOnSettingsActivity : LocalizedActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent { MegaProxyTheme { AlwaysOnSettingsScreen(this) } }
+ }
+}
+
+class TlsFingerprintActivity : LocalizedActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent { MegaProxyTheme { TlsFingerprintScreen(this) } }
+ }
+}
+
+class FailoverSettingsActivity : LocalizedActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent { MegaProxyTheme { FailoverSettingsScreen(this) } }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SettingsHomeScreen(activity: Activity) {
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val powerManager = remember { activity.getSystemService(PowerManager::class.java) }
+ var batteryOptimizationDisabled by remember {
+ mutableStateOf(powerManager.isIgnoringBatteryOptimizations(activity.packageName))
+ }
+ var showLanguageDialog by remember { mutableStateOf(false) }
+ var supportError by remember { mutableStateOf(null) }
+ val scope = rememberCoroutineScope()
+
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ batteryOptimizationDisabled = powerManager.isIgnoringBatteryOptimizations(activity.packageName)
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ SettingsScaffold(activity, stringResource(R.string.settings)) {
+ Text(stringResource(R.string.connection), style = MaterialTheme.typography.titleMedium)
+ SettingsButton(stringResource(R.string.profiles), stringResource(R.string.profiles_description)) { activity.startActivity(Intent(activity, ProfilesActivity::class.java)) }
+ SettingsButton(stringResource(R.string.always_on_vpn), stringResource(R.string.always_on_description)) { activity.startActivity(Intent(activity, AlwaysOnSettingsActivity::class.java)) }
+ SettingsButton(stringResource(R.string.fingerprints), stringResource(R.string.fingerprints_description)) { activity.startActivity(Intent(activity, TlsFingerprintActivity::class.java)) }
+ SettingsButton(stringResource(R.string.split_tunneling), stringResource(R.string.split_tunneling_description)) { activity.startActivity(Intent(activity, SplitTunnelActivity::class.java)) }
+ SettingsButton(stringResource(R.string.failover), stringResource(R.string.failover_description)) { activity.startActivity(Intent(activity, FailoverSettingsActivity::class.java)) }
+ Text(stringResource(R.string.diagnostics), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
+ SettingsButton(stringResource(R.string.visibility), stringResource(R.string.visibility_description)) { activity.startActivity(Intent(activity, VisibilityActivity::class.java)) }
+ if (!batteryOptimizationDisabled) {
+ SettingsButton(stringResource(R.string.battery_settings), stringResource(R.string.battery_settings_description)) {
+ activity.startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
+ data = Uri.parse("package:${activity.packageName}")
+ })
+ }
+ }
+ SettingsButton(stringResource(R.string.diagnostic_log), stringResource(R.string.diagnostic_log_description)) { activity.startActivity(Intent(activity, DiagnosticLogActivity::class.java)) }
+ Text(stringResource(R.string.appearance_and_language), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
+ SettingsButton(
+ stringResource(R.string.language),
+ "${stringResource(R.string.language_description)} · ${if (AppLanguageManager.current(activity) == AppLanguage.RUSSIAN) stringResource(R.string.language_russian) else stringResource(R.string.language_english)}",
+ ) { showLanguageDialog = true }
+ Text(stringResource(R.string.support), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
+ SettingsButton(stringResource(R.string.feedback), stringResource(R.string.feedback_description)) {
+ scope.launch {
+ runCatching {
+ val status = readAlwaysOnVpnStatus(activity)
+ val intent = withContext(Dispatchers.IO) {
+ FeedbackEmail.createIntent(
+ activity,
+ VpnRuntimeState.connection.value,
+ VpnRuntimeState.alwaysOn.value || status.enabled,
+ VpnRuntimeState.lockdown.value || status.lockdown,
+ )
+ }
+ activity.startActivity(intent)
+ }.onFailure { supportError = it.message ?: "Could not open an email client" }
+ }
+ }
+ SettingsButton(stringResource(R.string.github_issues), stringResource(R.string.github_issues_description)) {
+ runCatching {
+ activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/andre487/AndroidMegaProxy/issues")))
+ }.onFailure { supportError = activity.getString(R.string.no_browser) }
+ }
+ supportError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
+ Text(
+ stringResource(R.string.version_and_commit, BuildConfig.VERSION_NAME, BuildConfig.GIT_COMMIT_HASH),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp),
+ )
+ }
+
+ if (showLanguageDialog) {
+ val selected = AppLanguageManager.current(activity)
+ AlertDialog(
+ onDismissRequest = { showLanguageDialog = false },
+ title = { Text(stringResource(R.string.language)) },
+ text = {
+ Column {
+ AppLanguage.entries.forEach { language ->
+ val label = if (language == AppLanguage.RUSSIAN) {
+ stringResource(R.string.language_russian)
+ } else {
+ stringResource(R.string.language_english)
+ }
+ Row(
+ Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable(
+ value = selected == language,
+ role = Role.RadioButton,
+ onValueChange = {
+ AppLanguageManager.set(activity, language)
+ showLanguageDialog = false
+ activity.recreate()
+ },
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ androidx.compose.material3.RadioButton(selected == language, null)
+ Text(label)
+ }
+ }
+ }
+ },
+ confirmButton = {},
+ dismissButton = { TextButton(onClick = { showLanguageDialog = false }) { Text(stringResource(android.R.string.cancel)) } },
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun FailoverSettingsScreen(activity: Activity) {
+ val store = remember { ConfigStore(activity) }
+ val scope = rememberCoroutineScope()
+ var settings by remember { mutableStateOf(store.globalConnectionSettings()) }
+ var expanded by remember { mutableStateOf(false) }
+ var pendingAll by remember { mutableStateOf(false) }
+ val alwaysOn = remember { ProxyVpnService.isAlwaysOnMode || readAlwaysOnVpnStatus(activity).enabled }
+ fun save(mode: FailoverMode = settings.failoverMode, ids: List = settings.failoverProfileIds) {
+ settings = settings.copy(failoverMode = mode, failoverProfileIds = ids)
+ val snapshot = settings
+ scope.launch(ConfigIoDispatcher) { store.saveGlobalConnectionSettings(snapshot) }
+ }
+ SettingsScaffold(activity, "Failover") {
+ ExposedDropdownMenuBox(expanded, { expanded = it }) {
+ OutlinedTextField(settings.failoverMode.title, {}, readOnly = true, label = { Text("Failover mode") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth())
+ DropdownMenu(expanded, { expanded = false }) {
+ FailoverMode.entries.forEach { mode -> DropdownMenuItem(text = { Text(mode.title) }, onClick = {
+ expanded = false
+ if (mode == FailoverMode.ALL) pendingAll = true else save(mode = mode)
+ }) }
+ }
+ }
+ Text("Failover is only triggered after repeated timeout, reset or silent-drop signals. Authentication, host-key, certificate and configuration errors never trigger it.", style = MaterialTheme.typography.bodySmall)
+ if (settings.failoverMode == FailoverMode.SELECTED) {
+ Text("Fallback profiles", style = MaterialTheme.typography.titleMedium)
+ Text("Profiles are tried in the same order as the Profiles screen.", style = MaterialTheme.typography.bodySmall)
+ store.sortedProfiles().forEach { profile ->
+ val checked = profile.id in settings.failoverProfileIds
+ Row(
+ Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable(
+ value = checked,
+ onValueChange = { checkedValue ->
+ val ids = if (checkedValue) settings.failoverProfileIds + profile.id else settings.failoverProfileIds - profile.id
+ save(ids = ids)
+ },
+ role = Role.Checkbox,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(checked, null)
+ Text(profile.displayNameWithFlag)
+ }
+ }
+ val usableFallbacks = store.sortedProfiles().count { it.id in settings.failoverProfileIds }
+ if (usableFallbacks < 2) {
+ Text(
+ "Select at least two profiles. With fewer profiles, failover may have nowhere to switch.",
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ if (settings.failoverMode != FailoverMode.DISABLED) {
+ Text("Warning: switching profiles may change the apparent country and public exit IP.", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
+ if (alwaysOn) Text(
+ if (settings.failoverMode == FailoverMode.ALL)
+ "Always-on VPN remains active during automatic switching. Every switch is reported in the persistent notification and on the main screen."
+ else
+ "Always-on VPN remains active during automatic switching. The persistent notification and the main screen show the profile actually in use.",
+ color = MaterialTheme.colorScheme.tertiary,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ if (pendingAll) AlertDialog(
+ onDismissRequest = { pendingAll = false },
+ title = { Text("Enable global failover?") },
+ text = { Text("MegaProxy will automatically try any configured profile when blocking is suspected. This can change your location and public exit IP. Individual switches will not require confirmation.") },
+ confirmButton = { TextButton(onClick = { pendingAll = false; save(mode = FailoverMode.ALL) }) { Text("Enable") } },
+ dismissButton = { TextButton(onClick = { pendingAll = false }) { Text("Cancel") } },
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AlwaysOnSettingsScreen(activity: Activity) {
+ val store = remember { ConfigStore(activity) }
+ val scope = rememberCoroutineScope()
+ var expanded by remember { mutableStateOf(false) }
+ var selected by remember { mutableStateOf(store.alwaysOnProfile()) }
+ val alwaysOnActive = remember { ProxyVpnService.isAlwaysOnMode || readAlwaysOnVpnStatus(activity).enabled }
+ SettingsScaffold(activity, "Always-on VPN") {
+ ExposedDropdownMenuBox(expanded, { expanded = it }) {
+ OutlinedTextField(
+ selected.displayNameWithFlag, {}, readOnly = true,
+ label = { Text("Profile for Always-on VPN") },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
+ modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(),
+ )
+ DropdownMenu(expanded, { expanded = false }) {
+ store.sortedProfiles().forEach { profile ->
+ DropdownMenuItem(text = { Text(profile.displayNameWithFlag) }, onClick = {
+ selected = profile
+ scope.launch(ConfigIoDispatcher) { store.setAlwaysOnProfile(profile.id) }
+ expanded = false
+ if (alwaysOnActive && ProxyVpnService.isRunning) {
+ ProxyVpnService.switchProfile(activity, profile.id, true)
+ }
+ })
+ }
+ }
+ }
+ Text("This profile is used when Android starts MegaProxy as an Always-on VPN.", style = MaterialTheme.typography.bodySmall)
+ if (alwaysOnActive) {
+ Text("Always-on is active. Selecting another profile reconnects immediately.", color = MaterialTheme.colorScheme.tertiary, style = MaterialTheme.typography.bodySmall)
+ }
+ SettingsButton("Open Android Always-on VPN settings") {
+ activity.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun TlsFingerprintScreen(activity: Activity) {
+ val store = remember { ConfigStore(activity) }
+ val scope = rememberCoroutineScope()
+ var settings by remember { mutableStateOf(store.globalConnectionSettings()) }
+ var expanded by remember { mutableStateOf(false) }
+ var sshExpanded by remember { mutableStateOf(false) }
+ var sshAuthExpanded by remember { mutableStateOf(false) }
+ var error by remember { mutableStateOf(null) }
+ var showReconnectPrompt by remember { mutableStateOf(false) }
+ var showAlwaysOnNotice by remember { mutableStateOf(false) }
+ var deferred by remember { mutableStateOf(false) }
+
+ fun saveSettings(updated: net.megaproxy487.model.GlobalConnectionSettings) {
+ settings = updated
+ val snapshot = settings
+ scope.launch(ConfigIoDispatcher) {
+ store.saveGlobalConnectionSettings(snapshot)
+ if (ProxyVpnService.isRunning) store.markPendingReconnect()
+ }
+ if (ProxyVpnService.isRunning && !deferred) {
+ if (ProxyVpnService.isAlwaysOnMode || readAlwaysOnVpnStatus(activity).enabled) {
+ deferred = true; showAlwaysOnNotice = true
+ } else showReconnectPrompt = true
+ }
+ }
+
+ fun save(
+ profile: TlsProfile = settings.tlsProfile,
+ customJa3: String = settings.customJa3,
+ sshProfile: SshProfile = settings.sshProfile,
+ ) {
+ saveSettings(settings.copy(tlsProfile = profile, customJa3 = customJa3, sshProfile = sshProfile))
+ error = if (profile == TlsProfile.CUSTOM && Ja3Spec.parse(customJa3) == null) {
+ "JA3 must contain five fields: version,ciphers,extensions,groups,points"
+ } else null
+ }
+
+ SettingsScaffold(activity, "Fingerprints") {
+ Text("HTTPS fingerprint", style = MaterialTheme.typography.titleMedium)
+ ExposedDropdownMenuBox(expanded, { expanded = it }) {
+ OutlinedTextField(
+ settings.tlsProfile.title, {}, readOnly = true,
+ label = { Text("HTTPS TLS / JA3 profile") },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
+ modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(),
+ )
+ DropdownMenu(expanded, { expanded = false }) {
+ TlsProfile.entries.filter { it.available }.forEach { profile ->
+ DropdownMenuItem(text = { Text(profile.title) }, onClick = { save(profile = profile); expanded = false })
+ }
+ }
+ }
+ Text("Default currently uses the Chrome Android fingerprint and may change in a future MegaProxy update.", style = MaterialTheme.typography.bodySmall)
+ if (settings.tlsProfile == TlsProfile.CUSTOM) {
+ OutlinedTextField(
+ settings.customJa3,
+ { if (it.length <= 8 * 1024) save(customJa3 = it) },
+ label = { Text("JA3: version,ciphers,extensions,groups,points") },
+ minLines = 2,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
+ Text("SSH fingerprint", style = MaterialTheme.typography.titleMedium)
+ ExposedDropdownMenuBox(sshExpanded, { sshExpanded = it }) {
+ OutlinedTextField(
+ settings.sshProfile.title, {}, readOnly = true,
+ label = { Text("SSH client profile") },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(sshExpanded) },
+ modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(),
+ )
+ DropdownMenu(sshExpanded, { sshExpanded = false }) {
+ SshProfile.entries.forEach { profile ->
+ DropdownMenuItem(text = { Text(profile.title) }, onClick = {
+ save(sshProfile = profile)
+ sshExpanded = false
+ })
+ }
+ }
+ }
+ ExposedDropdownMenuBox(sshAuthExpanded, { sshAuthExpanded = it }) {
+ OutlinedTextField(
+ settings.sshAuthMode.title, {}, readOnly = true,
+ label = { Text("SSH authentication") },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(sshAuthExpanded) },
+ modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(),
+ )
+ DropdownMenu(sshAuthExpanded, { sshAuthExpanded = false }) {
+ SshAuthMode.entries.forEach { mode -> DropdownMenuItem(text = { Text(mode.title) }, onClick = {
+ saveSettings(settings.copy(sshAuthMode = mode))
+ sshAuthExpanded = false
+ }) }
+ }
+ }
+ IntegerSettingField(settings.sshKeepaliveSeconds, 0..3600, "SSH keepalive, seconds (0 = disabled)") {
+ saveSettings(settings.copy(sshKeepaliveSeconds = it))
+ }
+ IntegerSettingField(settings.sshMaxChannels, 1..256, "Maximum parallel SSH channels") {
+ saveSettings(settings.copy(sshMaxChannels = it))
+ }
+ IntegerSettingField(settings.sshRotationMinutes, 0..1440, "Rotate SSH session after minutes (0 = disabled)") {
+ saveSettings(settings.copy(sshRotationMinutes = it))
+ }
+ IntegerSettingField(settings.sshRotationMb, 0..10240, "Rotate SSH session after MB (0 = disabled)") {
+ saveSettings(settings.copy(sshRotationMb = it))
+ }
+ Text("The SSH profile controls the client banner and preferred KEX, cipher and MAC ordering.", style = MaterialTheme.typography.bodySmall)
+ if (showReconnectPrompt) {
+ Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) {
+ Column(Modifier.fillMaxWidth().padding(12.dp)) {
+ Text("Reconnect to apply these changes to the active VPN.")
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ TextButton(onClick = { showReconnectPrompt = false; deferred = true }) { Text("Next connection") }
+ TextButton(onClick = {
+ showReconnectPrompt = false
+ deferred = true
+ ProxyVpnService.reconnect(activity)
+ }) { Text("Reconnect now") }
+ }
+ }
+ }
+ }
+ if (showAlwaysOnNotice) {
+ Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) {
+ Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text("Always-on is active. Changes apply on the next connection.", modifier = Modifier.weight(1f))
+ TextButton(onClick = { showAlwaysOnNotice = false }) { Text("Dismiss") }
+ }
+ }
+ }
+ Text("Changes are saved automatically and apply to every profile.", style = MaterialTheme.typography.bodySmall)
+ }
+}
+
+@Composable
+private fun IntegerSettingField(initialValue: Int, range: IntRange, label: String, onValidValue: (Int) -> Unit) {
+ var text by remember { mutableStateOf(initialValue.toString()) }
+ val parsed = text.toIntOrNull()
+ OutlinedTextField(
+ value = text,
+ onValueChange = { value ->
+ if (value.length <= range.last.toString().length && value.all(Char::isDigit)) {
+ text = value
+ value.toIntOrNull()?.takeIf { it in range }?.let(onValidValue)
+ }
+ },
+ label = { Text(label) },
+ supportingText = if (text.isNotEmpty() && parsed !in range) {
+ { Text("Allowed range: ${range.first}–${range.last}") }
+ } else null,
+ isError = text.isNotEmpty() && parsed !in range,
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SettingsScaffold(activity: Activity, title: String, content: @Composable ColumnScope.() -> Unit) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(title) },
+ navigationIcon = {
+ IconButton(onClick = { activity.finish() }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
+ }
+ },
+ )
+ },
+ contentWindowInsets = WindowInsets.systemBars.union(WindowInsets.ime),
+ ) { padding ->
+ Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.TopCenter) {
+ Column(
+ Modifier
+ .widthIn(max = 840.dp)
+ .fillMaxWidth()
+ .fillMaxHeight()
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ content = content,
+ )
+ }
+ }
+}
+
+@Composable
+private fun SettingsButton(label: String, description: String? = null, onClick: () -> Unit) {
+ Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(label, style = MaterialTheme.typography.titleSmall)
+ description?.let {
+ Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null)
+ }
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/SplitTunnelActivity.kt b/app/src/main/java/net/megaproxy487/SplitTunnelActivity.kt
new file mode 100644
index 0000000..5c799c7
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/SplitTunnelActivity.kt
@@ -0,0 +1,263 @@
+package net.megaproxy487
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.union
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Checkbox
+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.OutlinedTextField
+import androidx.compose.material3.RadioButton
+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.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.res.stringResource
+import net.megaproxy487.data.ConfigStore
+import net.megaproxy487.data.ConfigIoDispatcher
+import net.megaproxy487.model.GlobalConnectionSettings
+import net.megaproxy487.vpn.ProxyVpnService
+import net.megaproxy487.vpn.readAlwaysOnVpnStatus
+import net.megaproxy487.ui.theme.MegaProxyTheme
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class SplitTunnelActivity : LocalizedActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent { MegaProxyTheme { SplitTunnelScreen(this) } }
+ }
+}
+
+private data class AppItem(val packageName: String, val label: String)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SplitTunnelScreen(activity: SplitTunnelActivity) {
+ val store = remember { ConfigStore(activity) }
+ var settings by remember { mutableStateOf(store.globalConnectionSettings()) }
+ var appSearch by remember { mutableStateOf("") }
+ var showReconnectPrompt by remember { mutableStateOf(false) }
+ var showAlwaysOnDeferredNotice by remember { mutableStateOf(false) }
+ var deferChangesUntilNextConnection by remember { mutableStateOf(false) }
+ val coroutineScope = rememberCoroutineScope()
+ val alwaysOnActive = remember { ProxyVpnService.isAlwaysOnMode || readAlwaysOnVpnStatus(activity).enabled }
+
+ fun updateSettings(updated: GlobalConnectionSettings) {
+ val selectedPackagesOnly = settings.selectedPackages != updated.selectedPackages &&
+ settings.copy(selectedPackages = updated.selectedPackages) == updated
+ val affectsActiveRouting = !(settings.routeAllApps && updated.routeAllApps && selectedPackagesOnly)
+ settings = updated
+ coroutineScope.launch(ConfigIoDispatcher) {
+ store.saveGlobalConnectionSettings(updated)
+ if (ProxyVpnService.isRunning && affectsActiveRouting) store.markPendingReconnect()
+ }
+ if (ProxyVpnService.isRunning && affectsActiveRouting && !deferChangesUntilNextConnection) {
+ if (alwaysOnActive) {
+ deferChangesUntilNextConnection = true
+ showAlwaysOnDeferredNotice = true
+ } else {
+ showReconnectPrompt = true
+ }
+ }
+ }
+
+ var apps by remember(activity) { mutableStateOf?>(null) }
+ LaunchedEffect(activity) {
+ apps = withContext(Dispatchers.IO) {
+ val launcher = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
+ activity.packageManager.queryIntentActivities(launcher, 0)
+ .map { AppItem(it.activityInfo.packageName, it.loadLabel(activity.packageManager).toString()) }
+ .distinctBy { it.packageName }
+ .sortedBy { it.label.lowercase() }
+ }
+ }
+ val visibleApps = remember(apps, appSearch) {
+ val query = appSearch.trim()
+ val availableApps = apps.orEmpty()
+ if (query.isEmpty()) availableApps else availableApps.filter {
+ it.label.contains(query, ignoreCase = true) || it.packageName.contains(query, ignoreCase = true)
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.split_tunneling)) },
+ navigationIcon = {
+ IconButton(onClick = { activity.finish() }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ )
+ },
+ bottomBar = {
+ if (showReconnectPrompt || showAlwaysOnDeferredNotice) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
+ modifier = Modifier.fillMaxWidth().padding(12.dp),
+ ) {
+ Column(Modifier.fillMaxWidth().padding(12.dp)) {
+ Text(
+ if (showAlwaysOnDeferredNotice) "Always-on is active. Routing changes apply on the next connection."
+ else "Reconnect to apply routing changes to the active VPN.",
+ )
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ TextButton(onClick = {
+ showReconnectPrompt = false
+ showAlwaysOnDeferredNotice = false
+ deferChangesUntilNextConnection = true
+ }) { Text(if (showAlwaysOnDeferredNotice) "Dismiss" else "Next connection") }
+ if (showReconnectPrompt) {
+ TextButton(onClick = {
+ showReconnectPrompt = false
+ deferChangesUntilNextConnection = true
+ ProxyVpnService.reconnect(activity)
+ }) { Text("Reconnect now") }
+ }
+ }
+ }
+ }
+ }
+ },
+ // Use one inset source. Applying safeDrawing in Scaffold and imePadding to
+ // its child can double-apply or misreport the IME area on some OEM builds.
+ contentWindowInsets = WindowInsets.systemBars.union(WindowInsets.ime),
+ ) { padding ->
+ LazyColumn(
+ Modifier
+ .fillMaxSize()
+ .padding(padding),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ item { Text(stringResource(R.string.routing_mode), style = MaterialTheme.typography.titleMedium) }
+ item {
+ Column(Modifier.selectableGroup()) {
+ Row(
+ Modifier.fillMaxWidth().heightIn(min = 56.dp).selectable(
+ selected = !settings.routeAllApps,
+ onClick = { updateSettings(settings.copy(routeAllApps = false)) },
+ role = Role.RadioButton,
+ ),
+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
+ ) {
+ RadioButton(!settings.routeAllApps, null)
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(R.string.split_tunneling))
+ Text("Only selected applications use the proxy.", style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ Row(
+ Modifier.fillMaxWidth().heightIn(min = 56.dp).selectable(
+ selected = settings.routeAllApps,
+ onClick = { updateSettings(settings.copy(routeAllApps = true)) },
+ role = Role.RadioButton,
+ ),
+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
+ ) {
+ RadioButton(settings.routeAllApps, null)
+ Column(Modifier.weight(1f)) {
+ Text("Global VPN")
+ Text("All applications use the proxy. This is the default mode.", style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+ }
+ item { Text("UDP and QUIC are blocked; DNS uses the profile's configured DoH endpoint.", style = MaterialTheme.typography.bodySmall) }
+ item {
+ Row(
+ Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable(
+ value = settings.bypassLocalNetworks,
+ onValueChange = { updateSettings(settings.copy(bypassLocalNetworks = it)) },
+ role = Role.Checkbox,
+ ),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text("Bypass local networks")
+ Text("Direct TCP access to private and link-local IP addresses.", style = MaterialTheme.typography.bodySmall)
+ }
+ Checkbox(settings.bypassLocalNetworks, null)
+ }
+ }
+ if (!settings.routeAllApps) {
+ item { Text(stringResource(R.string.applications), style = MaterialTheme.typography.titleMedium) }
+ if (apps == null) item {
+ Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.Center) {
+ CircularProgressIndicator()
+ }
+ }
+ item {
+ OutlinedTextField(
+ value = appSearch,
+ onValueChange = { appSearch = it },
+ label = { Text("Search applications") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ items(visibleApps, key = { it.packageName }) { app ->
+ val checked = app.packageName in settings.selectedPackages
+ Row(
+ Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable(
+ value = checked,
+ onValueChange = { checkedValue ->
+ updateSettings(settings.copy(selectedPackages = if (checked) {
+ if (checkedValue) settings.selectedPackages else settings.selectedPackages - app.packageName
+ } else {
+ if (checkedValue) settings.selectedPackages + app.packageName else settings.selectedPackages
+ }))
+ },
+ role = Role.Checkbox,
+ ),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
+ ) {
+ Text(app.label, Modifier.weight(1f))
+ Checkbox(checked, null)
+ }
+ }
+ }
+ item { Text(stringResource(R.string.changes_saved_automatically), style = MaterialTheme.typography.bodySmall) }
+ }
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/SshHostKeyActivity.kt b/app/src/main/java/net/megaproxy487/SshHostKeyActivity.kt
new file mode 100644
index 0000000..c53b170
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/SshHostKeyActivity.kt
@@ -0,0 +1,61 @@
+package net.megaproxy487
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.compose.setContent
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import net.megaproxy487.ui.theme.MegaProxyTheme
+import net.megaproxy487.data.ConfigStore
+import net.megaproxy487.vpn.ProxyVpnService
+
+class SshHostKeyActivity : LocalizedActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ val profileId = intent.getStringExtra(EXTRA_PROFILE_ID).orEmpty()
+ val hop = intent.getStringExtra(EXTRA_HOP).orEmpty()
+ val algorithm = intent.getStringExtra(EXTRA_ALGORITHM).orEmpty()
+ val fingerprint = intent.getStringExtra(EXTRA_FINGERPRINT).orEmpty()
+ val changed = intent.getBooleanExtra(EXTRA_CHANGED, false)
+ val testOnly = intent.getBooleanExtra(EXTRA_TEST_ONLY, false)
+ fun reject() {
+ // A normal/Always-on connection remains paused with an actionable persistent
+ // notification. Tests are disposable and may release their temporary service.
+ if (testOnly) ProxyVpnService.dismissHostKeyPrompt(this)
+ finish()
+ }
+ setContent {
+ MegaProxyTheme {
+ AlertDialog(
+ onDismissRequest = ::reject,
+ title = { Text(if (changed) "SSH host key changed" else "Trust SSH host key?") },
+ text = { Text(buildString {
+ if (changed) append("Warning: the previously trusted key for the $hop host has changed. This can indicate a server reinstall or a man-in-the-middle attack. Verify the fingerprint through a trusted channel before replacing it.\n\n")
+ else append("This is the first connection to the $hop SSH host. Verify its fingerprint through a trusted channel.\n\n")
+ append("Algorithm: $algorithm\nFingerprint: $fingerprint")
+ }) },
+ confirmButton = { TextButton(onClick = {
+ if (ConfigStore(this).trustSshHostKey(profileId, hop, fingerprint)) {
+ if (testOnly) ProxyVpnService.test(this) else ProxyVpnService.reconnect(this)
+ }
+ finish()
+ }) { Text(if (changed) "Replace trusted key" else "Trust and connect") } },
+ dismissButton = { TextButton(onClick = ::reject) { Text("Cancel") } },
+ )
+ }
+ }
+ }
+
+ companion object {
+ const val EXTRA_PROFILE_ID = "profile_id"
+ const val EXTRA_HOP = "hop"
+ const val EXTRA_ALGORITHM = "algorithm"
+ const val EXTRA_FINGERPRINT = "fingerprint"
+ const val EXTRA_CHANGED = "changed"
+ const val EXTRA_TEST_ONLY = "test_only"
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/VisibilityActivity.kt b/app/src/main/java/net/megaproxy487/VisibilityActivity.kt
new file mode 100644
index 0000000..c6c3c59
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/VisibilityActivity.kt
@@ -0,0 +1,223 @@
+package net.megaproxy487
+
+import android.app.Activity
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+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.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.res.stringResource
+import net.megaproxy487.ui.theme.MegaProxyTheme
+import net.megaproxy487.data.ConfigStore
+import java.net.NetworkInterface
+
+class VisibilityActivity : LocalizedActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent { MegaProxyTheme { VisibilityScreen(this) } }
+ }
+}
+
+private enum class VisibilityState(val label: String) {
+ DETECTED("Detected"),
+ SELECTED_APPS_ONLY("Selected apps only"),
+ NOT_DETECTED("Not detected"),
+}
+
+private data class VisibilityCheck(
+ val title: String,
+ val state: VisibilityState,
+ val detail: String,
+)
+
+private data class VisibilityReport(
+ val global: List,
+ val selectedApps: List,
+ val splitTunneling: Boolean,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun VisibilityScreen(activity: Activity) {
+ var report by remember { mutableStateOf(buildVisibilityReport(activity)) }
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.visibility)) },
+ navigationIcon = {
+ IconButton(onClick = activity::finish) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ )
+ },
+ contentWindowInsets = WindowInsets.safeDrawing,
+ ) { padding ->
+ Column(
+ Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ "Local checks show signals that another Android app may observe. No network request, location lookup, or external reputation service is used.",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Button(onClick = { report = buildVisibilityReport(activity) }, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.run_checks_again))
+ }
+ VisibilitySection("Globally observable signals", report.global)
+ if (report.splitTunneling) {
+ Text(
+ "The signals below belong to the VPN network. With split tunneling, Android exposes that network to selected apps; excluded apps use the underlying network instead.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ VisibilitySection(
+ if (report.splitTunneling) "Visible to selected apps" else "VPN network signals",
+ report.selectedApps,
+ )
+ Text(
+ "A detected signal is not proof by itself. Android VPN APIs intentionally expose some VPN state, and legitimate security or filtering apps can produce the same signals.",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(bottom = 16.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun VisibilitySection(title: String, checks: List) {
+ Text(title, style = MaterialTheme.typography.titleMedium)
+ checks.forEach { check ->
+ Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
+ Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(check.title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f))
+ VisibilityBadge(check.state)
+ }
+ Text(check.detail, style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+}
+
+@Composable
+private fun VisibilityBadge(state: VisibilityState) {
+ val background = when (state) {
+ VisibilityState.DETECTED -> Color(0xFFB3261E)
+ VisibilityState.SELECTED_APPS_ONLY -> Color(0xFFF9A825)
+ VisibilityState.NOT_DETECTED -> Color(0xFF2E7D32)
+ }
+ val foreground = if (state == VisibilityState.SELECTED_APPS_ONLY) Color.Black else Color.White
+ Surface(color = background, contentColor = foreground, shape = RoundedCornerShape(12.dp)) {
+ Text(state.label, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp))
+ }
+}
+
+private fun buildVisibilityReport(context: Context): VisibilityReport {
+ val connectivity = context.getSystemService(ConnectivityManager::class.java)
+ val networks = runCatching { connectivity.allNetworks.toList() }.getOrDefault(emptyList())
+ val capabilities = networks.mapNotNull { network ->
+ runCatching { connectivity.getNetworkCapabilities(network) }.getOrNull()
+ }
+ val vpnCapabilities = capabilities.filter { it.hasTransport(NetworkCapabilities.TRANSPORT_VPN) }
+ val vpnTransportVisible = vpnCapabilities.isNotEmpty()
+ val vpnInfoVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && vpnCapabilities.any {
+ runCatching { it.transportInfo?.javaClass?.simpleName == "VpnTransportInfo" }.getOrDefault(false)
+ }
+ val vpnWithoutNotVpn = vpnCapabilities.any { !it.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) }
+
+ val linkProperties = networks.mapNotNull { network ->
+ runCatching { connectivity.getLinkProperties(network) }.getOrNull()
+ }
+ val linkProxyVisible = linkProperties.any { it.httpProxy != null }
+ val systemProxyVisible = listOf("http.proxyHost", "https.proxyHost", "socksProxyHost").any {
+ System.getProperty(it).orEmpty().isNotBlank()
+ }
+
+ val interfaces = runCatching {
+ NetworkInterface.getNetworkInterfaces()?.toList().orEmpty().filter { it.isUp }
+ }.getOrDefault(emptyList())
+ val vpnName = Regex("^(tun|tap|wg|ppp|ipsec)", RegexOption.IGNORE_CASE)
+ val vpnInterfaces = interfaces.filter { vpnName.containsMatchIn(it.name) }
+ val unusualMtu = vpnInterfaces.filter { it.mtu !in setOf(1280, 1500) }
+ val virtualDefaultRoute = linkProperties.any { properties ->
+ properties.routes.any { it.isDefaultRoute && vpnName.containsMatchIn(it.`interface`.orEmpty()) }
+ }
+ val virtualDns = linkProperties.any { properties ->
+ properties.dnsServers.any { address ->
+ address.isSiteLocalAddress || address.isLinkLocalAddress || address.isLoopbackAddress
+ }
+ }
+ val settings = ConfigStore(context).globalConnectionSettings()
+ val splitTunneling = !settings.routeAllApps
+
+ fun check(title: String, detected: Boolean, yes: String, no: String) = VisibilityCheck(
+ title,
+ if (detected) VisibilityState.DETECTED else VisibilityState.NOT_DETECTED,
+ if (detected) yes else no,
+ )
+ fun vpnCheck(title: String, detected: Boolean, yes: String, no: String) = VisibilityCheck(
+ title,
+ when {
+ !detected -> VisibilityState.NOT_DETECTED
+ splitTunneling -> VisibilityState.SELECTED_APPS_ONLY
+ else -> VisibilityState.DETECTED
+ },
+ if (detected && splitTunneling) "$yes Excluded apps use their underlying network." else if (detected) yes else no,
+ )
+
+ return VisibilityReport(
+ global = listOf(
+ check("System proxy properties", systemProxyVisible, "A process-wide proxy property is visible.", "No process-wide proxy property is visible."),
+ check("VPN-like interface name", vpnInterfaces.isNotEmpty(), "An active interface has a tun, tap, wg, ppp, or ipsec-style name.", "No active interface has a common VPN-style name."),
+ check("Unusual VPN MTU", unusualMtu.isNotEmpty(), "A VPN-style interface uses an MTU other than 1280 or 1500.", "No unusual MTU was found on VPN-style interfaces."),
+ VisibilityCheck("Local MITM certificate", VisibilityState.NOT_DETECTED, "MegaProxy installs no CA certificate and performs no TLS interception."),
+ ),
+ selectedApps = listOf(
+ vpnCheck("VPN transport", vpnTransportVisible, "Android exposes a network with TRANSPORT_VPN.", "No VPN transport is currently exposed."),
+ vpnCheck("VPN transport information", vpnInfoVisible, "Android exposes VpnTransportInfo for the VPN network.", "VpnTransportInfo is not exposed on the currently visible networks."),
+ vpnCheck("NOT_VPN capability", vpnWithoutNotVpn, "The VPN network does not have NET_CAPABILITY_NOT_VPN.", "No visible network is identified by this capability check as a VPN."),
+ vpnCheck("VPN link proxy", linkProxyVisible, "A proxy setting is attached to the VPN network.", "No proxy setting is attached to the VPN network."),
+ vpnCheck("Virtual default route", virtualDefaultRoute, "A visible default route uses a VPN-style interface.", "No visible default route uses a VPN-style interface."),
+ vpnCheck("Virtual DNS address", virtualDns, "The VPN network exposes a private, link-local, or loopback DNS address.", "The VPN network does not expose a local DNS address."),
+ ),
+ splitTunneling = splitTunneling,
+ )
+}
diff --git a/app/src/main/java/net/megaproxy487/data/ConfigIo.kt b/app/src/main/java/net/megaproxy487/data/ConfigIo.kt
new file mode 100644
index 0000000..d6d75b9
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/data/ConfigIo.kt
@@ -0,0 +1,10 @@
+package net.megaproxy487.data
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.asCoroutineDispatcher
+import java.util.concurrent.Executors
+
+/** Ordered background dispatcher for configuration encryption, JSON and persistence. */
+val ConfigIoDispatcher: CoroutineDispatcher = Executors.newSingleThreadExecutor { task ->
+ Thread(task, "megaproxy-config-io").apply { isDaemon = true }
+}.asCoroutineDispatcher()
diff --git a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt
new file mode 100644
index 0000000..59a8105
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt
@@ -0,0 +1,534 @@
+package net.megaproxy487.data
+
+import android.content.Context
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import net.megaproxy487.model.DnsProvider
+import net.megaproxy487.model.ProfileColors
+import net.megaproxy487.model.ProfileColorMatcher
+import net.megaproxy487.model.ProxyConfig
+import net.megaproxy487.model.ProxyProfile
+import net.megaproxy487.model.ProxyType
+import net.megaproxy487.model.SshProfile
+import net.megaproxy487.model.SshAuthMode
+import net.megaproxy487.model.FailoverMode
+import net.megaproxy487.model.TlsProfile
+import net.megaproxy487.model.GlobalConnectionSettings
+import org.json.JSONArray
+import org.json.JSONObject
+import java.security.KeyStore
+import java.util.UUID
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+class ConfigStore(context: Context) {
+ private val prefs = context.getSharedPreferences("proxy_config", Context.MODE_PRIVATE)
+
+ @Synchronized
+ fun profiles(): List {
+ ensureMigrated()
+ val decoded = decodeProfiles(prefs.getString(PROFILES, null)).ifEmpty {
+ val recovered = createInitialProfile()
+ prefs.edit()
+ .putString(PROFILES, encodeProfiles(listOf(recovered)))
+ .putString(ACTIVE_PROFILE_ID, recovered.id)
+ .putString(ALWAYS_ON_PROFILE_ID, recovered.id)
+ .putString(CONNECTION_PROFILE_ID, recovered.id)
+ .putBoolean(FAILOVER_ACTIVE, false)
+ .remove(FAILOVER_NOTICE)
+ .apply()
+ listOf(recovered)
+ }
+ if (prefs.getInt(FLAG_COLOR_VERSION, 0) >= CURRENT_FLAG_COLOR_VERSION) return decoded
+ val recolored = decoded.map { profile ->
+ if (profile.countryCode.isEmpty()) profile
+ else profile.copy(
+ colorIndex = ProfileColorMatcher.colorIndexForFlag(profile.countryCode, profile.colorIndex),
+ )
+ }
+ prefs.edit()
+ .putString(PROFILES, encodeProfiles(recolored))
+ .putInt(FLAG_COLOR_VERSION, CURRENT_FLAG_COLOR_VERSION)
+ .apply()
+ return recolored
+ }
+
+ fun activeProfile(): ProxyProfile = profile(activeProfileId()) ?: profiles().first()
+
+ fun alwaysOnProfile(): ProxyProfile = profile(alwaysOnProfileId()) ?: profiles().first()
+
+ fun sortedProfiles(): List = profiles()
+
+ @Synchronized
+ fun reorderProfiles(orderedIds: List) {
+ val current = profiles()
+ val byId = current.associateBy(ProxyProfile::id)
+ val reordered = orderedIds.mapNotNull(byId::get) + current.filter { it.id !in orderedIds }
+ if (reordered.map(ProxyProfile::id) == current.map(ProxyProfile::id)) return
+ writeProfiles(reordered)
+ }
+
+ fun profile(id: String): ProxyProfile? = profiles().firstOrNull { it.id == id }
+
+ fun activeProfileId(): String {
+ ensureMigrated()
+ return prefs.getString(ACTIVE_PROFILE_ID, null) ?: profiles().first().id
+ }
+
+ fun alwaysOnProfileId(): String {
+ ensureMigrated()
+ return prefs.getString(ALWAYS_ON_PROFILE_ID, null) ?: activeProfileId()
+ }
+
+ fun setActiveProfile(id: String) {
+ if (profile(id) != null) prefs.edit().putString(ACTIVE_PROFILE_ID, id).apply()
+ }
+
+ fun setAlwaysOnProfile(id: String) {
+ if (profile(id) != null) prefs.edit()
+ .putString(ALWAYS_ON_PROFILE_ID, id)
+ .putBoolean(FAILOVER_ACTIVE, false)
+ .apply()
+ }
+
+ fun connectionProfile(): ProxyProfile =
+ profile(prefs.getString(CONNECTION_PROFILE_ID, null).orEmpty()) ?: activeProfile()
+
+ /** Identifier-only lookup that never decrypts or parses profile payloads. */
+ fun connectionProfileId(): String =
+ prefs.getString(CONNECTION_PROFILE_ID, null).orEmpty().ifEmpty(::activeProfileId)
+
+ fun connectionConfig(): ProxyConfig = globalConnectionSettings().applyTo(connectionProfile().config)
+
+ fun setConnectionProfile(id: String) {
+ if (profile(id) != null) prefs.edit().putString(CONNECTION_PROFILE_ID, id).apply()
+ }
+
+ fun failoverNotice(): String? = prefs.getString(FAILOVER_NOTICE, null)
+
+ fun isFailoverActive(): Boolean = prefs.getBoolean(FAILOVER_ACTIVE, false)
+
+ fun setFailoverState(active: Boolean, notice: String?) {
+ prefs.edit().putBoolean(FAILOVER_ACTIVE, active).apply {
+ if (notice == null) remove(FAILOVER_NOTICE) else putString(FAILOVER_NOTICE, notice)
+ }.apply()
+ }
+
+ fun hasPendingReconnect(): Boolean = pendingReconnectToken() != null
+
+ fun pendingReconnectToken(): String? = prefs.getString(PENDING_RECONNECT, null)
+
+ fun markPendingReconnect() {
+ prefs.edit().putString(PENDING_RECONNECT, UUID.randomUUID().toString()).apply()
+ }
+
+ fun clearPendingReconnect(appliedToken: String?) {
+ if (appliedToken != null && pendingReconnectToken() == appliedToken) {
+ prefs.edit().remove(PENDING_RECONNECT).apply()
+ }
+ }
+
+ @Synchronized
+ fun globalConnectionSettings(): GlobalConnectionSettings {
+ ensureMigrated()
+ val stored = prefs.getString(GLOBAL_CONNECTION_SETTINGS, null)
+ if (stored != null) {
+ migrateGlobalIpv6ToProfiles(stored)
+ val decoded = decodeGlobalConnectionSettings(stored)
+ if (!runCatching { JSONObject(stored).has("sshProfile") }.getOrDefault(false)) {
+ val upgraded = decoded.copy(sshProfile = activeProfile().config.sshProfile)
+ saveGlobalConnectionSettings(upgraded)
+ return upgraded
+ }
+ return decoded
+ }
+ val source = activeProfile().config
+ val migrated = GlobalConnectionSettings(
+ tlsProfile = source.profile.takeIf { it.available } ?: TlsProfile.DEFAULT,
+ sshProfile = source.sshProfile,
+ customJa3 = source.customJa3,
+ selectedPackages = source.selectedPackages,
+ routeAllApps = source.routeAllApps,
+ bypassLocalNetworks = source.bypassLocalNetworks,
+ )
+ saveGlobalConnectionSettings(migrated)
+ prefs.edit().putBoolean(IPV6_PROFILE_MIGRATED, true).apply()
+ return migrated
+ }
+
+ @Synchronized
+ private fun migrateGlobalIpv6ToProfiles(storedSettings: String) {
+ if (prefs.getBoolean(IPV6_PROFILE_MIGRATED, false)) return
+ val enabled = runCatching { JSONObject(storedSettings).optBoolean("allowIpv6", false) }.getOrDefault(false)
+ writeProfiles(profiles().map { it.copy(config = it.config.copy(allowIpv6 = enabled)) })
+ prefs.edit().putBoolean(IPV6_PROFILE_MIGRATED, true).apply()
+ }
+
+ fun saveGlobalConnectionSettings(settings: GlobalConnectionSettings) {
+ val normalized = settings.copy(
+ tlsProfile = settings.tlsProfile.takeIf { it.available } ?: TlsProfile.DEFAULT,
+ sshKeepaliveSeconds = settings.sshKeepaliveSeconds.coerceIn(0, 3600),
+ sshMaxChannels = settings.sshMaxChannels.coerceIn(1, 256),
+ sshRotationMinutes = settings.sshRotationMinutes.coerceIn(0, 1440),
+ sshRotationMb = settings.sshRotationMb.coerceIn(0, 10240),
+ failoverProfileIds = settings.failoverProfileIds.distinct(),
+ )
+ prefs.edit().putString(GLOBAL_CONNECTION_SETTINGS, encodeGlobalConnectionSettings(normalized)).apply()
+ }
+
+ @Synchronized
+ fun addProfile(): ProxyProfile {
+ val existing = profiles()
+ val profile = ProxyProfile(
+ id = UUID.randomUUID().toString(),
+ colorIndex = nextColorIndex(existing),
+ config = ProxyConfig(port = 443),
+ )
+ writeProfiles(existing + profile)
+ return profile
+ }
+
+ @Synchronized
+ fun cloneProfile(id: String): ProxyProfile? {
+ val existing = profiles()
+ val sourceIndex = existing.indexOfFirst { it.id == id }
+ if (sourceIndex < 0) return null
+ val source = existing[sourceIndex]
+ val clone = source.copy(
+ id = UUID.randomUUID().toString(),
+ name = "${source.displayName} copy",
+ )
+ writeProfiles(existing.toMutableList().apply { add(sourceIndex + 1, clone) })
+ return clone
+ }
+
+ @Synchronized
+ fun importProfiles(imported: List): List {
+ val existing = profiles()
+ val allocated = existing.toMutableList()
+ val added = imported.map { source ->
+ val profile = ProxyProfile(
+ id = UUID.randomUUID().toString(),
+ name = source.name,
+ colorIndex = ProfileColorMatcher.colorIndexForFlag(
+ source.countryCode,
+ nextColorIndex(allocated),
+ ),
+ countryCode = source.countryCode,
+ config = source.config,
+ )
+ allocated += profile
+ profile
+ }
+ writeProfiles(existing + added)
+ return added
+ }
+
+ @Synchronized
+ fun importConfiguration(configuration: PortableConfiguration): List {
+ val existing = profiles()
+ val idMap = mutableMapOf()
+ val added = configuration.profiles.map { source ->
+ val newId = UUID.randomUUID().toString()
+ idMap[source.id] = newId
+ source.copy(id = newId)
+ }
+ writeProfiles(existing + added)
+ val editor = prefs.edit()
+ .putInt(DIAGNOSTIC_LOG_LIMIT_MB, configuration.diagnosticLogLimitMb)
+ configuration.activeProfileId?.let(idMap::get)?.let { editor.putString(ACTIVE_PROFILE_ID, it) }
+ configuration.alwaysOnProfileId?.let(idMap::get)?.let { editor.putString(ALWAYS_ON_PROFILE_ID, it) }
+ editor.apply()
+ configuration.globalConnectionSettings?.let { importedSettings ->
+ saveGlobalConnectionSettings(importedSettings.copy(
+ failoverProfileIds = importedSettings.failoverProfileIds.mapNotNull(idMap::get),
+ ))
+ }
+ return added
+ }
+
+ @Synchronized
+ fun saveProfile(profile: ProxyProfile) {
+ val current = profiles()
+ val updated = current.map { if (it.id == profile.id) profile else it }
+ if (updated != current) writeProfiles(updated)
+ }
+
+ @Synchronized
+ fun trustSshHostKey(profileId: String, hop: String, fingerprint: String): Boolean {
+ if (!fingerprint.matches(Regex("SHA256:[A-Za-z0-9+/]{20,}={0,2}"))) return false
+ val current = profiles()
+ var changed = false
+ val updated = current.map { profile ->
+ if (profile.id != profileId) profile else {
+ changed = true
+ profile.copy(config = if (hop == "jump") profile.config.copy(
+ jumpTrustedHostKey = fingerprint, jumpAcceptAnyHostKey = false,
+ ) else profile.config.copy(
+ trustedHostKey = fingerprint, acceptAnyHostKey = false,
+ ))
+ }
+ }
+ if (changed) writeProfiles(updated)
+ return changed
+ }
+
+ @Synchronized
+ fun deleteProfile(id: String): Boolean {
+ val current = profiles()
+ if (current.size <= 1 || current.none { it.id == id }) return false
+ val connectionWasDeleted = connectionProfile().id == id
+ val remaining = current.filterNot { it.id == id }
+ val replacement = remaining.first().id
+ val editor = prefs.edit().putString(PROFILES, encodeProfiles(remaining))
+ if (activeProfileId() == id) editor.putString(ACTIVE_PROFILE_ID, replacement)
+ if (alwaysOnProfileId() == id) editor.putString(ALWAYS_ON_PROFILE_ID, replacement)
+ if (connectionWasDeleted) editor.putString(CONNECTION_PROFILE_ID, replacement)
+ editor.apply()
+ val settings = globalConnectionSettings()
+ if (id in settings.failoverProfileIds) {
+ saveGlobalConnectionSettings(settings.copy(failoverProfileIds = settings.failoverProfileIds - id))
+ }
+ if (connectionWasDeleted && isFailoverActive()) setFailoverState(false, null)
+ return true
+ }
+
+ /** Compatibility accessor for callers that operate on the selected profile. */
+ fun load(): ProxyConfig = activeProfile().config
+
+ /** Compatibility writer for per-profile settings screens. */
+ fun save(config: ProxyConfig) = saveProfile(activeProfile().copy(config = config))
+
+ fun isConnectionDesired(): Boolean = prefs.getBoolean("connection_desired", false)
+
+ fun setConnectionDesired(desired: Boolean) {
+ prefs.edit().putBoolean("connection_desired", desired).apply()
+ }
+
+ fun diagnosticLogLimitMb(): Int = prefs.getInt(DIAGNOSTIC_LOG_LIMIT_MB, 3).coerceIn(1, 100)
+
+ fun setDiagnosticLogLimitMb(value: Int) {
+ prefs.edit().putInt(DIAGNOSTIC_LOG_LIMIT_MB, value.coerceIn(1, 100)).apply()
+ }
+
+ private fun ensureMigrated() {
+ if (prefs.contains(PROFILES)) return
+ synchronized(prefs) {
+ if (prefs.contains(PROFILES)) return
+ val profile = createInitialProfile()
+ prefs.edit()
+ .putString(PROFILES, encodeProfiles(listOf(profile)))
+ .putString(ACTIVE_PROFILE_ID, profile.id)
+ .putString(ALWAYS_ON_PROFILE_ID, profile.id)
+ .apply()
+ }
+ }
+
+ private fun createInitialProfile(): ProxyProfile = ProxyProfile(
+ id = UUID.randomUUID().toString(),
+ colorIndex = 0,
+ config = legacyConfig(),
+ )
+
+ private fun legacyConfig() = ProxyConfig(
+ host = prefs.getString("host", "").orEmpty(),
+ port = prefs.getInt("port", 443),
+ username = prefs.getString("username", "").orEmpty(),
+ password = decrypt(prefs.getString("password", null)),
+ allowInvalidProxyCertificate = false,
+ profile = enumValue(prefs.getString("profile", null), TlsProfile.DEFAULT),
+ customJa3 = prefs.getString("custom_ja3", "").orEmpty(),
+ dnsProvider = enumValue(prefs.getString("dns_provider", null), DnsProvider.CLOUDFLARE),
+ customDohUrl = prefs.getString("custom_doh_url", "").orEmpty(),
+ selectedPackages = prefs.getStringSet("packages", emptySet())?.toSet().orEmpty(),
+ allowIpv6 = prefs.getBoolean("allow_ipv6", false),
+ routeAllApps = true,
+ bypassLocalNetworks = true,
+ )
+
+ private inline fun > enumValue(value: String?, default: T): T =
+ runCatching { enumValueOf(value ?: default.name) }.getOrDefault(default)
+
+ private fun nextColorIndex(profiles: List): Int {
+ val counts = IntArray(ProfileColors.argb.size)
+ profiles.forEach { counts[Math.floorMod(it.colorIndex, counts.size)]++ }
+ return counts.indices.minWithOrNull(compareBy { counts[it] }.thenBy { it }) ?: 0
+ }
+
+ private fun writeProfiles(profiles: List) {
+ prefs.edit().putString(PROFILES, encodeProfiles(profiles)).apply()
+ }
+
+ private fun encodeProfiles(profiles: List) = JSONArray().apply {
+ profiles.forEach { profile ->
+ put(JSONObject().apply {
+ put("id", profile.id)
+ put("name", profile.name.trim())
+ put("color", profile.colorIndex)
+ put("countryCode", profile.countryCode.uppercase())
+ put("config", encodeConfig(profile.config))
+ })
+ }
+ }.toString()
+
+ private fun encodeConfig(config: ProxyConfig) = JSONObject().apply {
+ put("type", config.type.name)
+ put("host", config.host.trim())
+ put("port", config.port)
+ put("username", config.username)
+ put("password", encrypt(config.password))
+ put("privateKey", encrypt(config.privateKey))
+ put("sshProfile", config.sshProfile.name)
+ put("trustedHostKey", config.trustedHostKey)
+ put("acceptAnyHostKey", config.acceptAnyHostKey)
+ put("jumpHost", config.jumpHost.trim())
+ put("jumpPort", config.jumpPort)
+ put("jumpUsername", config.jumpUsername)
+ put("jumpPassword", encrypt(config.jumpPassword))
+ put("jumpPrivateKey", encrypt(config.jumpPrivateKey))
+ put("jumpTrustedHostKey", config.jumpTrustedHostKey)
+ put("jumpAcceptAnyHostKey", config.jumpAcceptAnyHostKey)
+ put("sameJumpAuthentication", config.sameJumpAuthentication)
+ put("allowInvalidProxyCertificate", config.allowInvalidProxyCertificate)
+ put("fingerprint", config.profile.name)
+ put("customJa3", config.customJa3.trim())
+ put("dnsProvider", config.dnsProvider.name)
+ put("customDohUrl", config.customDohUrl.trim())
+ put("packages", JSONArray(config.selectedPackages.sorted()))
+ put("allowIpv6", config.allowIpv6)
+ put("routeAllApps", config.routeAllApps)
+ put("bypassLocalNetworks", config.bypassLocalNetworks)
+ }
+
+ private fun decodeProfiles(value: String?): List = runCatching {
+ val array = JSONArray(value ?: return emptyList())
+ List(array.length()) { index ->
+ val item = array.getJSONObject(index)
+ ProxyProfile(
+ id = item.getString("id"),
+ name = item.optString("name"),
+ colorIndex = item.optInt("color", index),
+ countryCode = item.optString("countryCode"),
+ config = decodeConfig(item.getJSONObject("config")),
+ )
+ }
+ }.getOrDefault(emptyList())
+
+ private fun decodeConfig(item: JSONObject) = ProxyConfig(
+ type = enumValue(item.optString("type"), ProxyType.HTTPS),
+ host = item.optString("host"),
+ port = item.optInt("port", 443),
+ username = item.optString("username"),
+ password = decrypt(item.optString("password").ifEmpty { null }),
+ privateKey = decrypt(item.optString("privateKey").ifEmpty { null }),
+ sshProfile = enumValue(item.optString("sshProfile"), SshProfile.DEFAULT),
+ trustedHostKey = item.optString("trustedHostKey"),
+ acceptAnyHostKey = item.optBoolean("acceptAnyHostKey", false),
+ jumpHost = item.optString("jumpHost"),
+ jumpPort = item.optInt("jumpPort", 22),
+ jumpUsername = item.optString("jumpUsername"),
+ jumpPassword = decrypt(item.optString("jumpPassword").ifEmpty { null }),
+ jumpPrivateKey = decrypt(item.optString("jumpPrivateKey").ifEmpty { null }),
+ jumpTrustedHostKey = item.optString("jumpTrustedHostKey"),
+ jumpAcceptAnyHostKey = item.optBoolean("jumpAcceptAnyHostKey", false),
+ sameJumpAuthentication = item.optBoolean("sameJumpAuthentication", true),
+ allowInvalidProxyCertificate = item.optBoolean("allowInvalidProxyCertificate", false),
+ profile = enumValue(item.optString("fingerprint"), TlsProfile.DEFAULT),
+ customJa3 = item.optString("customJa3"),
+ dnsProvider = enumValue(item.optString("dnsProvider"), DnsProvider.CLOUDFLARE),
+ customDohUrl = item.optString("customDohUrl"),
+ selectedPackages = item.optJSONArray("packages")?.let { array ->
+ (0 until array.length()).map { array.getString(it) }.toSet()
+ }.orEmpty(),
+ allowIpv6 = item.optBoolean("allowIpv6", false),
+ routeAllApps = item.optBoolean("routeAllApps", false),
+ bypassLocalNetworks = item.optBoolean("bypassLocalNetworks", true),
+ )
+
+ private fun encodeGlobalConnectionSettings(settings: GlobalConnectionSettings) = JSONObject().apply {
+ put("fingerprint", settings.tlsProfile.name)
+ put("sshProfile", settings.sshProfile.name)
+ put("sshAuthMode", settings.sshAuthMode.name)
+ put("sshKeepaliveSeconds", settings.sshKeepaliveSeconds)
+ put("sshMaxChannels", settings.sshMaxChannels)
+ put("sshRotationMinutes", settings.sshRotationMinutes)
+ put("sshRotationMb", settings.sshRotationMb)
+ put("failoverMode", settings.failoverMode.name)
+ put("failoverProfileIds", JSONArray(settings.failoverProfileIds))
+ put("customJa3", settings.customJa3.trim())
+ put("packages", JSONArray(settings.selectedPackages.sorted()))
+ put("routeAllApps", settings.routeAllApps)
+ put("bypassLocalNetworks", settings.bypassLocalNetworks)
+ }.toString()
+
+ private fun decodeGlobalConnectionSettings(value: String): GlobalConnectionSettings = runCatching {
+ val item = JSONObject(value)
+ val parsedTls = enumValue(item.optString("fingerprint"), TlsProfile.DEFAULT)
+ GlobalConnectionSettings(
+ tlsProfile = parsedTls.takeIf { it.available } ?: TlsProfile.DEFAULT,
+ sshProfile = enumValue(item.optString("sshProfile"), SshProfile.DEFAULT),
+ sshAuthMode = enumValue(item.optString("sshAuthMode"), SshAuthMode.AUTO),
+ sshKeepaliveSeconds = item.optInt("sshKeepaliveSeconds", 30).coerceIn(0, 3600),
+ sshMaxChannels = item.optInt("sshMaxChannels", 32).coerceIn(1, 256),
+ sshRotationMinutes = item.optInt("sshRotationMinutes", 0).coerceIn(0, 1440),
+ sshRotationMb = item.optInt("sshRotationMb", 0).coerceIn(0, 10240),
+ failoverMode = enumValue(item.optString("failoverMode"), FailoverMode.DISABLED),
+ failoverProfileIds = item.optJSONArray("failoverProfileIds")?.let { array ->
+ (0 until array.length()).mapNotNull { array.optString(it).takeIf(String::isNotBlank) }
+ }.orEmpty(),
+ customJa3 = item.optString("customJa3"),
+ selectedPackages = item.optJSONArray("packages")?.let { array ->
+ (0 until array.length()).mapNotNull { array.optString(it).takeIf(String::isNotBlank) }.toSet()
+ }.orEmpty(),
+ routeAllApps = item.optBoolean("routeAllApps", true),
+ bypassLocalNetworks = item.optBoolean("bypassLocalNetworks", true),
+ )
+ }.getOrDefault(GlobalConnectionSettings())
+
+ private fun key(): SecretKey {
+ val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
+ (store.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
+ return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
+ init(KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .build())
+ generateKey()
+ }
+ }
+
+ private fun encrypt(plain: String): String {
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
+ cipher.init(Cipher.ENCRYPT_MODE, key())
+ return Base64.encodeToString(cipher.iv + cipher.doFinal(plain.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP)
+ }
+
+ private fun decrypt(packed: String?): String = runCatching {
+ if (packed == null) return ""
+ val bytes = Base64.decode(packed, Base64.NO_WRAP)
+ require(bytes.size > IV_SIZE)
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
+ cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(0, IV_SIZE)))
+ cipher.doFinal(bytes.copyOfRange(IV_SIZE, bytes.size)).toString(Charsets.UTF_8)
+ }.getOrDefault("")
+
+ private companion object {
+ const val KEY_ALIAS = "megaproxy.proxy.credentials.v1"
+ const val IV_SIZE = 12
+ const val PROFILES = "profiles_v2"
+ const val ACTIVE_PROFILE_ID = "active_profile_id"
+ const val ALWAYS_ON_PROFILE_ID = "always_on_profile_id"
+ private const val FAILOVER_ACTIVE = "failover_active"
+ private const val FAILOVER_NOTICE = "failover_notice"
+ const val CONNECTION_PROFILE_ID = "connection_profile_id"
+ const val FLAG_COLOR_VERSION = "flag_color_version"
+ const val CURRENT_FLAG_COLOR_VERSION = 1
+ const val DIAGNOSTIC_LOG_LIMIT_MB = "diagnostic_log_limit_mb"
+ const val GLOBAL_CONNECTION_SETTINGS = "global_connection_settings_v1"
+ private const val IPV6_PROFILE_MIGRATED = "ipv6_profile_migrated_v1"
+ private const val PENDING_RECONNECT = "pending_reconnect"
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt
new file mode 100644
index 0000000..8b4540e
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt
@@ -0,0 +1,258 @@
+package net.megaproxy487.data
+
+import net.megaproxy487.model.DnsProvider
+import net.megaproxy487.model.ProxyConfig
+import net.megaproxy487.model.ProxyProfile
+import net.megaproxy487.model.ProxyType
+import net.megaproxy487.model.SshProfile
+import net.megaproxy487.model.SshAuthMode
+import net.megaproxy487.model.FailoverMode
+import net.megaproxy487.model.TlsProfile
+import net.megaproxy487.model.GlobalConnectionSettings
+import org.json.JSONArray
+import org.json.JSONObject
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+
+enum class ConfigExportFormat(val extension: String, val mimeType: String) {
+ PROXY_LIST("txt", "text/plain"),
+ JSON("json", "application/json"),
+}
+
+data class PortableConfiguration(
+ val profiles: List,
+ val activeProfileId: String?,
+ val alwaysOnProfileId: String?,
+ val diagnosticLogLimitMb: Int = 3,
+ val globalConnectionSettings: GlobalConnectionSettings? = null,
+ val skippedProfiles: Int = 0,
+)
+
+object ConfigTransfer {
+ const val SCHEMA_VERSION = 6
+
+ fun exportProxyList(profiles: List, includePasswords: Boolean): String =
+ profiles.filter { it.config.type == ProxyType.HTTPS }.joinToString("\n", postfix = "\n") { profile ->
+ val config = profile.config
+ val password = if (includePasswords) config.password else ""
+ val userInfo = "${encode(config.username)}:${encode(password)}"
+ val query = buildList {
+ if (profile.name.isNotBlank()) add("title=${encode(profile.name.trim())}")
+ if (profile.countryCode.isNotBlank()) add("cc=${encode(profile.countryCode.uppercase())}")
+ }.joinToString("&")
+ buildString {
+ append("https://").append(userInfo).append('@').append(config.host.trim())
+ if (config.port != 443) append(':').append(config.port)
+ if (query.isNotEmpty()) append('?').append(query)
+ }
+ }
+
+ fun exportJson(store: ConfigStore, includePasswords: Boolean, includePrivateKeys: Boolean = false): String = JSONObject().apply {
+ put("schema", "dev.megaproxy.config")
+ put("version", SCHEMA_VERSION)
+ put("passwordsIncluded", includePasswords)
+ put("privateKeysIncluded", includePrivateKeys)
+ put("activeProfileId", store.activeProfileId())
+ put("alwaysOnProfileId", store.alwaysOnProfileId())
+ put("diagnosticLogLimitMb", store.diagnosticLogLimitMb())
+ val global = store.globalConnectionSettings()
+ put("tls", JSONObject().apply {
+ put("fingerprint", global.tlsProfile.name)
+ put("customJa3", global.customJa3.trim())
+ })
+ put("ssh", JSONObject().apply {
+ put("fingerprint", global.sshProfile.name)
+ put("authMode", global.sshAuthMode.name)
+ put("keepaliveSeconds", global.sshKeepaliveSeconds)
+ put("maxChannels", global.sshMaxChannels)
+ put("rotationMinutes", global.sshRotationMinutes)
+ put("rotationMb", global.sshRotationMb)
+ })
+ put("failover", JSONObject().apply {
+ put("mode", global.failoverMode.name)
+ put("profileIds", JSONArray(global.failoverProfileIds))
+ })
+ put("routing", JSONObject().apply {
+ put("routeAllApps", global.routeAllApps)
+ put("selectedPackages", JSONArray(global.selectedPackages.sorted()))
+ put("bypassLocalNetworks", global.bypassLocalNetworks)
+ })
+ put("profiles", JSONArray().apply {
+ store.profiles().forEach { profile -> put(encodeProfile(profile, includePasswords, includePrivateKeys)) }
+ })
+ }.toString(2)
+
+ fun importJson(text: String): PortableConfiguration {
+ val root = JSONObject(text)
+ require(root.optString("schema") == "dev.megaproxy.config") { "This is not a MegaProxy configuration file" }
+ val version = root.optInt("version", 0)
+ require(version in 1..SCHEMA_VERSION) { "Unsupported MegaProxy configuration version: $version" }
+ val array = root.optJSONArray("profiles") ?: error("The configuration has no profiles")
+ require(array.length() <= MAX_IMPORTED_PROFILES) { "The configuration contains more than $MAX_IMPORTED_PROFILES profiles" }
+ val decoded = (0 until array.length()).map { index ->
+ runCatching { decodeProfile(array.getJSONObject(index), index) }
+ }
+ val decodedProfiles = decoded.mapNotNull(Result::getOrNull)
+ require(decodedProfiles.isNotEmpty()) { "The configuration contains no usable profiles" }
+ val legacyIpv6 = root.optJSONObject("routing")?.optBoolean("allowIpv6", false) ?: false
+ val profiles = if (version in 2..5) decodedProfiles.map {
+ it.copy(config = it.config.copy(allowIpv6 = legacyIpv6))
+ } else decodedProfiles
+ return PortableConfiguration(
+ profiles = profiles,
+ activeProfileId = root.optString("activeProfileId").ifBlank { null },
+ alwaysOnProfileId = root.optString("alwaysOnProfileId").ifBlank { null },
+ diagnosticLogLimitMb = root.optInt("diagnosticLogLimitMb", 3).coerceIn(1, 100),
+ globalConnectionSettings = if (version >= 2) {
+ decodeGlobalSettings(root).let { global ->
+ if (version < 4) global.copy(sshProfile = profiles.first().config.sshProfile) else global
+ }
+ } else null,
+ skippedProfiles = decoded.count(Result::isFailure),
+ )
+ }
+
+ private fun decodeGlobalSettings(root: JSONObject): GlobalConnectionSettings {
+ val tls = root.optJSONObject("tls") ?: JSONObject()
+ val routing = root.optJSONObject("routing") ?: JSONObject()
+ val ssh = root.optJSONObject("ssh") ?: JSONObject()
+ val failover = root.optJSONObject("failover") ?: JSONObject()
+ val parsedTls = enumValue(tls.optString("fingerprint"), TlsProfile.DEFAULT)
+ val packages = routing.optJSONArray("selectedPackages")?.let { array ->
+ require(array.length() <= MAX_IMPORTED_PACKAGES) { "The configuration contains too many application package names" }
+ (0 until array.length()).mapNotNull { index ->
+ array.optString(index).takeIf { it.length <= 256 }?.trim()
+ ?.takeIf { it.matches(Regex("[A-Za-z0-9_.]+")) }
+ }.toSet()
+ }.orEmpty()
+ return GlobalConnectionSettings(
+ tlsProfile = parsedTls.takeIf { it.available } ?: TlsProfile.DEFAULT,
+ sshProfile = enumValue(ssh.optString("fingerprint"), SshProfile.DEFAULT),
+ sshAuthMode = enumValue(ssh.optString("authMode"), SshAuthMode.AUTO),
+ sshKeepaliveSeconds = ssh.optInt("keepaliveSeconds", 30).coerceIn(0, 3600),
+ sshMaxChannels = ssh.optInt("maxChannels", 32).coerceIn(1, 256),
+ sshRotationMinutes = ssh.optInt("rotationMinutes", 0).coerceIn(0, 1440),
+ sshRotationMb = ssh.optInt("rotationMb", 0).coerceIn(0, 10240),
+ failoverMode = enumValue(failover.optString("mode"), FailoverMode.DISABLED),
+ failoverProfileIds = failover.optJSONArray("profileIds")?.let { array ->
+ require(array.length() <= MAX_IMPORTED_PROFILES) { "The configuration contains too many failover profile IDs" }
+ (0 until array.length()).mapNotNull {
+ array.optString(it).takeIf { id -> id.isNotBlank() && id.length <= 256 }
+ }
+ }.orEmpty(),
+ customJa3 = tls.limitedString("customJa3", MAX_CUSTOM_JA3_LENGTH),
+ selectedPackages = packages,
+ routeAllApps = routing.optBoolean("routeAllApps", true),
+ bypassLocalNetworks = routing.optBoolean("bypassLocalNetworks", true),
+ )
+ }
+
+ private fun encodeProfile(profile: ProxyProfile, includePasswords: Boolean, includePrivateKeys: Boolean) = JSONObject().apply {
+ put("id", profile.id)
+ put("name", profile.name.trim())
+ put("color", profile.colorIndex)
+ put("countryCode", profile.countryCode.uppercase())
+ put("proxy", JSONObject().apply {
+ put("type", profile.config.type.name)
+ put("host", profile.config.host.trim())
+ put("port", profile.config.port)
+ put("username", profile.config.username)
+ if (includePasswords) put("password", profile.config.password)
+ if (includePrivateKeys) put("privateKey", profile.config.privateKey)
+ put("allowInvalidProxyCertificate", profile.config.allowInvalidProxyCertificate)
+ put("sshProfile", profile.config.sshProfile.name)
+ put("trustedHostKey", profile.config.trustedHostKey)
+ put("acceptAnyHostKey", profile.config.acceptAnyHostKey)
+ if (profile.config.type == ProxyType.SSH_JUMP) put("jump", JSONObject().apply {
+ put("host", profile.config.jumpHost)
+ put("port", profile.config.jumpPort)
+ put("sameAuthentication", profile.config.sameJumpAuthentication)
+ if (!profile.config.sameJumpAuthentication) {
+ put("username", profile.config.jumpUsername)
+ if (includePasswords) put("password", profile.config.jumpPassword)
+ if (includePrivateKeys) put("privateKey", profile.config.jumpPrivateKey)
+ }
+ put("trustedHostKey", profile.config.jumpTrustedHostKey)
+ put("acceptAnyHostKey", profile.config.jumpAcceptAnyHostKey)
+ })
+ })
+ put("tls", JSONObject().apply {
+ put("fingerprint", profile.config.profile.name)
+ put("customJa3", profile.config.customJa3.trim())
+ })
+ put("dns", JSONObject().apply {
+ put("provider", profile.config.dnsProvider.name)
+ put("customDohUrl", profile.config.customDohUrl.trim())
+ })
+ put("routing", JSONObject().apply {
+ put("routeAllApps", profile.config.routeAllApps)
+ put("selectedPackages", JSONArray(profile.config.selectedPackages.sorted()))
+ put("allowIpv6", profile.config.allowIpv6)
+ put("bypassLocalNetworks", profile.config.bypassLocalNetworks)
+ })
+ }
+
+ private fun decodeProfile(item: JSONObject, index: Int): ProxyProfile {
+ val proxy = item.optJSONObject("proxy") ?: JSONObject()
+ val tls = item.optJSONObject("tls") ?: JSONObject()
+ val dns = item.optJSONObject("dns") ?: JSONObject()
+ val routing = item.optJSONObject("routing") ?: JSONObject()
+ val host = proxy.limitedString("host", 253).trim()
+ require(host.isNotEmpty() && !host.contains(Regex("[/:\\s]")))
+ val jump = proxy.optJSONObject("jump")
+ val packages = routing.optJSONArray("selectedPackages")?.let { array ->
+ require(array.length() <= MAX_IMPORTED_PACKAGES) { "A profile contains too many application package names" }
+ (0 until array.length()).mapNotNull { packageIndex ->
+ array.optString(packageIndex).takeIf { it.length <= 256 }?.trim()
+ ?.takeIf { it.matches(Regex("[A-Za-z0-9_.]+")) }
+ }.toSet()
+ }.orEmpty()
+ return ProxyProfile(
+ id = item.limitedString("id", 256).ifBlank { "import-$index" },
+ name = item.limitedString("name", 256).trim(),
+ colorIndex = item.optInt("color", index),
+ countryCode = item.limitedString("countryCode", 2).uppercase()
+ .takeIf { it.matches(Regex("[A-Z]{2}")) }.orEmpty(),
+ config = ProxyConfig(
+ type = enumValue(proxy.optString("type"), ProxyType.HTTPS),
+ host = host,
+ port = proxy.optInt("port", 443).takeIf { it in 1..65535 } ?: 443,
+ username = proxy.limitedString("username", 4_096),
+ password = proxy.limitedString("password", 16_384),
+ privateKey = proxy.limitedString("privateKey", 64 * 1024),
+ sshProfile = enumValue(proxy.optString("sshProfile"), SshProfile.DEFAULT),
+ trustedHostKey = proxy.limitedString("trustedHostKey", 256),
+ acceptAnyHostKey = proxy.optBoolean("acceptAnyHostKey", false),
+ jumpHost = jump?.limitedString("host", 253).orEmpty(),
+ jumpPort = jump?.optInt("port", 22) ?: 22,
+ sameJumpAuthentication = jump?.optBoolean("sameAuthentication", true) ?: true,
+ jumpUsername = jump?.limitedString("username", 4_096).orEmpty(),
+ jumpPassword = jump?.limitedString("password", 16_384).orEmpty(),
+ jumpPrivateKey = jump?.limitedString("privateKey", 64 * 1024).orEmpty(),
+ jumpTrustedHostKey = jump?.limitedString("trustedHostKey", 256).orEmpty(),
+ jumpAcceptAnyHostKey = jump?.optBoolean("acceptAnyHostKey", false) ?: false,
+ allowInvalidProxyCertificate = proxy.optBoolean("allowInvalidProxyCertificate", false),
+ profile = enumValue(tls.optString("fingerprint"), TlsProfile.DEFAULT),
+ customJa3 = tls.limitedString("customJa3", MAX_CUSTOM_JA3_LENGTH),
+ dnsProvider = enumValue(dns.optString("provider"), DnsProvider.CLOUDFLARE),
+ customDohUrl = dns.limitedString("customDohUrl", 2_048),
+ selectedPackages = packages,
+ allowIpv6 = routing.optBoolean("allowIpv6", false),
+ routeAllApps = routing.optBoolean("routeAllApps", false),
+ bypassLocalNetworks = routing.optBoolean("bypassLocalNetworks", true),
+ ),
+ )
+ }
+
+ private fun JSONObject.limitedString(name: String, maxLength: Int): String = optString(name).also {
+ require(it.length <= maxLength) { "$name is too long" }
+ }
+
+ private inline fun > enumValue(value: String, default: T): T =
+ enumValues().firstOrNull { it.name == value } ?: default
+
+ private const val MAX_CUSTOM_JA3_LENGTH = 8 * 1024
+
+ private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8.name())
+ .replace("+", "%20")
+}
diff --git a/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt b/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt
new file mode 100644
index 0000000..c1ef4a4
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt
@@ -0,0 +1,67 @@
+package net.megaproxy487.data
+
+import net.megaproxy487.model.ProxyConfig
+import org.json.JSONObject
+
+object FoxyProxyParser {
+ fun parse(text: String): Result = runCatching {
+ val root = JSONObject(text)
+ require(root.optString("schema") != "dev.megaproxy.config") {
+ "This is a MegaProxy configuration, not a FoxyProxy configuration"
+ }
+ val data = root.optJSONArray("data")
+ ?: error("The FoxyProxy configuration has no data array")
+ require(data.length() > 0) { "The FoxyProxy proxy list is empty" }
+ require(data.length() <= MAX_IMPORTED_PROFILES) {
+ "The FoxyProxy configuration contains more than $MAX_IMPORTED_PROFILES entries"
+ }
+
+ var skippedNonHttps = 0
+ val proxies = buildList {
+ for (index in 0 until data.length()) {
+ val item = data.optJSONObject(index) ?: continue
+ val type = item.optString("type").trim().lowercase()
+ if (type !in setOf("https", "ssl")) {
+ skippedNonHttps++
+ continue
+ }
+ parseProxy(item, index + 1)?.let(::add)
+ }
+ }
+ require(proxies.isNotEmpty()) {
+ if (skippedNonHttps > 0) "The FoxyProxy configuration contains no HTTPS proxies"
+ else "The FoxyProxy configuration contains no usable proxies"
+ }
+ ProxyListImportResult(proxies, skippedNonHttps)
+ }
+
+ private fun parseProxy(item: JSONObject, position: Int): ImportedProxy? {
+ val host = item.limitedString("hostname", 253).trim().ifEmpty {
+ item.limitedString("address", 253).trim()
+ }
+ require(host.isNotEmpty() && !host.contains(Regex("[/:\\s]"))) {
+ "FoxyProxy entry $position has an invalid hostname"
+ }
+ val port = when (val value = item.opt("port")) {
+ is Number -> value.toInt()
+ is String -> value.toIntOrNull()
+ else -> null
+ }?.takeIf { it in 1..65535 } ?: 443
+ val countryCode = item.optString("cc").trim().uppercase()
+ .takeIf { it.matches(Regex("[A-Z]{2}")) }.orEmpty()
+ return ImportedProxy(
+ name = item.limitedString("title", 256).trim(),
+ countryCode = countryCode,
+ config = ProxyConfig(
+ host = host,
+ port = port,
+ username = item.limitedString("username", 4_096),
+ password = item.limitedString("password", 16_384),
+ ),
+ )
+ }
+
+ private fun JSONObject.limitedString(name: String, maxLength: Int): String = optString(name).also {
+ require(it.length <= maxLength) { "$name is too long" }
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/data/LimitedInput.kt b/app/src/main/java/net/megaproxy487/data/LimitedInput.kt
new file mode 100644
index 0000000..e279dbf
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/data/LimitedInput.kt
@@ -0,0 +1,22 @@
+package net.megaproxy487.data
+
+import java.io.ByteArrayOutputStream
+import java.io.InputStream
+
+const val MAX_CONFIG_FILE_BYTES = 4 * 1024 * 1024
+const val MAX_IMPORTED_PROFILES = 1_000
+const val MAX_IMPORTED_PACKAGES = 10_000
+
+fun InputStream.readConfigText(): String {
+ val output = ByteArrayOutputStream(64 * 1024)
+ val buffer = ByteArray(16 * 1024)
+ var total = 0
+ while (true) {
+ val count = read(buffer)
+ if (count < 0) break
+ total += count
+ require(total <= MAX_CONFIG_FILE_BYTES) { "Configuration file is larger than 4 MB" }
+ output.write(buffer, 0, count)
+ }
+ return output.toString(Charsets.UTF_8.name())
+}
diff --git a/app/src/main/java/net/megaproxy487/data/PrivateKeyImport.kt b/app/src/main/java/net/megaproxy487/data/PrivateKeyImport.kt
new file mode 100644
index 0000000..cee2182
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/data/PrivateKeyImport.kt
@@ -0,0 +1,41 @@
+package net.megaproxy487.data
+
+import java.io.ByteArrayOutputStream
+import java.io.InputStream
+
+const val MAX_PRIVATE_KEY_FILE_BYTES = 64 * 1024
+
+private val supportedPrivateKeyTypes = setOf(
+ "OPENSSH PRIVATE KEY",
+ "PRIVATE KEY",
+ "RSA PRIVATE KEY",
+ "EC PRIVATE KEY",
+ "DSA PRIVATE KEY",
+)
+
+fun InputStream.readPrivateKeyText(): String {
+ val output = ByteArrayOutputStream(8 * 1024)
+ val buffer = ByteArray(8 * 1024)
+ var total = 0
+ while (true) {
+ val count = read(buffer)
+ if (count < 0) break
+ total += count
+ require(total <= MAX_PRIVATE_KEY_FILE_BYTES) { "Private key file is larger than 64 KiB" }
+ output.write(buffer, 0, count)
+ }
+
+ val text = output.toString(Charsets.UTF_8.name()).removePrefix("\uFEFF").trim()
+ require(text.isNotEmpty()) { "Private key file is empty" }
+ require(!text.contains("BEGIN ENCRYPTED PRIVATE KEY") &&
+ !text.contains(Regex("Proc-Type:\\s*4,ENCRYPTED", RegexOption.IGNORE_CASE))) {
+ "Passphrase-protected private keys are not supported"
+ }
+
+ val begin = Regex("^-----BEGIN ([A-Z0-9 ]+)-----", RegexOption.MULTILINE).find(text)
+ ?: throw IllegalArgumentException("File does not contain a PEM or OpenSSH private key")
+ val type = begin.groupValues[1]
+ require(type in supportedPrivateKeyTypes) { "Unsupported private key format: $type" }
+ require(text.contains("-----END $type-----")) { "Private key footer is missing" }
+ return "$text\n"
+}
diff --git a/app/src/main/java/net/megaproxy487/data/ProxyListParser.kt b/app/src/main/java/net/megaproxy487/data/ProxyListParser.kt
new file mode 100644
index 0000000..aaafa0b
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/data/ProxyListParser.kt
@@ -0,0 +1,69 @@
+package net.megaproxy487.data
+
+import net.megaproxy487.model.ProxyConfig
+import java.net.URI
+import java.net.URLDecoder
+import java.nio.charset.StandardCharsets
+
+data class ImportedProxy(val name: String, val countryCode: String, val config: ProxyConfig)
+data class ProxyListImportResult(val proxies: List, val skippedNonHttps: Int)
+
+object ProxyListParser {
+ fun parse(text: String): Result = runCatching {
+ val lines = text.lineSequence().map(String::trim)
+ .filter { it.isNotEmpty() && !it.startsWith("#") }
+ .take(MAX_IMPORTED_PROFILES + 1)
+ .toList()
+ require(lines.isNotEmpty()) { "The proxy list is empty" }
+ require(lines.size <= MAX_IMPORTED_PROFILES) { "The proxy list contains more than $MAX_IMPORTED_PROFILES entries" }
+ var skippedNonHttps = 0
+ val proxies = lines.mapIndexedNotNull { index, line ->
+ val uri = runCatching { URI(line) }.getOrElse { error("Line ${index + 1} is not a valid URI") }
+ if (!uri.scheme.equals("https", ignoreCase = true)) {
+ skippedNonHttps++
+ null
+ } else {
+ parseLine(uri, index + 1)
+ }
+ }
+ ProxyListImportResult(proxies, skippedNonHttps)
+ }
+
+ private fun parseLine(uri: URI, lineNumber: Int): ImportedProxy {
+ require(uri.toString().length <= 64 * 1024) { "Line $lineNumber is too long" }
+ val host = uri.host?.takeIf(String::isNotBlank) ?: error("Line $lineNumber has no proxy host")
+ val userInfo = uri.rawUserInfo ?: error("Line $lineNumber has no Basic Auth credentials")
+ val separator = userInfo.indexOf(':')
+ require(separator >= 0) { "Line $lineNumber has no Basic Auth password" }
+ val username = decodeUriComponent(userInfo.substring(0, separator))
+ val password = decodeUriComponent(userInfo.substring(separator + 1))
+ require(username.length <= 4_096) { "Line $lineNumber has an excessively long username" }
+ require(password.length <= 16_384) { "Line $lineNumber has an excessively long password" }
+ val query = parseQuery(uri.rawQuery)
+ return ImportedProxy(
+ name = query["title"].orEmpty(),
+ countryCode = query["cc"].orEmpty().uppercase()
+ .takeIf { it.matches(Regex("[A-Z]{2}")) }.orEmpty(),
+ config = ProxyConfig(
+ host = host,
+ port = if (uri.port == -1) 443 else uri.port,
+ username = username,
+ password = password,
+ ),
+ )
+ }
+
+ private fun parseQuery(rawQuery: String?): Map = rawQuery.orEmpty()
+ .splitToSequence('&').filter(String::isNotEmpty).take(101).toList().also {
+ require(it.size <= 100) { "A proxy URL contains too many query parameters" }
+ }.associate { pair ->
+ val separator = pair.indexOf('=')
+ if (separator < 0) decode(pair) to ""
+ else decode(pair.substring(0, separator)) to decode(pair.substring(separator + 1))
+ }
+
+ private fun decode(value: String): String =
+ URLDecoder.decode(value, StandardCharsets.UTF_8.name())
+
+ private fun decodeUriComponent(value: String): String = decode(value.replace("+", "%2B"))
+}
diff --git a/app/src/main/java/net/megaproxy487/data/SuperProxyParser.kt b/app/src/main/java/net/megaproxy487/data/SuperProxyParser.kt
new file mode 100644
index 0000000..8d1a847
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/data/SuperProxyParser.kt
@@ -0,0 +1,15 @@
+package net.megaproxy487.data
+
+object SuperProxyParser {
+ const val HEADER = "# superproxy:proxylist:v1"
+
+ fun matches(text: String): Boolean =
+ text.lineSequence().map(String::trim).firstOrNull(String::isNotEmpty) == HEADER
+
+ fun parse(text: String): Result = runCatching {
+ require(matches(text)) { "This is not a supported Super Proxy configuration file" }
+ // Super Proxy's optional `fingerprint` query parameter is a certificate pin.
+ // MegaProxy intentionally relies on Android's trust store and does not import pins.
+ ProxyListParser.parse(text).getOrThrow()
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/model/ProfileColorMatcher.kt b/app/src/main/java/net/megaproxy487/model/ProfileColorMatcher.kt
new file mode 100644
index 0000000..9594b7d
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/model/ProfileColorMatcher.kt
@@ -0,0 +1,50 @@
+package net.megaproxy487.model
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+
+object ProfileColorMatcher {
+ fun colorIndexForFlag(countryCode: String, fallback: Int): Int {
+ val profile = ProxyProfile(id = "color-sample", colorIndex = fallback, countryCode = countryCode)
+ val flag = profile.flagEmoji
+ if (flag.isEmpty()) return fallback
+
+ val bitmap = Bitmap.createBitmap(BITMAP_SIZE, BITMAP_SIZE, Bitmap.Config.ARGB_8888)
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ textSize = TEXT_SIZE
+ textAlign = Paint.Align.CENTER
+ }
+ Canvas(bitmap).drawText(flag, BITMAP_SIZE / 2f, BASELINE, paint)
+
+ val votes = IntArray(ProfileColors.argb.size)
+ val pixels = IntArray(BITMAP_SIZE * BITMAP_SIZE)
+ bitmap.getPixels(pixels, 0, BITMAP_SIZE, 0, 0, BITMAP_SIZE, BITMAP_SIZE)
+ bitmap.recycle()
+ pixels.forEach { pixel ->
+ if (Color.alpha(pixel) < 96) return@forEach
+ val red = Color.red(pixel)
+ val green = Color.green(pixel)
+ val blue = Color.blue(pixel)
+ val max = maxOf(red, green, blue)
+ val min = minOf(red, green, blue)
+ if (max > 242 && min > 242) return@forEach
+ if (max - min < 24) return@forEach
+ val nearest = ProfileColors.argb.indices.minByOrNull { index ->
+ val candidate = ProfileColors.argb[index].toInt()
+ squared(red - Color.red(candidate)) +
+ squared(green - Color.green(candidate)) +
+ squared(blue - Color.blue(candidate))
+ } ?: return@forEach
+ votes[nearest]++
+ }
+ return votes.indices.maxByOrNull { votes[it] }?.takeIf { votes[it] > 0 } ?: fallback
+ }
+
+ private fun squared(value: Int) = value * value
+
+ private const val BITMAP_SIZE = 96
+ private const val TEXT_SIZE = 72f
+ private const val BASELINE = 72f
+}
diff --git a/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt b/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt
new file mode 100644
index 0000000..f6c0a19
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt
@@ -0,0 +1,196 @@
+package net.megaproxy487.model
+
+data class ProxyConfig(
+ val type: ProxyType = ProxyType.HTTPS,
+ val host: String = "",
+ val port: Int = 443,
+ val username: String = "",
+ val password: String = "",
+ val allowInvalidProxyCertificate: Boolean = false,
+ val profile: TlsProfile = TlsProfile.DEFAULT,
+ val customJa3: String = "",
+ val dnsProvider: DnsProvider = DnsProvider.CLOUDFLARE,
+ val customDohUrl: String = "",
+ val selectedPackages: Set = emptySet(),
+ val allowIpv6: Boolean = false,
+ val routeAllApps: Boolean = true,
+ val bypassLocalNetworks: Boolean = true,
+ val resolvedProxyIp: String = "",
+ val privateKey: String = "",
+ val sshProfile: SshProfile = SshProfile.DEFAULT,
+ val trustedHostKey: String = "",
+ val acceptAnyHostKey: Boolean = false,
+ val jumpHost: String = "",
+ val jumpPort: Int = 22,
+ val jumpUsername: String = "",
+ val jumpPassword: String = "",
+ val jumpPrivateKey: String = "",
+ val jumpTrustedHostKey: String = "",
+ val jumpAcceptAnyHostKey: Boolean = false,
+ val sameJumpAuthentication: Boolean = true,
+ val resolvedJumpIp: String = "",
+ val sshAuthMode: SshAuthMode = SshAuthMode.AUTO,
+ val sshKeepaliveSeconds: Int = 30,
+ val sshMaxChannels: Int = 32,
+ val sshRotationMinutes: Int = 0,
+ val sshRotationMb: Int = 0,
+) {
+ fun connectionValidationError(): String? = when {
+ host.isBlank() -> if (type == ProxyType.HTTPS) "Enter the proxy hostname" else "Enter the SSH hostname"
+ host.contains(Regex("[/:\\s]")) -> "Enter a hostname without a scheme or path"
+ port !in 1..65535 -> "Port must be between 1 and 65535"
+ type == ProxyType.HTTPS && username.isBlank() -> "Enter the Basic Auth username"
+ type == ProxyType.HTTPS && password.isBlank() -> "Enter the Basic Auth password"
+ type != ProxyType.HTTPS && username.isBlank() -> "Enter the SSH username"
+ type == ProxyType.SSH_JUMP && jumpHost.isBlank() -> "Enter the jump host"
+ type == ProxyType.SSH_JUMP && jumpHost.contains(Regex("[/:\\s]")) -> "Enter a jump hostname without a scheme or path"
+ type == ProxyType.SSH_JUMP && jumpPort !in 1..65535 -> "Jump port must be between 1 and 65535"
+ type == ProxyType.SSH_JUMP && !sameJumpAuthentication && jumpUsername.isBlank() -> "Enter the jump SSH username"
+ type == ProxyType.HTTPS && profile == TlsProfile.CUSTOM && Ja3Spec.parse(customJa3) == null ->
+ "JA3 must contain five fields: version,ciphers,extensions,groups,points"
+ dnsProvider == DnsProvider.CUSTOM && !customDohUrl.matches(Regex("https://[^/\\s]+/.+")) ->
+ "The custom DoH URL must start with https://"
+ else -> null
+ }
+
+ fun validationError(): String? = connectionValidationError()
+}
+
+enum class ProxyType(val title: String, val defaultPort: Int) {
+ HTTPS("HTTPS", 443),
+ SSH("SSH", 22),
+ SSH_JUMP("SSH with Jump", 22),
+}
+
+enum class SshProfile(val title: String) {
+ DEFAULT("Default (currently OpenSSH/Termux)"),
+ OPENSSH_TERMUX("OpenSSH / Termux"),
+ CONNECTBOT("ConnectBot"),
+ JUICESSH("JuiceSSH"),
+ TERMIUS_ANDROID("Termius Android"),
+}
+
+enum class SshAuthMode(val title: String) {
+ AUTO("Auto: key, then password"),
+ PASSWORD_ONLY("Password only"),
+ KEY_ONLY("Private key only"),
+}
+
+enum class FailoverMode(val title: String) {
+ DISABLED("Disabled"),
+ SELECTED("Selected profiles"),
+ ALL("All profiles"),
+}
+
+data class ProxyProfile(
+ val id: String,
+ val name: String = "",
+ val colorIndex: Int,
+ val countryCode: String = "",
+ val config: ProxyConfig = ProxyConfig(),
+) {
+ val displayName: String
+ get() = name.trim().ifEmpty { config.host.trim().ifEmpty { "New profile" } }
+
+ val flagEmoji: String
+ get() {
+ val code = countryCode.uppercase()
+ if (!code.matches(Regex("[A-Z]{2}"))) return ""
+ return code.map { character ->
+ String(Character.toChars(0x1F1E6 + character.code - 'A'.code))
+ }.joinToString("")
+ }
+
+ val displayNameWithFlag: String
+ get() = listOf(flagEmoji, displayName).filter(String::isNotEmpty).joinToString(" ")
+}
+
+object ProfileColors {
+ // Material Design 500 palette, excluding colors with poor contrast in light themes.
+ val argb = listOf(
+ 0xFFF44336, 0xFFE91E63, 0xFF9C27B0, 0xFF673AB7,
+ 0xFF3F51B5, 0xFF2196F3, 0xFF009688, 0xFF4CAF50,
+ 0xFF8BC34A, 0xFFFF9800, 0xFFFF5722, 0xFF795548,
+ )
+}
+
+enum class DnsProvider(val title: String, val url: String) {
+ CLOUDFLARE("Cloudflare", "https://cloudflare-dns.com/dns-query"),
+ GOOGLE("Google", "https://dns.google/dns-query"),
+ QUAD9("Quad9", "https://dns.quad9.net/dns-query"),
+ YANDEX("Yandex Basic", "https://common.dot.dns.yandex.net/dns-query"),
+ YANDEX_SAFE("Yandex Safe", "https://safe.dot.dns.yandex.net/dns-query"),
+ YANDEX_FAMILY("Yandex Family", "https://family.dot.dns.yandex.net/dns-query"),
+ CUSTOM("Custom DoH URL", ""),
+
+ ;
+
+ fun fallbackUrls(): List {
+ // Never bypass an explicitly selected content-filtering policy.
+ if (this == CUSTOM || this == YANDEX_SAFE || this == YANDEX_FAMILY) return emptyList()
+ return entries.asSequence()
+ .filter { it != this && it != CUSTOM && it != YANDEX_SAFE && it != YANDEX_FAMILY }
+ .map(DnsProvider::url)
+ .filter(String::isNotEmpty)
+ .toList()
+ }
+}
+
+enum class TlsProfile(val title: String, val available: Boolean = true) {
+ DEFAULT("Default (currently Chrome Android)"),
+ CHROME_ANDROID("Chrome Android 133 (uTLS)"),
+ FIREFOX_ANDROID("Firefox Android 120 (uTLS)"),
+ EDGE_ANDROID("Edge Android (awaiting a verified profile)", false),
+ RANDOMIZED("Randomized compatible"),
+ SAMSUNG_INTERNET("Samsung Internet (awaiting a verified profile)", false),
+ YANDEX_BROWSER("Yandex Browser (awaiting a verified profile)", false),
+ CUSTOM("Manual JA3"),
+}
+
+data class GlobalConnectionSettings(
+ val tlsProfile: TlsProfile = TlsProfile.DEFAULT,
+ val sshProfile: SshProfile = SshProfile.DEFAULT,
+ val sshAuthMode: SshAuthMode = SshAuthMode.AUTO,
+ val sshKeepaliveSeconds: Int = 30,
+ val sshMaxChannels: Int = 32,
+ val sshRotationMinutes: Int = 0,
+ val sshRotationMb: Int = 0,
+ val failoverMode: FailoverMode = FailoverMode.DISABLED,
+ val failoverProfileIds: List = emptyList(),
+ val customJa3: String = "",
+ val selectedPackages: Set = emptySet(),
+ val routeAllApps: Boolean = true,
+ val bypassLocalNetworks: Boolean = true,
+) {
+ fun applyTo(config: ProxyConfig): ProxyConfig = config.copy(
+ profile = if (tlsProfile == TlsProfile.DEFAULT) TlsProfile.CHROME_ANDROID else tlsProfile,
+ customJa3 = customJa3,
+ sshProfile = sshProfile,
+ sshAuthMode = sshAuthMode,
+ sshKeepaliveSeconds = sshKeepaliveSeconds,
+ sshMaxChannels = sshMaxChannels,
+ sshRotationMinutes = sshRotationMinutes,
+ sshRotationMb = sshRotationMb,
+ selectedPackages = selectedPackages,
+ routeAllApps = routeAllApps,
+ bypassLocalNetworks = bypassLocalNetworks,
+ )
+}
+
+data class Ja3Spec(
+ val tlsVersion: Int,
+ val cipherSuites: List,
+ val extensions: List,
+ val supportedGroups: List,
+ val ecPointFormats: List,
+) {
+ companion object {
+ fun parse(value: String): Ja3Spec? = runCatching {
+ val fields = value.trim().split(',')
+ require(fields.size == 5)
+ fun numbers(field: String): List = if (field.isBlank()) emptyList() else
+ field.split('-').map { it.toInt().also { number -> require(number in 0..65535) } }
+ Ja3Spec(fields[0].toInt(), numbers(fields[1]), numbers(fields[2]), numbers(fields[3]), numbers(fields[4]))
+ }.getOrNull()
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/ui/theme/MegaProxyTheme.kt b/app/src/main/java/net/megaproxy487/ui/theme/MegaProxyTheme.kt
new file mode 100644
index 0000000..47cd8d1
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/ui/theme/MegaProxyTheme.kt
@@ -0,0 +1,27 @@
+package net.megaproxy487.ui.theme
+
+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.platform.LocalContext
+
+@Composable
+fun MegaProxyTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit,
+) {
+ val context = LocalContext.current
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && darkTheme -> dynamicDarkColorScheme(context)
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> dynamicLightColorScheme(context)
+ darkTheme -> darkColorScheme()
+ else -> lightColorScheme()
+ }
+ MaterialTheme(colorScheme = colorScheme, content = content)
+}
diff --git a/app/src/main/java/net/megaproxy487/vpn/AlwaysOnVpnStatus.kt b/app/src/main/java/net/megaproxy487/vpn/AlwaysOnVpnStatus.kt
new file mode 100644
index 0000000..ecb8a81
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/vpn/AlwaysOnVpnStatus.kt
@@ -0,0 +1,26 @@
+package net.megaproxy487.vpn
+
+import android.content.Context
+import android.provider.Settings
+
+data class AlwaysOnVpnStatus(
+ val enabled: Boolean,
+ val lockdown: Boolean,
+ val configuredPackage: String?,
+)
+
+val AlwaysOnVpnStatus.hasOtherProvider: Boolean
+ get() = configuredPackage != null && !enabled
+
+fun readAlwaysOnVpnStatus(context: Context): AlwaysOnVpnStatus {
+ val configuredPackage = runCatching {
+ Settings.Secure.getString(context.contentResolver, "always_on_vpn_app")
+ ?.takeIf(String::isNotBlank)
+ }.getOrNull()
+ val enabled = configuredPackage?.let { it == context.packageName }
+ ?: ProxyVpnService.isAlwaysOnMode
+ val lockdown = enabled && runCatching {
+ Settings.Secure.getInt(context.contentResolver, "always_on_vpn_lockdown", 0) == 1
+ }.getOrDefault(ProxyVpnService.isLockdownMode)
+ return AlwaysOnVpnStatus(enabled, lockdown, configuredPackage)
+}
diff --git a/app/src/main/java/net/megaproxy487/vpn/BlockingDetection.kt b/app/src/main/java/net/megaproxy487/vpn/BlockingDetection.kt
new file mode 100644
index 0000000..0f88bbd
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/vpn/BlockingDetection.kt
@@ -0,0 +1,31 @@
+package net.megaproxy487.vpn
+
+enum class BlockingSignal {
+ TCP_TIMEOUT,
+ TLS_HANDSHAKE_TIMEOUT,
+ TLS_HANDSHAKE_RESET,
+ CONNECT_RESPONSE_TIMEOUT,
+ CONNECT_RESPONSE_RESET,
+ SSH_HANDSHAKE_TIMEOUT,
+ CONNECTION_RESET,
+ SILENT_DROP,
+}
+
+object BlockingDetection {
+ fun classify(message: String): BlockingSignal? {
+ val value = message.lowercase()
+ if (listOf("authenticate", "credentials", "host_key", "certificate", "x509", "invalid ", "refused").any(value::contains)) return null
+ return when {
+ "stage=tls_handshake" in value && "reason=timeout" in value -> BlockingSignal.TLS_HANDSHAKE_TIMEOUT
+ "stage=tls_handshake" in value && ("reason=reset" in value || "reason=eof" in value) -> BlockingSignal.TLS_HANDSHAKE_RESET
+ "stage=connect_response" in value && "reason=timeout" in value -> BlockingSignal.CONNECT_RESPONSE_TIMEOUT
+ "stage=connect_response" in value && ("reason=reset" in value || "reason=eof" in value) -> BlockingSignal.CONNECT_RESPONSE_RESET
+ "ssh" in value && "handshake" in value && ("timeout" in value || "timed out" in value) -> BlockingSignal.SSH_HANDSHAKE_TIMEOUT
+ "handshake" in value && "timeout" in value -> BlockingSignal.TLS_HANDSHAKE_TIMEOUT
+ "reset" in value || "broken pipe" in value -> BlockingSignal.CONNECTION_RESET
+ "i/o timeout" in value || "timed out" in value -> BlockingSignal.TCP_TIMEOUT
+ "unexpected eof" in value || "silent" in value -> BlockingSignal.SILENT_DROP
+ else -> null
+ }
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/vpn/BootstrapAddressCache.kt b/app/src/main/java/net/megaproxy487/vpn/BootstrapAddressCache.kt
new file mode 100644
index 0000000..003fdfa
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/vpn/BootstrapAddressCache.kt
@@ -0,0 +1,51 @@
+package net.megaproxy487.vpn
+
+import android.content.Context
+import org.json.JSONObject
+import java.security.MessageDigest
+
+class BootstrapAddressCache(context: Context) {
+ private val preferences = context.getSharedPreferences("bootstrap_address_cache", Context.MODE_PRIVATE)
+
+ fun get(host: String, now: Long = System.currentTimeMillis()): String? {
+ val value = preferences.getString(key(host), null) ?: return null
+ return runCatching {
+ val json = JSONObject(value)
+ val address = json.getString("address")
+ val savedAt = json.getLong("savedAt")
+ address.takeIf { now - savedAt in 0..MAX_AGE_MS && isNumericIpv4(it) }
+ }.getOrNull()
+ }
+
+ fun put(host: String, address: String, now: Long = System.currentTimeMillis()) {
+ if (!isNumericIpv4(address)) return
+ preferences.edit().putString(
+ key(host),
+ JSONObject().put("address", address).put("savedAt", now).toString(),
+ ).apply()
+ }
+
+ fun remove(host: String) {
+ preferences.edit().remove(key(host)).apply()
+ }
+
+ private fun key(host: String): String = MessageDigest.getInstance("SHA-256")
+ .digest(host.trim().lowercase().toByteArray(Charsets.UTF_8))
+ .joinToString("") { "%02x".format(it) }
+
+ // Bootstrap DoH deliberately requests an A record. Keeping this parser IPv4-only
+ // avoids both a compatibility dependency on API 29 and accidental DNS lookups.
+ private fun isNumericIpv4(address: String): Boolean {
+ val octets = address.split('.')
+ return octets.size == 4 && octets.all { octet ->
+ octet.isNotEmpty() &&
+ octet.length <= 3 &&
+ octet.all(Char::isDigit) &&
+ octet.toIntOrNull() in 0..255
+ }
+ }
+
+ private companion object {
+ const val MAX_AGE_MS = 7L * 24 * 60 * 60 * 1_000
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/vpn/DiagnosticLog.kt b/app/src/main/java/net/megaproxy487/vpn/DiagnosticLog.kt
new file mode 100644
index 0000000..e463936
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/vpn/DiagnosticLog.kt
@@ -0,0 +1,75 @@
+package net.megaproxy487.vpn
+
+import android.os.Handler
+import android.os.Looper
+import androidx.compose.runtime.mutableStateListOf
+import java.time.LocalTime
+import java.time.format.DateTimeFormatter
+import java.util.concurrent.atomic.AtomicInteger
+
+object DiagnosticLog {
+ fun add(message: String) {
+ // The persistent logger already has its own bounded background queue. Do not
+ // mirror normal VPN traffic into Compose state: native diagnostics can be very
+ // frequent and flooding the main looper makes the entire UI unresponsive.
+ PersistentDiagnosticLog.write(message)
+ }
+
+ fun clear() = Unit
+}
+
+enum class TestState { IDLE, RUNNING, SUCCEEDED, FAILED }
+
+object TestDiagnosticLog {
+ private const val MAX_ENTRIES = 300
+ private val timeFormat = DateTimeFormatter.ofPattern("HH:mm:ss.SSS")
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val pendingUiUpdates = AtomicInteger(0)
+ val entries = mutableStateListOf()
+ private val mutableState = androidx.compose.runtime.mutableStateOf(TestState.IDLE)
+ private val mutableExitIp = androidx.compose.runtime.mutableStateOf(null)
+ val state: androidx.compose.runtime.State = mutableState
+ val exitIp: androidx.compose.runtime.State = mutableExitIp
+
+ private fun onMain(action: () -> Unit) {
+ if (Looper.myLooper() == Looper.getMainLooper()) action()
+ else if (pendingUiUpdates.incrementAndGet() <= MAX_PENDING_UI_UPDATES) {
+ mainHandler.post {
+ try { action() } finally { pendingUiUpdates.decrementAndGet() }
+ }
+ } else pendingUiUpdates.decrementAndGet()
+ }
+
+ fun reset() = onMain {
+ entries.clear()
+ mutableExitIp.value = null
+ mutableState.value = TestState.IDLE
+ }
+
+ fun begin() = onMain {
+ entries.clear()
+ mutableExitIp.value = null
+ mutableState.value = TestState.RUNNING
+ }
+
+ fun add(message: String) {
+ val safeMessage = PrivacyLogSanitizer.sanitize(message)
+ PersistentDiagnosticLog.write("scope=connection_test $safeMessage")
+ onMain {
+ entries.add("${LocalTime.now().format(timeFormat)} $safeMessage")
+ while (entries.size > MAX_ENTRIES) entries.removeAt(0)
+ }
+ }
+
+ fun succeed(ip: String) = onMain {
+ mutableExitIp.value = ip
+ mutableState.value = TestState.SUCCEEDED
+ }
+
+ fun fail(message: String? = null) = onMain {
+ if (message != null) add(message)
+ mutableState.value = TestState.FAILED
+ }
+
+ private const val MAX_PENDING_UI_UPDATES = 512
+}
diff --git a/app/src/main/java/net/megaproxy487/vpn/PersistentDiagnosticLog.kt b/app/src/main/java/net/megaproxy487/vpn/PersistentDiagnosticLog.kt
new file mode 100644
index 0000000..cf5bd6f
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/vpn/PersistentDiagnosticLog.kt
@@ -0,0 +1,204 @@
+package net.megaproxy487.vpn
+
+import android.content.Context
+import android.os.Build
+import java.io.File
+import java.io.OutputStream
+import java.io.RandomAccessFile
+import java.time.Instant
+import java.util.UUID
+import java.util.concurrent.ArrayBlockingQueue
+import java.util.concurrent.ThreadPoolExecutor
+import java.util.concurrent.TimeUnit
+
+object PersistentDiagnosticLog {
+ const val DEFAULT_LIMIT_MB = 3
+ const val MIN_LIMIT_MB = 1
+ const val MAX_LIMIT_MB = 100
+
+ private const val CURRENT_FILE = "diagnostic.log"
+ private const val PREVIOUS_FILE = "diagnostic.1.log"
+ // Native networking can emit diagnostics faster than flash storage can append them.
+ // Keep memory bounded under an error storm; oldest pending diagnostics are expendable.
+ private val executor = ThreadPoolExecutor(
+ 1, 1, 0L, TimeUnit.MILLISECONDS, ArrayBlockingQueue(2_048),
+ { task -> Thread(task, "megaproxy-diagnostic-log").apply { isDaemon = true } },
+ ThreadPoolExecutor.DiscardOldestPolicy(),
+ )
+ private val lock = Any()
+ private val sessionId = UUID.randomUUID().toString().take(8)
+ @Volatile private var directory: File? = null
+ @Volatile private var limitMb = DEFAULT_LIMIT_MB
+
+ fun initialize(context: Context, configuredLimitMb: Int) {
+ directory = File(context.filesDir, "logs").also { it.mkdirs() }
+ limitMb = configuredLimitMb.coerceIn(MIN_LIMIT_MB, MAX_LIMIT_MB)
+ enforceLimitAsync()
+ write("event=logger_initialized session=$sessionId api=${Build.VERSION.SDK_INT} limit_mb=$limitMb")
+ }
+
+ fun setLimitMb(value: Int) {
+ limitMb = value.coerceIn(MIN_LIMIT_MB, MAX_LIMIT_MB)
+ enforceLimitAsync()
+ write("event=log_limit_changed limit_mb=$limitMb")
+ }
+
+ fun write(rawMessage: String) {
+ val safeMessage = PrivacyLogSanitizer.sanitize(rawMessage)
+ val line = "${Instant.now()} session=$sessionId $safeMessage\n"
+ executor.execute {
+ synchronized(lock) {
+ val logDirectory = directory ?: return@synchronized
+ rotateIfNeeded(logDirectory, line.toByteArray().size.toLong())
+ File(logDirectory, CURRENT_FILE).appendText(line, Charsets.UTF_8)
+ }
+ }
+ }
+
+ /** Crash-path write: deliberately synchronous so it survives immediate process termination. */
+ fun writeCrash(thread: Thread, throwable: Throwable) = synchronized(lock) {
+ val logDirectory = directory ?: return
+ val entry = buildString {
+ append("${Instant.now()} session=$sessionId event=uncaught_exception api=${Build.VERSION.SDK_INT}")
+ append(" thread=${PrivacyLogSanitizer.sanitize(thread.name)}\n")
+ var current: Throwable? = throwable
+ var causeDepth = 0
+ while (current != null && causeDepth < 8) {
+ val prefix = if (causeDepth == 0) "exception" else "cause_$causeDepth"
+ append("$prefix=${safeCrashClass(current.javaClass.name)}")
+ append(" message=${PrivacyLogSanitizer.sanitize(current.message.orEmpty())}\n")
+ current.stackTrace.take(256).forEach { frame ->
+ append("at ${safeCrashClass(frame.className)}.${frame.methodName}")
+ append("(${frame.fileName ?: "unknown"}:${frame.lineNumber})\n")
+ }
+ current = current.cause
+ causeDepth++
+ }
+ append("${Instant.now()} session=$sessionId event=crash_end\n")
+ }
+ rotateIfNeeded(logDirectory, entry.toByteArray(Charsets.UTF_8).size.toLong())
+ File(logDirectory, CURRENT_FILE).appendText(entry, Charsets.UTF_8)
+ }
+
+ fun readTail(maxBytes: Int): String = synchronized(lock) {
+ val logDirectory = directory ?: return ""
+ val files = listOf(PREVIOUS_FILE, CURRENT_FILE).map { File(logDirectory, it) }.filter(File::isFile)
+ var remaining = maxBytes.coerceAtLeast(1)
+ val chunks = ArrayDeque()
+ for (file in files.asReversed()) {
+ if (remaining == 0) break
+ val count = minOf(file.length(), remaining.toLong()).toInt()
+ if (count == 0) continue
+ val chunk = ByteArray(count)
+ RandomAccessFile(file, "r").use { input ->
+ input.seek(file.length() - count)
+ input.readFully(chunk)
+ }
+ chunks.addFirst(chunk)
+ remaining -= count
+ }
+ val bytes = chunks.fold(ByteArray(0)) { result, chunk -> result + chunk }
+ val text = bytes.toString(Charsets.UTF_8)
+ if (remaining == 0) text.substringAfter('\n', "") else text
+ }
+
+ fun copyTo(output: OutputStream) {
+ // A bounded logger queue may discard diagnostics, but an export must never lose
+ // its task and then wait forever on a discarded Future.
+ synchronized(lock) {
+ val logDirectory = directory ?: return
+ listOf(PREVIOUS_FILE, CURRENT_FILE).forEach { name ->
+ val file = File(logDirectory, name)
+ if (file.isFile) file.inputStream().buffered().use { it.copyTo(output) }
+ }
+ }
+ }
+
+ fun clear() {
+ executor.execute {
+ synchronized(lock) {
+ val logDirectory = directory ?: return@synchronized
+ File(logDirectory, CURRENT_FILE).delete()
+ File(logDirectory, PREVIOUS_FILE).delete()
+ }
+ write("event=log_cleared")
+ }
+ }
+
+ private fun rotateIfNeeded(logDirectory: File, incomingBytes: Long) {
+ val segmentLimit = segmentLimitBytes()
+ val current = File(logDirectory, CURRENT_FILE)
+ if (current.length() + incomingBytes <= segmentLimit) return
+ val previous = File(logDirectory, PREVIOUS_FILE)
+ previous.delete()
+ if (current.isFile) current.renameTo(previous)
+ if (incomingBytes > segmentLimit) current.writeText("", Charsets.UTF_8)
+ }
+
+ private fun enforceLimitAsync() {
+ executor.execute {
+ synchronized(lock) {
+ val logDirectory = directory ?: return@synchronized
+ val segmentLimit = segmentLimitBytes()
+ trimToTail(File(logDirectory, PREVIOUS_FILE), segmentLimit)
+ trimToTail(File(logDirectory, CURRENT_FILE), segmentLimit)
+ }
+ }
+ }
+
+ private fun segmentLimitBytes(): Long = limitMb.toLong() * 1024 * 1024 / 2
+
+ private fun trimToTail(file: File, maxBytes: Long) {
+ if (!file.isFile || file.length() <= maxBytes) return
+ val temporary = File(file.parentFile, "${file.name}.trim")
+ RandomAccessFile(file, "r").use { input ->
+ input.seek(file.length() - maxBytes)
+ while (input.filePointer < input.length() && input.read() != '\n'.code) Unit
+ temporary.outputStream().buffered().use { output ->
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ output.write(buffer, 0, count)
+ }
+ }
+ }
+ file.delete()
+ temporary.renameTo(file)
+ }
+
+ private fun safeCrashClass(name: String): String = when {
+ name.startsWith("net.megaproxy487.") ||
+ name.startsWith("android.") || name.startsWith("androidx.") ||
+ name.startsWith("java.") || name.startsWith("kotlin.") ||
+ name.startsWith("kotlinx.") -> name
+ else -> "[external]"
+ }
+}
+
+object PrivacyLogSanitizer {
+ private val email = Regex("(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}")
+ private val url = Regex("(?i)https?://[^\\s]+")
+ private val ipv4 = Regex("(? Unit): String?
+ fun start(tunFd: Int, config: ProxyConfig, status: (String) -> Unit): Boolean
+ fun test(config: ProxyConfig, status: (String) -> Unit): String?
+ fun stop()
+}
+
+data class NativeConnectionStats(
+ val downloadBytes: Long,
+ val uploadBytes: Long,
+ val proxyLatencyMillis: Double,
+ val proxyLatencyAtMillis: Long,
+ val connectionErrorRate: Double,
+ val connectionSamples: Int,
+ val totalOutcomes: Long,
+)
+
+object ConnectionStatsReader {
+ fun snapshot(): NativeConnectionStats? = runCatching {
+ val raw = Class.forName("mobile.Mobile").getMethod("getStats").invoke(null) as String
+ val json = JSONObject(raw)
+ NativeConnectionStats(
+ downloadBytes = json.getLong("downloadBytes"),
+ uploadBytes = json.getLong("uploadBytes"),
+ proxyLatencyMillis = json.getDouble("proxyLatencyMillis"),
+ proxyLatencyAtMillis = json.getLong("proxyLatencyAtMillis"),
+ connectionErrorRate = json.getDouble("connectionErrorRate"),
+ connectionSamples = json.getInt("connectionSamples"),
+ totalOutcomes = json.getLong("totalOutcomes"),
+ )
+ }.getOrNull()
+}
+
+/** JNI boundary for the Go/uTLS userspace TCP/IP stack. */
+class NativeProxyCore(
+ private val vpnService: VpnService,
+ private val diagnostics: (String) -> Unit = DiagnosticLog::add,
+) : ProxyCore {
+ private fun protectSocket(fd: Long): Boolean = vpnService.protect(fd.toInt())
+
+ private fun configJson(config: ProxyConfig) = JSONObject()
+ .put("type", config.type.name)
+ .put("host", config.host.trim())
+ .put("dialHost", config.resolvedProxyIp)
+ .put("port", config.port)
+ .put("username", config.username)
+ .put("password", config.password)
+ .put("privateKey", config.privateKey)
+ .put("sshProfile", config.sshProfile.name)
+ .put("trustedHostKey", config.trustedHostKey)
+ .put("acceptAnyHostKey", config.acceptAnyHostKey)
+ .put("jumpHost", config.jumpHost.trim())
+ .put("jumpDialHost", config.resolvedJumpIp)
+ .put("jumpPort", config.jumpPort)
+ .put("jumpUsername", config.jumpUsername)
+ .put("jumpPassword", config.jumpPassword)
+ .put("jumpPrivateKey", config.jumpPrivateKey)
+ .put("jumpTrustedHostKey", config.jumpTrustedHostKey)
+ .put("jumpAcceptAnyHostKey", config.jumpAcceptAnyHostKey)
+ .put("sameJumpAuthentication", config.sameJumpAuthentication)
+ .put("sshAuthMode", config.sshAuthMode.name)
+ .put("sshKeepaliveSeconds", config.sshKeepaliveSeconds)
+ .put("sshMaxChannels", config.sshMaxChannels)
+ .put("sshRotationMinutes", config.sshRotationMinutes)
+ .put("sshRotationMb", config.sshRotationMb)
+ .put("allowInvalidProxyCertificate", config.allowInvalidProxyCertificate)
+ .put("profile", config.profile.name)
+ .put("customJa3", config.customJa3.trim())
+ .put("dohUrl", if (config.dnsProvider.url.isNotEmpty()) config.dnsProvider.url else config.customDohUrl.trim())
+ .put("dohFallbackUrls", JSONArray(config.dnsProvider.fallbackUrls()))
+ .put("allowIpv6", config.allowIpv6)
+ .put("bypassLocalNetworks", config.bypassLocalNetworks)
+ .toString()
+
+ private fun callback(type: Class<*>, methodName: String, callback: (Array?) -> Any?) =
+ Proxy.newProxyInstance(type.classLoader, arrayOf(type)) { _, method, args ->
+ if (method.name == methodName) callback(args) else error("Unknown native callback ${method.name}")
+ }
+
+ override fun resolveProxy(host: String, status: (String) -> Unit): String? = runCatching {
+ val mobile = Class.forName("mobile.Mobile")
+ val protectorType = Class.forName("mobile.Protector")
+ val reporterType = Class.forName("mobile.Reporter")
+ val protector = callback(protectorType, "protect") { protectSocket(it!![0] as Long) }
+ val reporter = callback(reporterType, "report") { diagnostics(it!![0] as String); null }
+ mobile.getMethod("resolveProxy", String::class.java, protectorType, reporterType)
+ .invoke(null, host, protector, reporter) as String
+ }.onFailure {
+ val message = it.cause?.message ?: it.message ?: "Unknown native error"
+ diagnostics("event=bootstrap_dns result=failed detail=$message")
+ status("Proxy DNS failed: $message")
+ }.getOrNull()
+
+ override fun start(tunFd: Int, config: ProxyConfig, status: (String) -> Unit): Boolean {
+ var detachedFd: Int? = null
+ return runCatching {
+ val mobile = Class.forName("mobile.Mobile")
+ val protectorType = Class.forName("mobile.Protector")
+ val reporterType = Class.forName("mobile.Reporter")
+ val protector = callback(protectorType, "protect") { protectSocket(it!![0] as Long) }
+ val reporter = callback(reporterType, "report") {
+ val message = it!![0] as String
+ diagnostics(message)
+ VpnRuntimeState.observeDiagnostic(message)
+ if ("SSH_HOST_KEY_" in message || "dpi_hint=possible" in message) status(message)
+ null
+ }
+ detachedFd = ParcelFileDescriptor.fromFd(tunFd).detachFd()
+ val startMethod = mobile.getMethod(
+ "start", Long::class.javaPrimitiveType, String::class.java, protectorType, reporterType,
+ )
+ val goFd = detachedFd!!
+ detachedFd = null // Start's contract takes ownership, including error paths.
+ startMethod.invoke(null, goFd.toLong(), configJson(config), protector, reporter)
+ status("TCP is protected by ${config.type.title}")
+ true
+ }.getOrElse {
+ detachedFd?.let { fd -> runCatching { ParcelFileDescriptor.adoptFd(fd).close() } }
+ status(if (it is ClassNotFoundException) "Add app/libs/megaproxy.aar" else "Native core error: ${it.cause?.message ?: it.message}")
+ false
+ }
+ }
+
+ override fun test(config: ProxyConfig, status: (String) -> Unit): String? = runCatching {
+ val mobile = Class.forName("mobile.Mobile")
+ val protectorType = Class.forName("mobile.Protector")
+ val reporterType = Class.forName("mobile.Reporter")
+ val protector = callback(protectorType, "protect") { protectSocket(it!![0] as Long) }
+ val reporter = callback(reporterType, "report") {
+ val message = it!![0] as String
+ diagnostics(message)
+ if ("SSH_HOST_KEY_" in message) status(message)
+ null
+ }
+ mobile.getMethod("testConnection", String::class.java, protectorType, reporterType)
+ .invoke(null, configJson(config), protector, reporter) as String
+ }.onSuccess {
+ status("Test passed: exit IP $it")
+ }.onFailure {
+ val message = it.cause?.message ?: it.message ?: "Unknown native error"
+ diagnostics("event=connection_test result=failed detail=$message")
+ status("Test failed: $message")
+ }.getOrNull()
+
+ override fun stop() {
+ runCatching { Class.forName("mobile.Mobile").getMethod("stop").invoke(null) }
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt
new file mode 100644
index 0000000..4d6c4c0
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt
@@ -0,0 +1,861 @@
+package net.megaproxy487.vpn
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.net.VpnService
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.net.Network
+import android.net.NetworkRequest
+import android.os.Build
+import android.os.ParcelFileDescriptor
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import net.megaproxy487.MainActivity
+import net.megaproxy487.SshHostKeyActivity
+import net.megaproxy487.data.ConfigStore
+import net.megaproxy487.data.ConfigIoDispatcher
+import net.megaproxy487.model.FailoverMode
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicLong
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.concurrent.thread
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+
+class ProxyVpnService : VpnService() {
+ @Volatile private var tunnel: ParcelFileDescriptor? = null
+ @Volatile private var core: ProxyCore? = null
+ @Volatile private var activeConfig: net.megaproxy487.model.ProxyConfig? = null
+ @Volatile private var tunnelTestOnly = false
+ @Volatile private var hostKeyPrompt: PendingIntent? = null
+ @Volatile private var failoverNotice: String? = null
+ @Volatile private var connectionBlockedForAction = false
+ @Volatile private var reconnectAfterStart = false
+ @Volatile private var healthWarningActive = false
+ @Volatile private var consecutiveStartFailures = 0
+ @Volatile private var nextStartAttemptAt = 0L
+ @Volatile private var retryStatus: String? = null
+ private var lastHealthBytes = 0L
+ private var lastHealthOutcomes = 0L
+ private val tunnelStateLock = Any()
+ private val startGeneration = AtomicLong(0)
+ private val probableFailureCounts = ConcurrentHashMap()
+ private val probableFailureTimes = ConcurrentHashMap()
+ private val attemptedFailoverProfiles = ConcurrentHashMap.newKeySet()
+ private val freshDnsRecoveryProfiles = ConcurrentHashMap.newKeySet()
+ private val underlyingCapabilities = ConcurrentHashMap()
+ @Volatile private var underlyingNetwork: Network? = null
+ @Volatile private var networkCallbackRegistered = false
+ private val monitorHandler = Handler(Looper.getMainLooper())
+ private val reconnectForNetworkChange = Runnable {
+ if (ConfigStore(this).isConnectionDesired() && tunnel != null && !testRunning.get()) {
+ DiagnosticLog.add("event=network_handover action=reconnect")
+ startService(Intent(this, ProxyVpnService::class.java).setAction(ACTION_RECONNECT)
+ .putExtra(EXTRA_RECONNECT_REASON, "underlying_network_changed"))
+ }
+ }
+ private val networkCallback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ reconsiderUnderlyingNetworks(network)
+ }
+
+ override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
+ underlyingCapabilities[network] = capabilities
+ reconsiderUnderlyingNetworks(network)
+ }
+
+ override fun onLost(network: Network) {
+ underlyingCapabilities.remove(network)
+ reconsiderUnderlyingNetworks()
+ }
+ }
+ private val monitor = object : Runnable {
+ override fun run() {
+ val desired = ConfigStore(this@ProxyVpnService).isConnectionDesired()
+ if (desired) {
+ updatePassiveHealthState()
+ getSystemService(NotificationManager::class.java).notify(
+ NOTIFICATION_ID,
+ notification(failoverNotice ?: retryStatus ?: VpnRuntimeState.networkWarning.value ?: if (isRunning) "Connected" else "Reconnecting…"),
+ )
+ val retryDue = SystemClock.elapsedRealtime() >= nextStartAttemptAt
+ if (retryDue && tunnel == null && hostKeyPrompt == null && !connectionBlockedForAction && !testRunning.get() && startRunning.compareAndSet(false, true)) {
+ val generation = startGeneration.get()
+ VpnRuntimeState.update(VpnConnectionState.CONNECTING)
+ thread(name = "megaproxy-vpn-recovery") {
+ try {
+ startTunnel(testOnly = false, generation = generation)
+ } catch (error: Exception) {
+ if (!isStartCurrent(generation)) return@thread
+ handleStartFailure(false, "VPN recovery failed", error.message.orEmpty(), ConfigStore(this@ProxyVpnService).connectionProfile().id)
+ } finally {
+ finishStartAttempt()
+ }
+ }
+ }
+ }
+ monitorHandler.postDelayed(this, MONITOR_INTERVAL_MS)
+ }
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ failoverNotice = ConfigStore(this).failoverNotice()
+ failoverNotice?.let(VpnRuntimeState::updateNetworkWarning)
+ createChannel()
+ registerUnderlyingNetworkCallback()
+ monitorHandler.post(monitor)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val systemAlwaysOnStart = intent?.action == SERVICE_INTERFACE
+ // Android starts the selected service with SERVICE_INTERFACE on older releases;
+ // the explicit isAlwaysOn property itself was only added in API 29.
+ val platformAlwaysOn = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isAlwaysOn
+ // ACTION_REFRESH_STATUS must be allowed to clear a stale true value after the
+ // user disables Always-on in Android Settings.
+ isAlwaysOnMode = platformAlwaysOn || systemAlwaysOnStart
+ isLockdownMode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) isLockdownEnabled else false
+ val store = ConfigStore(this)
+ // Android may call the sticky service again with a null intent. Do not replace a
+ // failover profile on every callback; only a fresh system Always-on start selects
+ // the configured base profile.
+ if (isAlwaysOnMode && intent?.action != ACTION_START_MANUAL) {
+ store.setConnectionDesired(true)
+ if (intent?.action == SERVICE_INTERFACE && !store.isFailoverActive()) {
+ store.setConnectionProfile(store.alwaysOnProfileId())
+ store.setFailoverState(false, null)
+ failoverNotice = null
+ VpnRuntimeState.updateNetworkWarning(null)
+ }
+ }
+ if (intent?.action == ACTION_START_MANUAL) {
+ probableFailureCounts.clear(); probableFailureTimes.clear(); attemptedFailoverProfiles.clear(); failoverNotice = null
+ freshDnsRecoveryProfiles.clear()
+ connectionBlockedForAction = false
+ resetRetryState()
+ store.setFailoverState(false, null)
+ VpnRuntimeState.updateNetworkWarning(null)
+ }
+ VpnRuntimeState.updateSystem(isAlwaysOnMode, isLockdownMode, store.connectionProfileId())
+ if (intent?.action == ACTION_REFRESH_STATUS) {
+ return if (tunnel != null) START_STICKY else START_NOT_STICKY
+ }
+ if (intent?.action == ACTION_DISMISS_HOST_KEY) {
+ hostKeyPrompt = null
+ if (tunnel == null) { stopForeground(STOP_FOREGROUND_REMOVE); stopSelf() }
+ return START_NOT_STICKY
+ }
+ if (intent?.action == ACTION_STOP) {
+ stopTunnel()
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ if (intent?.action == ACTION_RECONNECT) {
+ hostKeyPrompt = null; failoverNotice = null
+ connectionBlockedForAction = false
+ resetRetryState()
+ store.setFailoverState(false, null)
+ probableFailureCounts.clear(); probableFailureTimes.clear(); attemptedFailoverProfiles.clear()
+ VpnRuntimeState.updateNetworkWarning(null)
+ DiagnosticLog.add("event=vpn_reconnect reason=${intent.getStringExtra(EXTRA_RECONNECT_REASON) ?: "settings_changed"}")
+ startForeground(NOTIFICATION_ID, notification("Reconnecting…"))
+ stopTunnel(removeForeground = false)
+ val generation = startGeneration.get()
+ ConfigStore(this).setConnectionDesired(true)
+ if (startRunning.compareAndSet(false, true)) {
+ VpnRuntimeState.update(VpnConnectionState.CONNECTING)
+ thread(name = "megaproxy-vpn-reconnect") {
+ try {
+ startTunnel(testOnly = false, generation = generation)
+ } catch (error: Exception) {
+ if (!isStartCurrent(generation)) return@thread
+ handleStartFailure(false, "VPN reconnect failed", error.message.orEmpty(), ConfigStore(this@ProxyVpnService).connectionProfile().id)
+ } finally {
+ finishStartAttempt()
+ }
+ }
+ } else reconnectAfterStart = true
+ return START_STICKY
+ }
+ if (intent?.action == ACTION_TEST) {
+ if (startRunning.get()) {
+ TestDiagnosticLog.fail("Wait for the current VPN connection attempt to finish")
+ return START_NOT_STICKY
+ }
+ if (!testRunning.compareAndSet(false, true)) {
+ TestDiagnosticLog.add("Connection test is already running")
+ return START_NOT_STICKY
+ }
+ hostKeyPrompt = null
+ SshHostKeyPromptState.clear()
+ startForeground(NOTIFICATION_ID, notification("Testing connection…"))
+ TestDiagnosticLog.begin()
+ thread(name = "megaproxy-connection-test") {
+ try {
+ testConnection()
+ } catch (error: Exception) {
+ TestDiagnosticLog.fail("Connection test stopped unexpectedly: ${error.message ?: error.javaClass.simpleName}")
+ if (tunnelTestOnly) stopTunnel()
+ } finally {
+ testRunning.set(false)
+ }
+ }
+ return START_NOT_STICKY
+ }
+ startForeground(NOTIFICATION_ID, notification("Connecting…"))
+ if (tunnel == null && startRunning.compareAndSet(false, true)) {
+ val generation = startGeneration.get()
+ VpnRuntimeState.update(VpnConnectionState.CONNECTING)
+ thread(name = "megaproxy-vpn-start") {
+ try {
+ startTunnel(testOnly = false, generation = generation)
+ } catch (error: Exception) {
+ if (!isStartCurrent(generation)) return@thread
+ handleStartFailure(false, "VPN start failed", error.message.orEmpty(), ConfigStore(this@ProxyVpnService).connectionProfile().id)
+ } finally {
+ finishStartAttempt()
+ }
+ }
+ }
+ return START_STICKY
+ }
+
+ private fun finishStartAttempt() {
+ startRunning.set(false)
+ if (reconnectAfterStart) {
+ reconnectAfterStart = false
+ monitorHandler.post {
+ startService(Intent(this, ProxyVpnService::class.java).setAction(ACTION_RECONNECT)
+ .putExtra(EXTRA_RECONNECT_REASON, "profile_changed_during_connect"))
+ }
+ }
+ }
+
+ private fun testConnection() {
+ val storedConfig = ConfigStore(this).globalConnectionSettings().applyTo(ConfigStore(this).activeProfile().config)
+ storedConfig.connectionValidationError()?.let {
+ TestDiagnosticLog.fail("Connection test cannot start: $it")
+ if (tunnel == null) { stopForeground(STOP_FOREGROUND_REMOVE); stopSelf() }
+ return
+ }
+ val temporaryVpn = tunnel == null
+ if (temporaryVpn && !startTunnel(testOnly = true, suppliedConfig = storedConfig, generation = startGeneration.get())) {
+ TestDiagnosticLog.fail("Connection test failed: temporary VPN could not be started")
+ if (hostKeyPrompt == null) {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+ return
+ }
+ val config = synchronized(tunnelStateLock) { activeConfig } ?: run {
+ TestDiagnosticLog.fail("Connection test failed: active VPN configuration is unavailable")
+ return
+ }
+ val exitIp = NativeProxyCore(this, TestDiagnosticLog::add).test(config) { message ->
+ configureHostKeyPrompt(message, ConfigStore(this).activeProfileId(), true)
+ getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(message))
+ }
+ if (exitIp != null) TestDiagnosticLog.succeed(exitIp) else TestDiagnosticLog.fail()
+ if (temporaryVpn) {
+ stopTunnel()
+ stopSelf()
+ }
+ }
+
+ private fun startTunnel(
+ testOnly: Boolean,
+ suppliedConfig: net.megaproxy487.model.ProxyConfig? = null,
+ generation: Long = startGeneration.get(),
+ ): Boolean {
+ val configStore = ConfigStore(this)
+ val pendingReconnectToken = if (testOnly) null else configStore.pendingReconnectToken()
+ val storedProfile = configStore.connectionProfile()
+ val storedConfig = suppliedConfig ?: configStore.globalConnectionSettings().applyTo(storedProfile.config)
+ val promptProfileId = if (testOnly) configStore.activeProfileId() else storedProfile.id
+ val diagnostics = if (testOnly) TestDiagnosticLog::add else DiagnosticLog::add
+ var failureDetail = ""
+ diagnostics(
+ if (testOnly) "event=vpn_start mode=test"
+ else if (storedConfig.routeAllApps) "event=vpn_start mode=global"
+ else "event=vpn_start mode=split selected_app_count=${storedConfig.selectedPackages.size}"
+ )
+ val validationError = if (testOnly) storedConfig.connectionValidationError() else storedConfig.validationError()
+ validationError?.let {
+ if (testOnly) TestDiagnosticLog.fail(it) else {
+ val notice = if (isAlwaysOnMode)
+ "VPN configuration requires attention. Fix it in MegaProxy, then reconnect from Android Always-on VPN settings."
+ else "VPN configuration requires attention. Open MegaProxy settings."
+ VpnRuntimeState.updateNetworkWarning(notice)
+ if (isAlwaysOnMode) {
+ connectionBlockedForAction = true
+ startForeground(NOTIFICATION_ID, notification(notice))
+ VpnRuntimeState.update(VpnConnectionState.CONNECTING)
+ } else {
+ ConfigStore(this).setConnectionDesired(false)
+ VpnRuntimeState.update(VpnConnectionState.DISCONNECTED)
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+ }
+ return false
+ }
+ val builder = Builder()
+ .setSession("MegaProxy")
+ // Leave headroom for cellular, hotspot and nested-tunnel encapsulation.
+ // 1400 avoids common PMTU black holes while remaining efficient for TCP.
+ .setMtu(VPN_MTU)
+ .addAddress("10.77.0.1", 30)
+ .addAddress("fd77::1", 126)
+ .addRoute("0.0.0.0", 0)
+ .addRoute("::", 0)
+ .addDnsServer("10.77.0.2")
+ .setBlocking(true)
+ if (testOnly) {
+ builder.addAllowedApplication(packageName)
+ } else if (!storedConfig.routeAllApps) {
+ var allowedCount = 0
+ storedConfig.selectedPackages.forEach { selectedPackage ->
+ runCatching { builder.addAllowedApplication(selectedPackage) }
+ .onSuccess { allowedCount++ }
+ .onFailure { diagnostics("event=routing package=unavailable action=skipped") }
+ }
+ if (allowedCount == 0) {
+ // An empty allow-list means "all apps" to VpnService.Builder. Route only
+ // our own process so an empty/missing split list cannot become global VPN.
+ builder.addAllowedApplication(packageName)
+ diagnostics("event=routing mode=split selected_app_count=0 fallback=self_only")
+ }
+ }
+ val establishedTunnel = builder.establish() ?: run {
+ handleStartFailure(testOnly, "VPN interface could not be established")
+ return false
+ }
+ diagnostics("TUN established with IPv4, IPv6 and intercepted DNS")
+ val proxyCore = NativeProxyCore(this, diagnostics)
+ val addressCache = BootstrapAddressCache(this)
+ fun resolveHost(host: String, target: String): String? {
+ if (!testOnly) {
+ addressCache.get(host)?.let {
+ diagnostics("event=bootstrap_dns result=cache_hit target=$target age_limit_days=7")
+ return it
+ }
+ }
+ return proxyCore.resolveProxy(host) { message ->
+ failureDetail = message
+ getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(message))
+ }?.also { addressCache.put(host, it) }
+ }
+ val proxyIp = resolveHost(storedConfig.host, "proxy") ?: run {
+ establishedTunnel.close()
+ if (!isStartCurrent(generation)) return false
+ handleStartFailure(testOnly, "Proxy bootstrap DNS failed", failureDetail, promptProfileId)
+ return false
+ }
+ val jumpIp = if (storedConfig.type == net.megaproxy487.model.ProxyType.SSH_JUMP) {
+ resolveHost(storedConfig.jumpHost, "jump") ?: run {
+ establishedTunnel.close()
+ if (!isStartCurrent(generation)) return false
+ handleStartFailure(testOnly, "Jump host bootstrap DNS failed", failureDetail, promptProfileId)
+ return false
+ }
+ } else ""
+ if (!isStartCurrent(generation)) {
+ establishedTunnel.close()
+ return false
+ }
+ val config = storedConfig.copy(resolvedProxyIp = proxyIp, resolvedJumpIp = jumpIp)
+ val started = proxyCore.start(establishedTunnel.fd, config) { message ->
+ failureDetail = message
+ configureHostKeyPrompt(message, promptProfileId, testOnly)
+ if (!testOnly && "dpi_hint=possible" in message) monitorHandler.post { handleRuntimeDiagnostic(promptProfileId, message) }
+ getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(message))
+ }
+ if (!started) {
+ establishedTunnel.close()
+ if (isStartCurrent(generation)) {
+ isRunning = false
+ handleStartFailure(testOnly, "Native proxy core failed to start", failureDetail, promptProfileId)
+ }
+ return false
+ }
+ val committed = synchronized(tunnelStateLock) {
+ if (!isStartCurrent(generation) || tunnel != null) false else {
+ tunnel = establishedTunnel
+ core = proxyCore
+ tunnelTestOnly = testOnly
+ activeConfig = config
+ isRunning = true
+ true
+ }
+ }
+ if (!committed) {
+ proxyCore.stop()
+ establishedTunnel.close()
+ return false
+ }
+ underlyingNetwork?.let { setUnderlyingNetworks(arrayOf(it)) }
+ probableFailureCounts.remove(promptProfileId)
+ probableFailureTimes.remove(promptProfileId)
+ if (failoverNotice == null) VpnRuntimeState.updateNetworkWarning(null)
+ else VpnRuntimeState.updateNetworkWarning(failoverNotice)
+ VpnRuntimeState.updateSystem(isAlwaysOnMode, isLockdownMode, promptProfileId)
+ if (!testOnly) configStore.clearPendingReconnect(pendingReconnectToken)
+ resetRetryState()
+ VpnRuntimeState.update(VpnConnectionState.CONNECTED)
+ return true
+ }
+
+ private fun isStartCurrent(generation: Long): Boolean = startGeneration.get() == generation
+
+ private fun handleStartFailure(testOnly: Boolean, message: String, detail: String = message, profileId: String = "") {
+ if (testOnly) {
+ TestDiagnosticLog.fail(message)
+ if (hostKeyPrompt != null) {
+ getSystemService(NotificationManager::class.java).notify(
+ NOTIFICATION_ID, notification("SSH host key approval required"),
+ )
+ } else {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+ } else {
+ val signal = BlockingDetection.classify(detail)
+ if (signal != null && profileId.isNotEmpty()) {
+ val count = recordProbableFailure(profileId)
+ DiagnosticLog.add("event=blocking_detection result=suspected signal=${signal.name.lowercase()} consecutive=$count network=${networkKind()} profile_type=${ConfigStore(this).profile(profileId)?.config?.type?.name?.lowercase() ?: "unknown"}")
+ if (count >= 2) handleProbableBlocking(profileId, signal)
+ }
+ if (signal == null && requiresUserAction(detail)) {
+ val notice = if (isAlwaysOnMode)
+ "VPN connection requires attention. Check authentication, certificate, or SSH host-key settings, then reconnect Always-on VPN."
+ else "VPN connection requires attention. Check authentication, certificate, or SSH host-key settings."
+ VpnRuntimeState.updateNetworkWarning(notice)
+ if (isAlwaysOnMode) {
+ connectionBlockedForAction = true
+ VpnRuntimeState.update(VpnConnectionState.CONNECTING)
+ getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(notice))
+ } else {
+ ConfigStore(this).setConnectionDesired(false)
+ VpnRuntimeState.update(VpnConnectionState.DISCONNECTED)
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+ return
+ }
+ val failureStage = connectionFailureStage(detail)
+ consecutiveStartFailures++
+ if (!isAlwaysOnMode && consecutiveStartFailures >= MAX_MANUAL_START_FAILURES) {
+ val notice = "$failureStage Connection stopped after $consecutiveStartFailures failed attempts."
+ DiagnosticLog.add("event=vpn_retry result=exhausted attempts=$consecutiveStartFailures stage=${failureStageToken(detail)}")
+ retryStatus = null
+ ConfigStore(this).setConnectionDesired(false)
+ VpnRuntimeState.updateNetworkWarning(notice)
+ VpnRuntimeState.update(VpnConnectionState.DISCONNECTED)
+ getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(notice))
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ return
+ }
+ val delay = retryDelayMs(consecutiveStartFailures)
+ nextStartAttemptAt = SystemClock.elapsedRealtime() + delay
+ retryStatus = "$failureStage Retrying in ${delay / 1_000}s (attempt ${consecutiveStartFailures + 1})."
+ DiagnosticLog.add("event=vpn_retry result=scheduled attempt=${consecutiveStartFailures + 1} delay_ms=$delay stage=${failureStageToken(detail)}")
+ VpnRuntimeState.update(VpnConnectionState.CONNECTING)
+ getSystemService(NotificationManager::class.java).notify(
+ NOTIFICATION_ID,
+ notification(retryStatus!!),
+ )
+ }
+ }
+
+ private fun retryDelayMs(failures: Int): Long = RETRY_DELAYS_MS[
+ (failures - 1).coerceIn(0, RETRY_DELAYS_MS.lastIndex)
+ ]
+
+ private fun resetRetryState() {
+ consecutiveStartFailures = 0
+ nextStartAttemptAt = 0L
+ retryStatus = null
+ }
+
+ private fun connectionFailureStage(detail: String): String = when {
+ "SSH jump handshake" in detail -> "Jump host SSH handshake failed."
+ "SSH destination handshake" in detail -> "Destination SSH handshake failed."
+ "jump host could not reach" in detail -> "Jump host could not reach the destination."
+ "dial jump host" in detail -> "Could not connect to the jump host."
+ "VPN interface could not be established" in detail -> "Android could not create the VPN interface."
+ else -> "Connection failed."
+ }
+
+ private fun failureStageToken(detail: String): String = when {
+ "SSH jump handshake" in detail -> "ssh_jump_handshake"
+ "SSH destination handshake" in detail -> "ssh_destination_handshake"
+ "jump host could not reach" in detail -> "ssh_jump_direct_tcpip"
+ "dial jump host" in detail -> "ssh_jump_tcp_connect"
+ "VPN interface could not be established" in detail -> "vpn_establish"
+ else -> "other"
+ }
+
+ private fun requiresUserAction(detail: String): Boolean {
+ val value = detail.lowercase()
+ return listOf(
+ "authenticate", "authentication", "credentials", "unauthorized", "forbidden",
+ "certificate", "x509", "host key", "ssh_host_key", "invalid config",
+ ).any(value::contains)
+ }
+
+ @Synchronized
+ private fun handleProbableBlocking(profileId: String, signal: BlockingSignal) {
+ val store = ConfigStore(this)
+ val settings = store.globalConnectionSettings()
+ val warning = "Proxy probably blocked (${signal.name.lowercase().replace('_', ' ')})."
+ VpnRuntimeState.updateNetworkWarning(warning)
+ // DNS failover may move a hostname away from a filtered or stale address. Give
+ // the current profile one fresh resolution attempt before changing exit IP or
+ // location through profile failover.
+ if (freshDnsRecoveryProfiles.add(profileId)) {
+ store.profile(profileId)?.config?.let { profile ->
+ BootstrapAddressCache(this).apply {
+ remove(profile.host)
+ if (profile.type == net.megaproxy487.model.ProxyType.SSH_JUMP) remove(profile.jumpHost)
+ }
+ }
+ val notice = "$warning Retrying this profile with fresh encrypted DNS before failover."
+ VpnRuntimeState.updateNetworkWarning(notice)
+ DiagnosticLog.add("event=blocking_recovery action=refresh_dns profile_type=${store.profile(profileId)?.config?.type?.name?.lowercase() ?: "unknown"}")
+ getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(notice))
+ if (tunnel != null) stopTunnel(removeForeground = false)
+ return
+ }
+ if (settings.failoverMode == FailoverMode.DISABLED) {
+ val notice = "$warning Failover is disabled; the connection may remain unavailable."
+ VpnRuntimeState.updateNetworkWarning(notice)
+ getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(notice))
+ return
+ }
+ val ordered = store.sortedProfiles()
+ attemptedFailoverProfiles += profileId
+ val currentIndex = ordered.indexOfFirst { it.id == profileId }.coerceAtLeast(0)
+ val afterCurrent = ordered.drop(currentIndex + 1) + ordered.take(currentIndex + 1)
+ val candidates = when (settings.failoverMode) {
+ FailoverMode.SELECTED -> afterCurrent.filter { it.id in settings.failoverProfileIds }
+ FailoverMode.ALL -> afterCurrent
+ else -> emptyList()
+ }.filter { it.id != profileId && it.id !in attemptedFailoverProfiles }
+ val next = candidates.firstOrNull() ?: run {
+ val notice = "$warning No eligible fallback profile remains."
+ failoverNotice = notice
+ store.setFailoverState(false, notice)
+ VpnRuntimeState.updateNetworkWarning(notice)
+ getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(notice))
+ return
+ }
+ attemptedFailoverProfiles += next.id
+ resetRetryState()
+ store.setConnectionProfile(next.id)
+ val notice = "Failover active: switched to ${next.displayName}. Location and exit IP may have changed."
+ failoverNotice = notice
+ store.setFailoverState(true, notice)
+ VpnRuntimeState.updateNetworkWarning(notice)
+ VpnRuntimeState.updateSystem(isAlwaysOnMode, isLockdownMode, next.id)
+ if (tunnel != null) stopTunnel(removeForeground = false)
+ getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(notice))
+ }
+
+ private fun handleRuntimeDiagnostic(profileId: String, detail: String) {
+ val signal = BlockingDetection.classify(detail) ?: return
+ val count = recordProbableFailure(profileId)
+ if (count >= 2) handleProbableBlocking(profileId, signal)
+ }
+
+ private fun recordProbableFailure(profileId: String): Int {
+ val now = SystemClock.elapsedRealtime()
+ val previous = probableFailureTimes.put(profileId, now)
+ return if (previous == null || now - previous > FAILURE_WINDOW_MS) {
+ probableFailureCounts[profileId] = 1
+ 1
+ } else {
+ probableFailureCounts.merge(profileId, 1, Int::plus) ?: 1
+ }
+ }
+
+ private fun networkKind(): String {
+ val manager = getSystemService(ConnectivityManager::class.java)
+ val capabilities = manager.getNetworkCapabilities(manager.activeNetwork) ?: return "none"
+ return when {
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "mobile"
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
+ else -> "other"
+ }
+ }
+
+ private fun updatePassiveHealthState() {
+ if (!isRunning) return
+ // The monitor runs on the main looper. Avoid JNI/reflection/JSON work here;
+ // health evaluation itself is safe on this short-lived worker.
+ thread(name = "megaproxy-health-snapshot") {
+ updatePassiveHealthStateOffMain()
+ }
+ }
+
+ private fun updatePassiveHealthStateOffMain() {
+ val stats = ConnectionStatsReader.snapshot() ?: return
+ val bytes = stats.downloadBytes + stats.uploadBytes
+ val trafficProgressed = bytes > lastHealthBytes
+ val newConnectionOutcomes = stats.totalOutcomes > lastHealthOutcomes
+ when {
+ trafficProgressed && healthWarningActive -> {
+ healthWarningActive = false
+ if (failoverNotice == null) VpnRuntimeState.updateNetworkWarning(null)
+ DiagnosticLog.add("event=connection_health state=recovered")
+ }
+ newConnectionOutcomes && stats.connectionSamples >= 3 && stats.connectionErrorRate >= 0.75 && !healthWarningActive -> {
+ healthWarningActive = true
+ val warning = "Connection is degraded: most recent proxy connections failed."
+ VpnRuntimeState.updateNetworkWarning(warning)
+ DiagnosticLog.add("event=connection_health state=degraded error_rate_percent=${(stats.connectionErrorRate * 100).toInt()} samples=${stats.connectionSamples}")
+ }
+ }
+ lastHealthBytes = bytes
+ lastHealthOutcomes = stats.totalOutcomes
+ }
+
+ private fun registerUnderlyingNetworkCallback() {
+ val manager = getSystemService(ConnectivityManager::class.java)
+ val request = NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
+ .build()
+ runCatching {
+ manager.registerNetworkCallback(request, networkCallback)
+ networkCallbackRegistered = true
+ }.onFailure {
+ DiagnosticLog.add("event=network_monitor result=failed reason=${it.javaClass.simpleName}")
+ }
+ }
+
+ private fun reconsiderUnderlyingNetworks(preferred: Network? = null) {
+ monitorHandler.post {
+ val validated = underlyingCapabilities.entries
+ .filter { it.value.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) }
+ .maxWithOrNull(
+ compareBy> { networkPreference(it.value) }
+ .thenBy { if (it.key == preferred) 1 else 0 }
+ .thenBy { if (it.key == underlyingNetwork) 1 else 0 },
+ )?.key
+ val captive = underlyingCapabilities.values.any {
+ it.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)
+ }
+ if (validated == null) {
+ if (captive) {
+ val warning = "Wi-Fi sign-in is required before the VPN can reconnect."
+ VpnRuntimeState.updateNetworkWarning(warning)
+ getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(warning))
+ DiagnosticLog.add("event=network state=captive_portal")
+ }
+ return@post
+ }
+ val previous = underlyingNetwork
+ if (previous == validated) return@post
+ underlyingNetwork = validated
+ if (tunnel != null) setUnderlyingNetworks(arrayOf(validated))
+ DiagnosticLog.add("event=network underlying=changed connected=${tunnel != null}")
+ if (previous != null && tunnel != null) {
+ monitorHandler.removeCallbacks(reconnectForNetworkChange)
+ monitorHandler.postDelayed(reconnectForNetworkChange, NETWORK_CHANGE_DEBOUNCE_MS)
+ }
+ }
+ }
+
+ private fun networkPreference(capabilities: NetworkCapabilities): Int = when {
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 3
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 2
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 1
+ else -> 0
+ }
+
+ private fun stopTunnel(removeForeground: Boolean = true) {
+ startGeneration.incrementAndGet()
+ val stopped = synchronized(tunnelStateLock) {
+ val result = Triple(tunnel, core, tunnelTestOnly)
+ tunnel = null
+ core = null
+ tunnelTestOnly = false
+ activeConfig = null
+ isRunning = false
+ healthWarningActive = false
+ lastHealthBytes = 0L
+ lastHealthOutcomes = 0L
+ result
+ }
+ if (stopped.first != null) {
+ if (stopped.third) TestDiagnosticLog.add("Stopping temporary VPN")
+ else DiagnosticLog.add("Stopping VPN")
+ }
+ VpnRuntimeState.update(VpnConnectionState.DISCONNECTED)
+ resetRetryState()
+ stopped.second?.stop()
+ stopped.first?.close()
+ if (removeForeground) stopForeground(STOP_FOREGROUND_REMOVE)
+ }
+
+ override fun onRevoke() {
+ ConfigStore(this).setConnectionDesired(false)
+ isAlwaysOnMode = false
+ isLockdownMode = false
+ stopTunnel()
+ stopSelf()
+ }
+ override fun onDestroy() {
+ monitorHandler.removeCallbacks(monitor)
+ monitorHandler.removeCallbacks(reconnectForNetworkChange)
+ if (networkCallbackRegistered) {
+ runCatching { getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback) }
+ }
+ stopTunnel()
+ super.onDestroy()
+ }
+
+ private fun createChannel() {
+ val channel = NotificationChannel(CHANNEL_ID, "VPN", NotificationManager.IMPORTANCE_LOW).apply {
+ description = "Persistent VPN status"
+ setShowBadge(false)
+ }
+ getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
+ }
+
+ private fun configureHostKeyPrompt(message: String, profileId: String, testOnly: Boolean) {
+ val marker = when {
+ "SSH_HOST_KEY_UNKNOWN|" in message -> "SSH_HOST_KEY_UNKNOWN|"
+ "SSH_HOST_KEY_CHANGED|" in message -> "SSH_HOST_KEY_CHANGED|"
+ else -> return
+ }
+ val parts = message.substringAfter(marker).substringBefore(' ').split('|')
+ if (parts.size < 3) return
+ val changed = marker.startsWith("SSH_HOST_KEY_CHANGED")
+ val fingerprint = if (changed) parts.getOrNull(3) else parts.getOrNull(2)
+ if (fingerprint == null || !fingerprint.startsWith("SHA256:")) return
+ val intent = Intent(this, SshHostKeyActivity::class.java)
+ .putExtra(SshHostKeyActivity.EXTRA_PROFILE_ID, profileId)
+ .putExtra(SshHostKeyActivity.EXTRA_HOP, parts[0])
+ .putExtra(SshHostKeyActivity.EXTRA_ALGORITHM, parts[1])
+ .putExtra(SshHostKeyActivity.EXTRA_FINGERPRINT, fingerprint)
+ .putExtra(SshHostKeyActivity.EXTRA_CHANGED, changed)
+ .putExtra(SshHostKeyActivity.EXTRA_TEST_ONLY, testOnly)
+ SshHostKeyPromptState.show(
+ PendingSshHostKey(profileId, parts[0], parts[1], fingerprint, changed, testOnly),
+ )
+ hostKeyPrompt = PendingIntent.getActivity(
+ this, profileId.hashCode() xor parts[0].hashCode(), intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+
+ private fun notification(text: String) = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(net.megaproxy487.R.drawable.ic_vpn_notification)
+ .setContentTitle("MegaProxy is active")
+ .setContentText(text)
+ .setOngoing(true)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .setContentIntent(hostKeyPrompt ?: PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE))
+ .also { builder -> hostKeyPrompt?.let { builder.addAction(0, "Review SSH key", it) } }
+ .build()
+
+ companion object {
+ private const val CHANNEL_ID = "vpn"
+ private const val NOTIFICATION_ID = 101
+ private const val ACTION_STOP = "net.megaproxy487.STOP"
+ private const val ACTION_TEST = "net.megaproxy487.TEST"
+ private const val ACTION_RECONNECT = "net.megaproxy487.RECONNECT"
+ private const val ACTION_START_MANUAL = "net.megaproxy487.START_MANUAL"
+ private const val ACTION_REFRESH_STATUS = "net.megaproxy487.REFRESH_STATUS"
+ private const val ACTION_DISMISS_HOST_KEY = "net.megaproxy487.DISMISS_HOST_KEY"
+ private const val EXTRA_RECONNECT_PROFILE_ID = "reconnect_profile_id"
+ private const val EXTRA_RECONNECT_REASON = "reconnect_reason"
+ private const val MONITOR_INTERVAL_MS = 10_000L
+ private const val FAILURE_WINDOW_MS = 60_000L
+ private const val NETWORK_CHANGE_DEBOUNCE_MS = 1_500L
+ private const val MAX_MANUAL_START_FAILURES = 5
+ private const val VPN_MTU = 1_400
+ private val RETRY_DELAYS_MS = longArrayOf(5_000L, 10_000L, 20_000L, 40_000L, 80_000L, 180_000L, 300_000L)
+ private val testRunning = AtomicBoolean(false)
+ private val startRunning = AtomicBoolean(false)
+ private val commandScope = CoroutineScope(SupervisorJob() + ConfigIoDispatcher)
+ @Volatile var isRunning: Boolean = false
+ private set
+ @Volatile var isAlwaysOnMode: Boolean = false
+ private set
+ @Volatile var isLockdownMode: Boolean = false
+ private set
+
+ fun start(context: Context) {
+ val app = context.applicationContext
+ commandScope.launch {
+ ConfigStore(app).setConnectionDesired(true)
+ ConfigStore(app).let { it.setConnectionProfile(it.activeProfileId()) }
+ ContextCompat.startForegroundService(app, Intent(app, ProxyVpnService::class.java).setAction(ACTION_START_MANUAL))
+ }
+ }
+ fun stop(context: Context) {
+ val app = context.applicationContext
+ commandScope.launch {
+ ConfigStore(app).setConnectionDesired(false)
+ app.startService(Intent(app, ProxyVpnService::class.java).setAction(ACTION_STOP))
+ }
+ }
+ fun reconnect(context: Context) {
+ val app = context.applicationContext
+ commandScope.launch {
+ ConfigStore(app).setConnectionDesired(true)
+ ContextCompat.startForegroundService(app, Intent(app, ProxyVpnService::class.java).setAction(ACTION_RECONNECT))
+ }
+ }
+ /** Called from MY_PACKAGE_REPLACED after its receiver has checked persisted state. */
+ fun restoreAfterPackageUpdate(context: Context) {
+ val app = context.applicationContext
+ ContextCompat.startForegroundService(
+ app,
+ Intent(app, ProxyVpnService::class.java).setAction(ACTION_RECONNECT)
+ .putExtra(EXTRA_RECONNECT_REASON, "package_replaced"),
+ )
+ }
+ fun switchProfile(context: Context, profileId: String, useAsAlwaysOn: Boolean) {
+ val app = context.applicationContext
+ commandScope.launch {
+ val store = ConfigStore(app)
+ if (store.profile(profileId) == null) return@launch
+ if (useAsAlwaysOn) store.setAlwaysOnProfile(profileId) else store.setActiveProfile(profileId)
+ store.setConnectionProfile(profileId)
+ store.setConnectionDesired(true)
+ ContextCompat.startForegroundService(
+ app,
+ Intent(app, ProxyVpnService::class.java).setAction(ACTION_RECONNECT)
+ .putExtra(EXTRA_RECONNECT_PROFILE_ID, profileId)
+ .putExtra(EXTRA_RECONNECT_REASON, "profile_changed"),
+ )
+ }
+ }
+ fun test(context: Context) = ContextCompat.startForegroundService(
+ context, Intent(context, ProxyVpnService::class.java).setAction(ACTION_TEST),
+ )
+ fun refreshStatus(context: Context) {
+ if (isRunning) {
+ context.startService(Intent(context, ProxyVpnService::class.java).setAction(ACTION_REFRESH_STATUS))
+ }
+ }
+ fun dismissHostKeyPrompt(context: Context) {
+ SshHostKeyPromptState.clear()
+ context.startService(Intent(context, ProxyVpnService::class.java).setAction(ACTION_DISMISS_HOST_KEY))
+ }
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/vpn/SshHostKeyPromptState.kt b/app/src/main/java/net/megaproxy487/vpn/SshHostKeyPromptState.kt
new file mode 100644
index 0000000..01b118c
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/vpn/SshHostKeyPromptState.kt
@@ -0,0 +1,32 @@
+package net.megaproxy487.vpn
+
+import android.os.Handler
+import android.os.Looper
+import androidx.compose.runtime.mutableStateOf
+
+data class PendingSshHostKey(
+ val profileId: String,
+ val hop: String,
+ val algorithm: String,
+ val fingerprint: String,
+ val changed: Boolean,
+ val testOnly: Boolean,
+)
+
+object SshHostKeyPromptState {
+ private val mainHandler = Handler(Looper.getMainLooper())
+ val pending = mutableStateOf(null)
+
+ fun show(value: PendingSshHostKey) {
+ update(value)
+ }
+
+ fun clear() {
+ update(null)
+ }
+
+ private fun update(value: PendingSshHostKey?) {
+ if (Looper.myLooper() == Looper.getMainLooper()) pending.value = value
+ else mainHandler.post { pending.value = value }
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/vpn/VpnRuntimeState.kt b/app/src/main/java/net/megaproxy487/vpn/VpnRuntimeState.kt
new file mode 100644
index 0000000..8c29564
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/vpn/VpnRuntimeState.kt
@@ -0,0 +1,65 @@
+package net.megaproxy487.vpn
+
+import android.os.Handler
+import android.os.Looper
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+
+enum class VpnConnectionState { DISCONNECTED, CONNECTING, CONNECTED }
+enum class VpnTransportProtocol { UNKNOWN, HTTP_1_1, HTTP_2, SSH_MULTIPLEXED }
+
+fun transportProtocolFromDiagnostic(message: String): VpnTransportProtocol? = when {
+ "event=connection" in message && "protocol=http2" in message &&
+ "stage=tunnel" in message && "result=established" in message -> VpnTransportProtocol.HTTP_2
+ "event=connection" in message && "mode=proxy" in message &&
+ "stage=tunnel" in message && "result=established" in message -> VpnTransportProtocol.HTTP_1_1
+ "event=transport_capability" in message && "transport=ssh" in message &&
+ "multiplexed=true" in message -> VpnTransportProtocol.SSH_MULTIPLEXED
+ else -> null
+}
+
+object VpnRuntimeState {
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val mutableConnection = mutableStateOf(
+ if (ProxyVpnService.isRunning) VpnConnectionState.CONNECTED else VpnConnectionState.DISCONNECTED,
+ )
+ val connection: State = mutableConnection
+ private val mutableAlwaysOn = mutableStateOf(ProxyVpnService.isAlwaysOnMode)
+ private val mutableLockdown = mutableStateOf(ProxyVpnService.isLockdownMode)
+ private val mutableConnectionProfileId = mutableStateOf("")
+ val alwaysOn: State = mutableAlwaysOn
+ val lockdown: State = mutableLockdown
+ val connectionProfileId: State = mutableConnectionProfileId
+ private val mutableNetworkWarning = mutableStateOf(null)
+ val networkWarning: State = mutableNetworkWarning
+ private val mutableTransportProtocol = mutableStateOf(VpnTransportProtocol.UNKNOWN)
+ val transportProtocol: State = mutableTransportProtocol
+
+ fun update(value: VpnConnectionState) {
+ val update = {
+ mutableConnection.value = value
+ if (value != VpnConnectionState.CONNECTED) mutableTransportProtocol.value = VpnTransportProtocol.UNKNOWN
+ }
+ if (Looper.myLooper() == Looper.getMainLooper()) update() else mainHandler.post(update)
+ }
+
+ fun observeDiagnostic(message: String) {
+ val protocol = transportProtocolFromDiagnostic(message) ?: return
+ val update = { mutableTransportProtocol.value = protocol }
+ if (Looper.myLooper() == Looper.getMainLooper()) update() else mainHandler.post(update)
+ }
+
+ fun updateNetworkWarning(value: String?) {
+ if (Looper.myLooper() == Looper.getMainLooper()) mutableNetworkWarning.value = value
+ else mainHandler.post { mutableNetworkWarning.value = value }
+ }
+
+ fun updateSystem(alwaysOn: Boolean, lockdown: Boolean, connectionProfileId: String) {
+ val update = {
+ mutableAlwaysOn.value = alwaysOn
+ mutableLockdown.value = lockdown
+ mutableConnectionProfileId.value = connectionProfileId
+ }
+ if (Looper.myLooper() == Looper.getMainLooper()) update() else mainHandler.post(update)
+ }
+}
diff --git a/app/src/main/java/net/megaproxy487/vpn/VpnSettings.kt b/app/src/main/java/net/megaproxy487/vpn/VpnSettings.kt
new file mode 100644
index 0000000..4efbe1d
--- /dev/null
+++ b/app/src/main/java/net/megaproxy487/vpn/VpnSettings.kt
@@ -0,0 +1,14 @@
+package net.megaproxy487.vpn
+
+import android.app.Activity
+import android.content.Intent
+import android.provider.Settings
+
+const val OTHER_ALWAYS_ON_VPN_MESSAGE =
+ "Another app is configured as Always-on VPN. Change the Always-on VPN app in Android settings, then try again."
+
+fun openAndroidVpnSettings(activity: Activity) {
+ val vpnSettings = Intent(Settings.ACTION_VPN_SETTINGS)
+ runCatching { activity.startActivity(vpnSettings) }
+ .recoverCatching { activity.startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS)) }
+}
diff --git a/app/src/main/res/drawable/ic_vpn_notification.xml b/app/src/main/res/drawable/ic_vpn_notification.xml
new file mode 100644
index 0000000..82e7e17
--- /dev/null
+++ b/app/src/main/res/drawable/ic_vpn_notification.xml
@@ -0,0 +1,10 @@
+
+
+
+
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..be43858
--- /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..be43858
--- /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/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..4e5afda
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..f850e5e
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..4e5afda
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..ef2d792
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..d1c5c40
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..ef2d792
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..17cc7ad
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..fa349ec
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..17cc7ad
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..abb52ab
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..79bfcc7
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..abb52ab
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..39e3331
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..2709ad9
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..39e3331
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..7550247
--- /dev/null
+++ b/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,8 @@
+
+
+
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
new file mode 100644
index 0000000..913f34f
--- /dev/null
+++ b/app/src/main/res/values-ru/strings.xml
@@ -0,0 +1,63 @@
+
+
+ Назад
+ Настройки
+ Подключение
+ Профили
+ Прокси-серверы, учётные данные, DNS и IPv6
+ Постоянный VPN
+ Стартовый профиль и системные настройки VPN
+ Цифровые отпечатки
+ Поведение HTTPS JA3 и SSH-клиента
+ Раздельное туннелирование
+ Глобальная или маршрутизация отдельных приложений
+ Переключение при сбое
+ Резервные профили при вероятной блокировке
+ Диагностика
+ Видимость VPN
+ Проверка признаков, доступных приложениям
+ Настройки батареи
+ Надёжная работа VPN в фоновом режиме
+ Диагностический журнал
+ Просмотр, экспорт и очистка обезличенных событий
+ Интерфейс и язык
+ Язык
+ Язык интерфейса MegaProxy
+ Английский
+ Русский
+ Поддержка
+ Обратная связь
+ Отправить отзыв с диагностическим журналом
+ GitHub Issues
+ Сообщить об ошибке или посмотреть известные проблемы
+ Версия %1$s · %2$s
+ Подключиться
+ Отключиться
+ Подключение…
+ Проверить
+ Настроить
+ Не найдено приложение для открытия ссылки.
+ Проверка подключения
+ Профиль
+ Настройки профиля
+ Режим маршрутизации
+ Приложения
+ Проверить снова
+ Копировать
+ Импорт
+ Экспорт
+ Изменения сохраняются автоматически.
+ Отмена
+ Скрыть
+ Переподключиться сейчас
+ Переподключиться
+ Применить новые настройки
+ При следующем подключении
+ Очистить
+ Повторить проверку
+ HTTP/1.1
+ HTTP/2, мультиплексирование
+ SSH, мультиплексирование
+ Импортировать ключ назначения из файла
+ Импортировать ключ jump host из файла
+
diff --git a/app/src/main/res/values/ic_launcher_colors.xml b/app/src/main/res/values/ic_launcher_colors.xml
new file mode 100644
index 0000000..5d60d0a
--- /dev/null
+++ b/app/src/main/res/values/ic_launcher_colors.xml
@@ -0,0 +1,4 @@
+
+
+ #2725D9
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..29d2240
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,64 @@
+
+
+ MegaProxy
+ Back
+ Settings
+ Connection
+ Profiles
+ Proxy servers, credentials, DNS and IPv6
+ Always-on VPN
+ Startup profile and Android VPN controls
+ Fingerprints
+ HTTPS JA3 and SSH client behavior
+ Split tunneling
+ Choose global or per-app routing
+ Failover
+ Fallback profiles when blocking is suspected
+ Diagnostics
+ Visibility
+ Check what applications can detect
+ Battery settings
+ Allow reliable background VPN operation
+ Diagnostic log
+ Inspect, export or clear privacy-filtered events
+ Appearance and language
+ Language
+ Language used by the MegaProxy interface
+ English
+ Russian
+ Support
+ Feedback
+ Send feedback and attach the diagnostic log
+ GitHub issues
+ Report a bug or follow known issues
+ Version %1$s · %2$s
+ Connect
+ Disconnect
+ Connecting…
+ Test
+ Configure
+ No browser is available.
+ Connection test
+ Profile
+ Profile settings
+ Routing mode
+ Applications
+ Run again
+ Copy
+ Import
+ Export
+ Changes are saved automatically.
+ Cancel
+ Dismiss
+ Reconnect now
+ Reconnect
+ Apply new settings
+ Next connection
+ Clear
+ Run checks again
+ HTTP/1.1
+ HTTP/2 multiplexed
+ SSH multiplexed
+ Import destination key from file
+ Import jump key from file
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..3e47392
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..3c332df
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..a2451e9
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/test/java/net/megaproxy487/BatteryOptimizationReminderTest.kt b/app/src/test/java/net/megaproxy487/BatteryOptimizationReminderTest.kt
new file mode 100644
index 0000000..037f831
--- /dev/null
+++ b/app/src/test/java/net/megaproxy487/BatteryOptimizationReminderTest.kt
@@ -0,0 +1,39 @@
+package net.megaproxy487
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class BatteryOptimizationReminderTest {
+ @Test
+ fun firstRequestIsDue() {
+ assertTrue(BatteryOptimizationReminder.isRequestDue(now = 1_000L, lastRequestAt = 0L))
+ }
+
+ @Test
+ fun requestIsSuppressedDuringDailyInterval() {
+ val lastRequestAt = 1_000L
+ assertFalse(
+ BatteryOptimizationReminder.isRequestDue(
+ now = lastRequestAt + BatteryOptimizationReminder.REQUEST_INTERVAL_MS - 1,
+ lastRequestAt = lastRequestAt,
+ ),
+ )
+ }
+
+ @Test
+ fun requestIsDueAfterOneDay() {
+ val lastRequestAt = 1_000L
+ assertTrue(
+ BatteryOptimizationReminder.isRequestDue(
+ now = lastRequestAt + BatteryOptimizationReminder.REQUEST_INTERVAL_MS,
+ lastRequestAt = lastRequestAt,
+ ),
+ )
+ }
+
+ @Test
+ fun requestIsDueIfWallClockMovesBackwards() {
+ assertTrue(BatteryOptimizationReminder.isRequestDue(now = 1_000L, lastRequestAt = 2_000L))
+ }
+}
diff --git a/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt b/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt
new file mode 100644
index 0000000..f7f1e2b
--- /dev/null
+++ b/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt
@@ -0,0 +1,113 @@
+package net.megaproxy487.data
+
+import net.megaproxy487.model.ProxyConfig
+import net.megaproxy487.model.ProxyProfile
+import net.megaproxy487.model.TlsProfile
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ConfigTransferTest {
+ @Test
+ fun `proxy list omits passwords by default`() {
+ val profile = ProxyProfile(
+ id = "one", name = "Amsterdam", colorIndex = 0, countryCode = "nl",
+ config = ProxyConfig(host = "proxy.example", username = "user+name", password = "secret"),
+ )
+ val exported = ConfigTransfer.exportProxyList(listOf(profile), includePasswords = false)
+ assertTrue(exported.contains("https://user%2Bname:@proxy.example"))
+ assertFalse(exported.contains("secret"))
+ val imported = ProxyListParser.parse(exported).getOrThrow().proxies.single()
+ assertEquals("", imported.config.password)
+ assertEquals("NL", imported.countryCode)
+ assertEquals("Amsterdam", imported.name)
+ }
+
+ @Test
+ fun `proxy list includes password when requested`() {
+ val profile = ProxyProfile(
+ id = "one", colorIndex = 0,
+ config = ProxyConfig(host = "proxy.example", username = "user", password = "s e:c@r"),
+ )
+ val exported = ConfigTransfer.exportProxyList(listOf(profile), includePasswords = true)
+ assertEquals("s e:c@r", ProxyListParser.parse(exported).getOrThrow().proxies.single().config.password)
+ }
+
+ @Test
+ fun `json import preserves unavailable applications and tolerates future values`() {
+ val imported = ConfigTransfer.importJson(
+ """
+ {
+ "schema":"dev.megaproxy.config",
+ "version":1,
+ "activeProfileId":"source",
+ "alwaysOnProfileId":"source",
+ "profileSort":"FUTURE_SORT",
+ "profiles":[{
+ "id":"source",
+ "name":"Portable",
+ "color":999,
+ "countryCode":"de",
+ "proxy":{"host":"proxy.example","port":70000,"username":"u"},
+ "tls":{"fingerprint":"FUTURE_BROWSER","customJa3":"future"},
+ "dns":{"provider":"FUTURE_DNS","customDohUrl":"https://dns.example/query"},
+ "routing":{"selectedPackages":["com.example.not.installed","invalid package"],"routeAllApps":false},
+ "futureField":true
+ }]
+ }
+ """.trimIndent(),
+ )
+ val profile = imported.profiles.single()
+ assertEquals(443, profile.config.port)
+ assertEquals(TlsProfile.DEFAULT, profile.config.profile)
+ assertEquals(setOf("com.example.not.installed"), profile.config.selectedPackages)
+ assertEquals("", profile.config.password)
+ assertEquals("source", imported.activeProfileId)
+ }
+
+ @Test
+ fun `unsupported global fingerprint falls back to default`() {
+ val imported = ConfigTransfer.importJson(
+ """
+ {
+ "schema":"dev.megaproxy.config",
+ "version":2,
+ "tls":{"fingerprint":"SAMSUNG_INTERNET","customJa3":"ignored"},
+ "routing":{"selectedPackages":["com.example.missing"],"routeAllApps":false},
+ "profiles":[{
+ "id":"one",
+ "proxy":{"host":"proxy.example","username":"user"}
+ }]
+ }
+ """.trimIndent(),
+ )
+
+ assertEquals(TlsProfile.DEFAULT, imported.globalConnectionSettings?.tlsProfile)
+ assertEquals(setOf("com.example.missing"), imported.globalConnectionSettings?.selectedPackages)
+ }
+
+ @Test
+ fun `version one keeps per-profile IPv6 setting`() {
+ val imported = ConfigTransfer.importJson(
+ """{"schema":"dev.megaproxy.config","version":1,"profiles":[{
+ "id":"one","proxy":{"host":"proxy.example"},"routing":{"allowIpv6":true}
+ }]}""",
+ )
+
+ assertTrue(imported.profiles.single().config.allowIpv6)
+ }
+
+ @Test
+ fun `legacy global IPv6 setting migrates to every profile`() {
+ val imported = ConfigTransfer.importJson(
+ """{"schema":"dev.megaproxy.config","version":5,
+ "routing":{"allowIpv6":true},"profiles":[
+ {"id":"one","proxy":{"host":"one.example"}},
+ {"id":"two","proxy":{"host":"two.example"}}
+ ]}""",
+ )
+
+ assertTrue(imported.profiles.all { it.config.allowIpv6 })
+ }
+}
diff --git a/app/src/test/java/net/megaproxy487/data/FoxyProxyParserTest.kt b/app/src/test/java/net/megaproxy487/data/FoxyProxyParserTest.kt
new file mode 100644
index 0000000..5b9ae80
--- /dev/null
+++ b/app/src/test/java/net/megaproxy487/data/FoxyProxyParserTest.kt
@@ -0,0 +1,52 @@
+package net.megaproxy487.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class FoxyProxyParserTest {
+ @Test
+ fun `imports only TLS proxy entries from current FoxyProxy JSON`() {
+ val result = FoxyProxyParser.parse(
+ """
+ {
+ "mode":"pattern",
+ "data":[
+ {
+ "active":true,
+ "title":"Amsterdam",
+ "type":"https",
+ "hostname":"proxy.example",
+ "port":"8443",
+ "username":"user",
+ "password":"secret",
+ "cc":"nl"
+ },
+ {
+ "title":"Not supported",
+ "type":"socks5",
+ "hostname":"socks.example",
+ "port":"1080"
+ }
+ ]
+ }
+ """.trimIndent(),
+ ).getOrThrow()
+
+ assertEquals(1, result.skippedNonHttps)
+ val proxy = result.proxies.single()
+ assertEquals("Amsterdam", proxy.name)
+ assertEquals("NL", proxy.countryCode)
+ assertEquals("proxy.example", proxy.config.host)
+ assertEquals(8443, proxy.config.port)
+ assertEquals("user", proxy.config.username)
+ assertEquals("secret", proxy.config.password)
+ }
+
+ @Test
+ fun `rejects FoxyProxy config without HTTPS proxies`() {
+ val result = FoxyProxyParser.parse(
+ """{"data":[{"type":"http","hostname":"proxy.example","port":8080}]}""",
+ )
+ check(result.isFailure)
+ }
+}
diff --git a/app/src/test/java/net/megaproxy487/data/LimitedInputTest.kt b/app/src/test/java/net/megaproxy487/data/LimitedInputTest.kt
new file mode 100644
index 0000000..149f816
--- /dev/null
+++ b/app/src/test/java/net/megaproxy487/data/LimitedInputTest.kt
@@ -0,0 +1,19 @@
+package net.megaproxy487.data
+
+import java.io.ByteArrayInputStream
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Test
+
+class LimitedInputTest {
+ @Test
+ fun `reads a normal configuration`() {
+ assertEquals("config", ByteArrayInputStream("config".toByteArray()).readConfigText())
+ }
+
+ @Test
+ fun `rejects an oversized configuration`() {
+ val input = ByteArrayInputStream(ByteArray(MAX_CONFIG_FILE_BYTES + 1))
+ assertThrows(IllegalArgumentException::class.java) { input.readConfigText() }
+ }
+}
diff --git a/app/src/test/java/net/megaproxy487/data/PrivateKeyImportTest.kt b/app/src/test/java/net/megaproxy487/data/PrivateKeyImportTest.kt
new file mode 100644
index 0000000..d4ad2cb
--- /dev/null
+++ b/app/src/test/java/net/megaproxy487/data/PrivateKeyImportTest.kt
@@ -0,0 +1,51 @@
+package net.megaproxy487.data
+
+import java.io.ByteArrayInputStream
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Test
+
+class PrivateKeyImportTest {
+ @Test
+ fun importsOpenSshKeyAndNormalizesTrailingNewline() {
+ val input = """
+ -----BEGIN OPENSSH PRIVATE KEY-----
+ example
+ -----END OPENSSH PRIVATE KEY-----
+ """.trimIndent()
+
+ assertEquals("$input\n", ByteArrayInputStream(input.toByteArray()).readPrivateKeyText())
+ }
+
+ @Test
+ fun rejectsPublicKey() {
+ val input = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA example"
+
+ assertThrows(IllegalArgumentException::class.java) {
+ ByteArrayInputStream(input.toByteArray()).readPrivateKeyText()
+ }
+ }
+
+ @Test
+ fun rejectsEncryptedPemKey() {
+ val input = """
+ -----BEGIN EC PRIVATE KEY-----
+ Proc-Type: 4,ENCRYPTED
+ example
+ -----END EC PRIVATE KEY-----
+ """.trimIndent()
+
+ assertThrows(IllegalArgumentException::class.java) {
+ ByteArrayInputStream(input.toByteArray()).readPrivateKeyText()
+ }
+ }
+
+ @Test
+ fun rejectsOversizedKeyBeforeGrowingWithoutBound() {
+ val input = ByteArray(MAX_PRIVATE_KEY_FILE_BYTES + 1) { 'A'.code.toByte() }
+
+ assertThrows(IllegalArgumentException::class.java) {
+ ByteArrayInputStream(input).readPrivateKeyText()
+ }
+ }
+}
diff --git a/app/src/test/java/net/megaproxy487/data/ProxyListParserTest.kt b/app/src/test/java/net/megaproxy487/data/ProxyListParserTest.kt
new file mode 100644
index 0000000..5416eb5
--- /dev/null
+++ b/app/src/test/java/net/megaproxy487/data/ProxyListParserTest.kt
@@ -0,0 +1,40 @@
+package net.megaproxy487.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ProxyListParserTest {
+ @Test
+ fun parsesHttpsProxyUriAndTitle() {
+ val result = ProxyListParser.parse(
+ "https://user:p%40ss%3Aword@example.com:8443?title=Amsterdam&cc=NL",
+ ).getOrThrow().proxies.single()
+
+ assertEquals("Amsterdam", result.name)
+ assertEquals("NL", result.countryCode)
+ assertEquals("example.com", result.config.host)
+ assertEquals(8443, result.config.port)
+ assertEquals("user", result.config.username)
+ assertEquals("p@ss:word", result.config.password)
+ assertTrue(result.config.routeAllApps)
+ }
+
+ @Test
+ fun preservesLiteralPlusInBasicAuthAndDefaultsPort() {
+ val result = ProxyListParser.parse("https://user:abc+123@example.com").getOrThrow().proxies.single()
+
+ assertEquals(443, result.config.port)
+ assertEquals("abc+123", result.config.password)
+ }
+
+ @Test
+ fun skipsNonHttpsProxyAndReportsIt() {
+ val result = ProxyListParser.parse(
+ "https://user:password@example.com\nhttp://user:password@example.net:8080",
+ ).getOrThrow()
+
+ assertEquals(1, result.proxies.size)
+ assertEquals(1, result.skippedNonHttps)
+ }
+}
diff --git a/app/src/test/java/net/megaproxy487/data/SuperProxyParserTest.kt b/app/src/test/java/net/megaproxy487/data/SuperProxyParserTest.kt
new file mode 100644
index 0000000..ae7bd59
--- /dev/null
+++ b/app/src/test/java/net/megaproxy487/data/SuperProxyParserTest.kt
@@ -0,0 +1,27 @@
+package net.megaproxy487.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class SuperProxyParserTest {
+ @Test
+ fun `imports Super Proxy v1 HTTPS list and ignores certificate pin`() {
+ val text = """
+ # superproxy:proxylist:v1
+ https://user:p%40ss@proxy.example?fingerprint=sha256%2Fignored
+ """.trimIndent()
+
+ assertTrue(SuperProxyParser.matches(text))
+ val proxy = SuperProxyParser.parse(text).getOrThrow().proxies.single()
+ assertEquals("proxy.example", proxy.config.host)
+ assertEquals(443, proxy.config.port)
+ assertEquals("user", proxy.config.username)
+ assertEquals("p@ss", proxy.config.password)
+ }
+
+ @Test
+ fun `rejects a generic proxy list as Super Proxy config`() {
+ check(SuperProxyParser.parse("https://user:pass@proxy.example").isFailure)
+ }
+}
diff --git a/app/src/test/java/net/megaproxy487/model/DnsProviderTest.kt b/app/src/test/java/net/megaproxy487/model/DnsProviderTest.kt
new file mode 100644
index 0000000..4219abc
--- /dev/null
+++ b/app/src/test/java/net/megaproxy487/model/DnsProviderTest.kt
@@ -0,0 +1,31 @@
+package net.megaproxy487.model
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class DnsProviderTest {
+ @Test
+ fun `built in providers have encrypted failover`() {
+ listOf(DnsProvider.CLOUDFLARE, DnsProvider.GOOGLE, DnsProvider.QUAD9, DnsProvider.YANDEX).forEach { provider ->
+ assertTrue(provider.url.startsWith("https://"))
+ assertTrue(provider.fallbackUrls().size >= 2)
+ assertTrue(provider.url !in provider.fallbackUrls())
+ }
+ }
+
+ @Test
+ fun `custom provider never leaks queries to implicit fallback`() {
+ assertEquals(emptyList(), DnsProvider.CUSTOM.fallbackUrls())
+ assertEquals(emptyList(), DnsProvider.YANDEX_SAFE.fallbackUrls())
+ assertEquals(emptyList(), DnsProvider.YANDEX_FAMILY.fallbackUrls())
+ }
+
+ @Test
+ fun `Yandex modes are available`() {
+ assertEquals(
+ setOf(DnsProvider.YANDEX, DnsProvider.YANDEX_SAFE, DnsProvider.YANDEX_FAMILY),
+ DnsProvider.entries.filter { it.name.startsWith("YANDEX") }.toSet(),
+ )
+ }
+}
diff --git a/app/src/test/java/net/megaproxy487/model/Ja3SpecTest.kt b/app/src/test/java/net/megaproxy487/model/Ja3SpecTest.kt
new file mode 100644
index 0000000..9663da8
--- /dev/null
+++ b/app/src/test/java/net/megaproxy487/model/Ja3SpecTest.kt
@@ -0,0 +1,18 @@
+package net.megaproxy487.model
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class Ja3SpecTest {
+ @Test fun parsesCanonicalJa3() {
+ val spec = Ja3Spec.parse("771,4865-4866,0-10-11,29-23,0")!!
+ assertEquals(771, spec.tlsVersion)
+ assertEquals(listOf(4865, 4866), spec.cipherSuites)
+ }
+
+ @Test fun rejectsMalformedJa3() {
+ assertNull(Ja3Spec.parse("771,4865,0"))
+ assertNull(Ja3Spec.parse("771,70000,0,29,0"))
+ }
+}
diff --git a/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt b/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt
new file mode 100644
index 0000000..e030249
--- /dev/null
+++ b/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt
@@ -0,0 +1,38 @@
+package net.megaproxy487.model
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class ProxyConfigTest {
+ @Test
+ fun `default TLS fingerprint currently resolves to Chrome Android`() {
+ val resolved = GlobalConnectionSettings(tlsProfile = TlsProfile.DEFAULT)
+ .applyTo(ProxyConfig())
+
+ assertEquals(TlsProfile.CHROME_ANDROID, resolved.profile)
+ }
+
+ @Test
+ fun `global settings preserve profile IPv6 capability`() {
+ val resolved = GlobalConnectionSettings().applyTo(ProxyConfig(allowIpv6 = true))
+
+ assertEquals(true, resolved.allowIpv6)
+ }
+
+ private val validConnection = ProxyConfig(
+ host = "proxy.example.com",
+ username = "user",
+ password = "password",
+ )
+
+ @Test
+ fun splitTunnelingAllowsNoApplications() {
+ assertNull(validConnection.copy(routeAllApps = false).validationError())
+ }
+
+ @Test
+ fun globalVpnDoesNotRequireSelectedApplications() {
+ assertNull(validConnection.validationError())
+ }
+}
diff --git a/app/src/test/java/net/megaproxy487/vpn/BlockingDetectionTest.kt b/app/src/test/java/net/megaproxy487/vpn/BlockingDetectionTest.kt
new file mode 100644
index 0000000..da3c6d0
--- /dev/null
+++ b/app/src/test/java/net/megaproxy487/vpn/BlockingDetectionTest.kt
@@ -0,0 +1,30 @@
+package net.megaproxy487.vpn
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class BlockingDetectionTest {
+ @Test fun classifiesTimeoutAndReset() {
+ assertEquals(BlockingSignal.TCP_TIMEOUT, BlockingDetection.classify("dial tcp: i/o timeout"))
+ assertEquals(BlockingSignal.CONNECTION_RESET, BlockingDetection.classify("connection reset by peer"))
+ assertEquals(
+ BlockingSignal.TLS_HANDSHAKE_RESET,
+ BlockingDetection.classify("event=connection stage=tls_handshake result=failed reason=reset"),
+ )
+ assertEquals(
+ BlockingSignal.CONNECT_RESPONSE_TIMEOUT,
+ BlockingDetection.classify("event=connection stage=connect_response result=failed reason=timeout"),
+ )
+ assertEquals(
+ BlockingSignal.SSH_HANDSHAKE_TIMEOUT,
+ BlockingDetection.classify("SSH destination handshake: i/o timeout"),
+ )
+ }
+
+ @Test fun doesNotTreatAuthenticationOrCertificateErrorsAsBlocking() {
+ assertNull(BlockingDetection.classify("unable to authenticate: i/o timeout"))
+ assertNull(BlockingDetection.classify("x509 certificate error"))
+ assertNull(BlockingDetection.classify("connection refused"))
+ }
+}
diff --git a/app/src/test/java/net/megaproxy487/vpn/PrivacyLogSanitizerTest.kt b/app/src/test/java/net/megaproxy487/vpn/PrivacyLogSanitizerTest.kt
new file mode 100644
index 0000000..6c45513
--- /dev/null
+++ b/app/src/test/java/net/megaproxy487/vpn/PrivacyLogSanitizerTest.kt
@@ -0,0 +1,35 @@
+package net.megaproxy487.vpn
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PrivacyLogSanitizerTest {
+ @Test
+ fun removesNetworkIdentifiersCredentialsAndPackageNames() {
+ val sanitized = PrivacyLogSanitizer.sanitize(
+ "username=alice password=secret url=https://proxy.example.com/path " +
+ "ipv4=192.0.2.10:443 ipv6=[2001:db8::1]:443 package=com.example.privateapp " +
+ "email=user@example.com mac=00:11:22:33:44:55 path=/data/user/0/private/file",
+ )
+
+ listOf("alice", "secret", "proxy.example.com", "192.0.2.10", "2001:db8", "com.example.privateapp", "user@example.com", "00:11:22:33:44:55", "/data/user")
+ .forEach { assertFalse(sanitized.contains(it)) }
+ assertTrue(sanitized.contains("username=[redacted]"))
+ assertTrue(sanitized.contains("password=[redacted]"))
+ assertTrue(sanitized.contains("[url]"))
+ assertTrue(sanitized.contains("[ip]"))
+ assertTrue(sanitized.contains("[host]") || sanitized.contains("[package]"))
+ }
+
+ @Test
+ fun keepsStructuredDiagnosticsAndFlattensLines() {
+ val sanitized = PrivacyLogSanitizer.sanitize(
+ "event=connection conn=42 stage=tls_handshake\nresult=failed reason=reset dpi_hint=possible_tls_interference",
+ )
+
+ assertFalse(sanitized.contains('\n'))
+ assertTrue(sanitized.contains("event=connection"))
+ assertTrue(sanitized.contains("dpi_hint=possible_tls_interference"))
+ }
+}
diff --git a/app/src/test/java/net/megaproxy487/vpn/TransportProtocolTest.kt b/app/src/test/java/net/megaproxy487/vpn/TransportProtocolTest.kt
new file mode 100644
index 0000000..07c2a49
--- /dev/null
+++ b/app/src/test/java/net/megaproxy487/vpn/TransportProtocolTest.kt
@@ -0,0 +1,23 @@
+package net.megaproxy487.vpn
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class TransportProtocolTest {
+ @Test fun recognizesEstablishedTransportsOnly() {
+ assertEquals(
+ VpnTransportProtocol.HTTP_2,
+ transportProtocolFromDiagnostic("event=connection mode=proxy protocol=http2 stage=tunnel result=established"),
+ )
+ assertEquals(
+ VpnTransportProtocol.HTTP_1_1,
+ transportProtocolFromDiagnostic("event=connection mode=proxy stage=tunnel result=established"),
+ )
+ assertEquals(
+ VpnTransportProtocol.SSH_MULTIPLEXED,
+ transportProtocolFromDiagnostic("event=transport_capability transport=ssh multiplexed=true"),
+ )
+ assertNull(transportProtocolFromDiagnostic("event=transport_capability transport=https_proxy h2_negotiated=true"))
+ }
+}
diff --git a/artwork/launcher-icon.png b/artwork/launcher-icon.png
new file mode 100644
index 0000000..e402479
Binary files /dev/null and b/artwork/launcher-icon.png differ
diff --git a/assets/branding/megaproxy-launcher-master.png b/assets/branding/megaproxy-launcher-master.png
new file mode 100644
index 0000000..8f5eb7c
Binary files /dev/null and b/assets/branding/megaproxy-launcher-master.png differ
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..1b4481a
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ id("com.android.application") version "8.7.3" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+}
diff --git a/docs/en/index.md b/docs/en/index.md
new file mode 100644
index 0000000..efe01b0
--- /dev/null
+++ b/docs/en/index.md
@@ -0,0 +1,403 @@
+# MegaProxy server setup
+
+[Русская версия](../ru/index.md)
+
+This guide describes production-oriented servers for MegaProxy on a current Ubuntu or Debian VPS.
+It covers three transports:
+
+1. HTTPS proxy with TLS, Basic authentication, and HTTP/2.
+2. Direct SSH transport.
+3. SSH through a dedicated jump host.
+
+The examples favor standard protocols, a small attack surface, automatic certificate renewal, and
+predictable recovery after a reboot. Replace every value written as `EXAMPLE_VALUE` before using a
+configuration.
+
+## Choose the topology
+
+| Profile type | Public servers | Public ports | Best use |
+| --- | ---: | --- | --- |
+| HTTPS | 1 | TCP 443 and TCP 80 for certificate issuance | Efficient HTTP/2 multiplexing and simple credentials |
+| SSH | 1 | TCP 22, or another chosen SSH port | Standard OpenSSH deployment and key authentication |
+| SSH with Jump | 2 | Jump: TCP 22; destination: SSH reachable from jump | Separating the public entry point from the egress server |
+
+Use different providers or networks for failover profiles. A second profile on the same VM does
+not protect against a VM, provider, route, or datacenter failure.
+
+## Common server baseline
+
+The commands below assume a fresh server and a sudo-capable administrator account. Keep the
+existing administrator SSH session open until a second session has successfully connected.
+
+```shell
+sudo apt update
+sudo apt full-upgrade
+sudo apt install --yes ca-certificates curl openssl ufw unattended-upgrades
+sudo systemctl enable --now unattended-upgrades
+```
+
+Set an accurate clock; TLS and SSH diagnostics become confusing when it is wrong:
+
+```shell
+timedatectl status
+sudo systemctl enable --now systemd-timesyncd
+```
+
+Apply a default-deny inbound firewall policy and open only the ports required by the selected
+topology. Check the provider's network firewall as well as the OS firewall.
+
+```shell
+sudo ufw default deny incoming
+sudo ufw default allow outgoing
+sudo ufw allow 22/tcp
+sudo ufw enable
+sudo ufw status verbose
+```
+
+Before changing the SSH port, first open the new port and verify a new connection. Never remove the
+only working administrative path to the server.
+
+## HTTPS with HTTP/2
+
+This setup uses [GOST v3](https://v3.gost.run/en/), whose HTTP/2 proxy mode accepts CONNECT streams
+over TLS. MegaProxy negotiates `h2` with ALPN and reuses the HTTP/2 connection for multiple tunnels.
+
+### 1. Prepare DNS and the firewall
+
+Create an `A` record such as `proxy.example.com` pointing to the server. Add an `AAAA` record only
+if the server has working public IPv6. Wait until public DNS returns the new address.
+
+```shell
+sudo ufw allow 80/tcp
+sudo ufw allow 443/tcp
+```
+
+Port 80 is used only for the ACME HTTP challenge. Keep it available so certificates can renew.
+
+### 2. Obtain a public certificate
+
+Install Certbot and request a certificate while port 80 is unused:
+
+```shell
+sudo apt install --yes certbot
+sudo certbot certonly --standalone \
+ --domain proxy.example.com \
+ --agree-tos \
+ --email admin@example.com \
+ --no-eff-email
+sudo certbot renew --dry-run
+```
+
+Certbot installations normally configure automatic renewal. MegaProxy validates the system CA,
+hostname, and validity dates but does not pin a particular certificate, so routine renewal works
+without changing the profile.
+
+### 3. Install GOST
+
+Use a versioned GOST v3 binary or container from the
+[official releases](https://github.com/go-gost/gost/releases). Verify its published checksum and
+avoid unversioned third-party packages. The container example below keeps the installation easy to
+audit:
+
+```shell
+sudo apt install --yes docker.io
+sudo systemctl enable --now docker
+sudo install -d -m 0700 /opt/megaproxy
+```
+
+Generate a long random password locally and store it in a password manager:
+
+```shell
+openssl rand -base64 32
+```
+
+Create `/opt/megaproxy/gost.yml`:
+
+```yaml
+services:
+ - name: megaproxy-h2
+ addr: ":443"
+ handler:
+ type: http2
+ auth:
+ username: EXAMPLE_USERNAME
+ password: EXAMPLE_LONG_RANDOM_PASSWORD
+ listener:
+ type: http2
+ tls:
+ certFile: /etc/letsencrypt/live/proxy.example.com/fullchain.pem
+ keyFile: /etc/letsencrypt/live/proxy.example.com/privkey.pem
+ options:
+ minVersion: VersionTLS12
+ maxVersion: VersionTLS13
+ alpn:
+ - h2
+```
+
+Protect the configuration because it contains the Basic Auth password:
+
+```shell
+sudo chmod 600 /opt/megaproxy/gost.yml
+```
+
+Do not configure GOST's `mitm`, protocol sniffing, Web API, metrics endpoint, or profiling endpoint.
+MegaProxy needs only CONNECT forwarding, and these features are unnecessary for this deployment.
+
+Start a pinned GOST release. Replace `EXAMPLE_GOST_VERSION` with a reviewed v3 release tag:
+
+```shell
+sudo docker run --detach \
+ --name megaproxy-gost \
+ --restart unless-stopped \
+ --network host \
+ --volume /opt/megaproxy/gost.yml:/etc/gost/gost.yml:ro \
+ --volume /etc/letsencrypt:/etc/letsencrypt:ro \
+ --env GOST_LOGGER_LEVEL=warn \
+ gogost/gost:EXAMPLE_GOST_VERSION \
+ -C /etc/gost/gost.yml
+```
+
+Inspect startup without publishing credentials:
+
+```shell
+sudo docker ps --filter name=megaproxy-gost
+sudo docker logs --tail 100 megaproxy-gost
+```
+
+Reload the renewed certificate by restarting the small container. Create
+`/etc/letsencrypt/renewal-hooks/deploy/restart-megaproxy-gost`:
+
+```shell
+#!/bin/sh
+exec /usr/bin/docker restart megaproxy-gost
+```
+
+Then secure and test the hook:
+
+```shell
+sudo chmod 750 /etc/letsencrypt/renewal-hooks/deploy/restart-megaproxy-gost
+sudo certbot renew --dry-run
+```
+
+### 4. Verify and add the profile
+
+Confirm certificate verification and HTTP/2 ALPN from another machine:
+
+```shell
+openssl s_client \
+ -connect proxy.example.com:443 \
+ -servername proxy.example.com \
+ -alpn h2 \
+ -verify_return_error '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..5eed7ee
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/native/README.md b/native/README.md
new file mode 100644
index 0000000..8f94692
--- /dev/null
+++ b/native/README.md
@@ -0,0 +1,19 @@
+# Go dataplane
+
+This module is bound into `megaproxy.aar` with gomobile. It embeds tun2socks/gVisor,
+registers HTTPS CONNECT and SSH `direct-tcpip` transports, protects every upstream socket through Android
+`VpnService.protect`, verifies HTTPS proxy certificates and SSH host keys, and uses uTLS for HTTPS ClientHello control.
+SSH with Jump creates a nested SSH client through the jump session. SSH transports support TCP;
+DNS is carried over DoH, while arbitrary UDP (including QUIC) is intentionally blocked.
+`Start` always takes ownership of the passed duplicate TUN descriptor, including error paths.
+
+Build prerequisites: Go 1.26+, Android SDK/NDK and `gomobile`.
+
+```shell
+go install golang.org/x/mobile/cmd/gomobile@latest
+gomobile init
+gomobile bind -target=android -androidapi 26 -o ../app/libs/megaproxy.aar ./mobile
+```
+
+Run `go test ./...` before producing the AAR. The dependency versions are pinned in `go.mod`;
+commit the generated `go.sum` after the first successful dependency download.
diff --git a/native/go.mod b/native/go.mod
new file mode 100644
index 0000000..1b86f91
--- /dev/null
+++ b/native/go.mod
@@ -0,0 +1,31 @@
+module dev.megaproxy/native
+
+go 1.26.3
+
+require (
+ github.com/refraction-networking/utls v1.8.2
+ github.com/xjasonlyu/tun2socks/v2 v2.7.0
+ golang.org/x/crypto v0.55.0
+ golang.org/x/net v0.58.0
+ gvisor.dev/gvisor v0.0.0-20260701204157-69c2d17aea96
+)
+
+require (
+ github.com/andybalholm/brotli v1.0.6 // indirect
+ github.com/google/btree v1.1.3 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/klauspost/compress v1.17.4 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/zap v1.28.0 // indirect
+ golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
+ golang.org/x/mobile v0.0.0-20260821190718-4776eadac327 // indirect
+ golang.org/x/mod v0.39.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.41.0 // indirect
+ golang.org/x/time v0.15.0 // indirect
+ golang.org/x/tools v0.49.0 // indirect
+)
+
+tool golang.org/x/mobile/cmd/gobind
diff --git a/native/go.sum b/native/go.sum
new file mode 100644
index 0000000..52055f8
--- /dev/null
+++ b/native/go.sum
@@ -0,0 +1,60 @@
+github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
+github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
+github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
+github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
+github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/xjasonlyu/tun2socks/v2 v2.7.0 h1:fYEN0Q1sSanuoID8xvUTsMODbKLJJH/J5ywYoMRxIPw=
+github.com/xjasonlyu/tun2socks/v2 v2.7.0/go.mod h1:XHgJ3wbs63mZxGbJawt6jjNX65aPDl+Qhn2ePPEE4KI=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
+go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
+golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
+golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
+golang.org/x/mobile v0.0.0-20260821190718-4776eadac327 h1:D/wiQ6AoTYjDtSD0HMPhU8O40NUP8EF0UmDhIYCnG4I=
+golang.org/x/mobile v0.0.0-20260821190718-4776eadac327/go.mod h1:D9q8rgXu13Q3uuM+Vuy6F/DG1WF/giTPLtqQ9on5B1M=
+golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
+golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
+golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
+golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
+golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
+golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
+golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gvisor.dev/gvisor v0.0.0-20260701204157-69c2d17aea96 h1:LZXOf4NwvAwUy/eI8P+Y3DUdYLyaCEDNPEjsL2OP2ro=
+gvisor.dev/gvisor v0.0.0-20260701204157-69c2d17aea96/go.mod h1:8aLQqUBHDH8fY5y60lzmwDpMMbQCcT3EBfoSwhfaGCY=
diff --git a/native/mobile/bootstrap.go b/native/mobile/bootstrap.go
new file mode 100644
index 0000000..f3d8ec1
--- /dev/null
+++ b/native/mobile/bootstrap.go
@@ -0,0 +1,185 @@
+package mobile
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "syscall"
+ "time"
+)
+
+type bootstrapResolver struct {
+ name string
+ address string
+ url string
+}
+
+// Each resolver is dialled by a documented IP address, while TLS still verifies
+// the provider hostname. Multiple networks block individual public resolvers, so
+// bootstrap must not have a single point of failure.
+var bootstrapResolvers = []bootstrapResolver{
+ {name: "cloudflare", address: "1.1.1.1:443", url: "https://cloudflare-dns.com/dns-query"},
+ {name: "yandex_primary", address: "77.88.8.8:443", url: "https://common.dot.dns.yandex.net/dns-query"},
+ {name: "yandex_secondary", address: "77.88.8.1:443", url: "https://common.dot.dns.yandex.net/dns-query"},
+ {name: "google", address: "8.8.8.8:443", url: "https://dns.google/dns-query"},
+ {name: "quad9", address: "9.9.9.9:443", url: "https://dns.quad9.net/dns-query"},
+}
+
+// ResolveProxy bootstraps the proxy address through protected encrypted DNS.
+func ResolveProxy(host string, protector Protector, reporter Reporter) (string, error) {
+ query, id, err := buildAQuery(host)
+ if err != nil {
+ return "", err
+ }
+ var failures []error
+ for _, resolver := range bootstrapResolvers {
+ ip, resolveErr := resolveProxyWithResolver(query, id, resolver, protector, reporter)
+ if resolveErr == nil {
+ return ip, nil
+ }
+ failures = append(failures, fmt.Errorf("%s: %w", resolver.name, resolveErr))
+ report(reporter, "event=bootstrap_dns provider=%s result=failed reason=%s", resolver.name, errorClass(resolveErr))
+ }
+ return "", fmt.Errorf("all bootstrap DoH resolvers failed: %w", errors.Join(failures...))
+}
+
+func resolveProxyWithResolver(query []byte, id uint16, resolver bootstrapResolver, protector Protector, reporter Reporter) (string, error) {
+ dialer := net.Dialer{
+ Timeout: 6 * time.Second,
+ Control: func(_, _ string, raw syscall.RawConn) error {
+ var protectErr error
+ if err := raw.Control(func(fd uintptr) {
+ if !protector.Protect(int(fd)) {
+ protectErr = errors.New("VpnService.protect rejected bootstrap DoH socket")
+ }
+ }); err != nil {
+ return err
+ }
+ return protectErr
+ },
+ }
+ transport := &http.Transport{
+ Proxy: nil,
+ TLSHandshakeTimeout: 6 * time.Second,
+ MaxResponseHeaderBytes: 64 * 1024,
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return dialer.DialContext(ctx, "tcp", resolver.address)
+ },
+ }
+ defer transport.CloseIdleConnections()
+ client := &http.Client{Transport: transport, Timeout: 8 * time.Second}
+ request, err := http.NewRequest(http.MethodPost, resolver.url, bytes.NewReader(query))
+ if err != nil {
+ return "", err
+ }
+ request.Header.Set("Accept", "application/dns-message")
+ request.Header.Set("Content-Type", "application/dns-message")
+ report(reporter, "event=bootstrap_dns stage=request result=started transport=protected_doh provider=%s", resolver.name)
+ response, err := client.Do(request)
+ if err != nil {
+ return "", fmt.Errorf("bootstrap DoH request: %w", err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("bootstrap DoH returned %s", response.Status)
+ }
+ payload, err := io.ReadAll(io.LimitReader(response.Body, 65536))
+ if err != nil {
+ return "", err
+ }
+ ip, err := parseAResponse(payload, id)
+ if err != nil {
+ return "", err
+ }
+ report(reporter, "event=bootstrap_dns stage=response result=success family=ipv4 provider=%s", resolver.name)
+ return ip, nil
+}
+
+func buildAQuery(host string) ([]byte, uint16, error) {
+ var idBytes [2]byte
+ if _, err := rand.Read(idBytes[:]); err != nil {
+ return nil, 0, err
+ }
+ id := binary.BigEndian.Uint16(idBytes[:])
+ message := make([]byte, 12, 256)
+ binary.BigEndian.PutUint16(message[0:2], id)
+ binary.BigEndian.PutUint16(message[2:4], 0x0100)
+ binary.BigEndian.PutUint16(message[4:6], 1)
+ for _, label := range bytes.Split([]byte(host), []byte{'.'}) {
+ if len(label) == 0 || len(label) > 63 {
+ return nil, 0, errors.New("invalid proxy hostname for bootstrap DNS")
+ }
+ message = append(message, byte(len(label)))
+ message = append(message, label...)
+ }
+ message = append(message, 0, 0, 1, 0, 1)
+ return message, id, nil
+}
+
+func parseAResponse(message []byte, id uint16) (string, error) {
+ if len(message) < 12 || binary.BigEndian.Uint16(message[0:2]) != id {
+ return "", errors.New("invalid bootstrap DNS response")
+ }
+ if binary.BigEndian.Uint16(message[2:4])&0x000f != 0 {
+ return "", errors.New("bootstrap DNS returned an error")
+ }
+ questions := int(binary.BigEndian.Uint16(message[4:6]))
+ answers := int(binary.BigEndian.Uint16(message[6:8]))
+ offset := 12
+ for range questions {
+ var err error
+ offset, err = skipDNSName(message, offset)
+ if err != nil || offset+4 > len(message) {
+ return "", errors.New("malformed bootstrap DNS question")
+ }
+ offset += 4
+ }
+ for range answers {
+ var err error
+ offset, err = skipDNSName(message, offset)
+ if err != nil || offset+10 > len(message) {
+ return "", errors.New("malformed bootstrap DNS answer")
+ }
+ recordType := binary.BigEndian.Uint16(message[offset : offset+2])
+ recordClass := binary.BigEndian.Uint16(message[offset+2 : offset+4])
+ length := int(binary.BigEndian.Uint16(message[offset+8 : offset+10]))
+ offset += 10
+ if offset+length > len(message) {
+ return "", errors.New("truncated bootstrap DNS answer")
+ }
+ if recordType == 1 && recordClass == 1 && length == net.IPv4len {
+ return net.IP(message[offset : offset+length]).String(), nil
+ }
+ offset += length
+ }
+ return "", errors.New("bootstrap DNS response contains no IPv4 address")
+}
+
+func skipDNSName(message []byte, offset int) (int, error) {
+ for {
+ if offset >= len(message) {
+ return 0, io.ErrUnexpectedEOF
+ }
+ length := int(message[offset])
+ offset++
+ if length == 0 {
+ return offset, nil
+ }
+ if length&0xc0 == 0xc0 {
+ if offset >= len(message) {
+ return 0, io.ErrUnexpectedEOF
+ }
+ return offset + 1, nil
+ }
+ if length > 63 || offset+length > len(message) {
+ return 0, errors.New("invalid DNS name")
+ }
+ offset += length
+ }
+}
diff --git a/native/mobile/bootstrap_test.go b/native/mobile/bootstrap_test.go
new file mode 100644
index 0000000..3fb44ad
--- /dev/null
+++ b/native/mobile/bootstrap_test.go
@@ -0,0 +1,35 @@
+package mobile
+
+import (
+ "net"
+ "net/url"
+ "testing"
+)
+
+func TestBootstrapResolversUseDirectHTTPSAddresses(t *testing.T) {
+ if len(bootstrapResolvers) < 3 {
+ t.Fatalf("bootstrap resolver count = %d, want failover", len(bootstrapResolvers))
+ }
+ for _, resolver := range bootstrapResolvers {
+ parsed, err := url.Parse(resolver.url)
+ if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" {
+ t.Fatalf("invalid resolver URL %q", resolver.url)
+ }
+ host, port, err := net.SplitHostPort(resolver.address)
+ if err != nil || net.ParseIP(host) == nil || port != "443" {
+ t.Fatalf("invalid direct resolver address %q", resolver.address)
+ }
+ }
+}
+
+func TestBootstrapIncludesYandexRedundancy(t *testing.T) {
+ count := 0
+ for _, resolver := range bootstrapResolvers {
+ if resolver.name == "yandex_primary" || resolver.name == "yandex_secondary" {
+ count++
+ }
+ }
+ if count != 2 {
+ t.Fatalf("Yandex resolver count = %d, want 2", count)
+ }
+}
diff --git a/native/mobile/config.go b/native/mobile/config.go
new file mode 100644
index 0000000..54a7c18
--- /dev/null
+++ b/native/mobile/config.go
@@ -0,0 +1,198 @@
+package mobile
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net"
+ "strconv"
+ "strings"
+
+ tls "github.com/refraction-networking/utls"
+)
+
+type config struct {
+ Type string `json:"type"`
+ Host string `json:"host"`
+ DialHost string `json:"dialHost"`
+ Port int `json:"port"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ AllowInvalidProxyCertificate bool `json:"allowInvalidProxyCertificate"`
+ Profile string `json:"profile"`
+ CustomJA3 string `json:"customJa3"`
+ DoHURL string `json:"dohUrl"`
+ DoHFallbackURLs []string `json:"dohFallbackUrls"`
+ AllowIPv6 bool `json:"allowIpv6"`
+ BypassLocalNetworks bool `json:"bypassLocalNetworks"`
+ PrivateKey string `json:"privateKey"`
+ SSHProfile string `json:"sshProfile"`
+ TrustedHostKey string `json:"trustedHostKey"`
+ AcceptAnyHostKey bool `json:"acceptAnyHostKey"`
+ JumpHost string `json:"jumpHost"`
+ JumpDialHost string `json:"jumpDialHost"`
+ JumpPort int `json:"jumpPort"`
+ JumpUsername string `json:"jumpUsername"`
+ JumpPassword string `json:"jumpPassword"`
+ JumpPrivateKey string `json:"jumpPrivateKey"`
+ JumpTrustedHostKey string `json:"jumpTrustedHostKey"`
+ JumpAcceptAnyHostKey bool `json:"jumpAcceptAnyHostKey"`
+ SameJumpAuthentication bool `json:"sameJumpAuthentication"`
+ SSHAuthMode string `json:"sshAuthMode"`
+ SSHKeepaliveSeconds int `json:"sshKeepaliveSeconds"`
+ SSHMaxChannels int `json:"sshMaxChannels"`
+ SSHRotationMinutes int `json:"sshRotationMinutes"`
+ SSHRotationMB int `json:"sshRotationMb"`
+}
+
+func parseConfig(raw string) (config, error) {
+ var c config
+ if err := json.Unmarshal([]byte(raw), &c); err != nil {
+ return c, fmt.Errorf("decode config: %w", err)
+ }
+ c.Host = strings.TrimSpace(c.Host)
+ c.DialHost = strings.TrimSpace(c.DialHost)
+ if c.Type == "" {
+ c.Type = "HTTPS"
+ }
+ if c.Host == "" || strings.ContainsAny(c.Host, "/: \t\r\n") {
+ return c, errors.New("invalid proxy hostname")
+ }
+ if net.ParseIP(c.DialHost) == nil {
+ return c, errors.New("proxy bootstrap IP is missing or invalid")
+ }
+ if c.Port < 1 || c.Port > 65535 {
+ return c, errors.New("invalid proxy port")
+ }
+ if c.Type == "HTTPS" && (c.Username == "" || c.Password == "") {
+ return c, errors.New("basic auth credentials are required")
+ }
+ if c.Type != "HTTPS" && c.Username == "" {
+ return c, errors.New("SSH username is required")
+ }
+ if c.Type == "SSH_JUMP" {
+ if c.JumpHost == "" || strings.ContainsAny(c.JumpHost, "/: \t\r\n") {
+ return c, errors.New("invalid jump hostname")
+ }
+ if net.ParseIP(c.JumpDialHost) == nil {
+ return c, errors.New("jump bootstrap IP is missing or invalid")
+ }
+ if c.JumpPort < 1 || c.JumpPort > 65535 {
+ return c, errors.New("invalid jump port")
+ }
+ if c.SameJumpAuthentication {
+ c.JumpUsername, c.JumpPassword, c.JumpPrivateKey = c.Username, c.Password, c.PrivateKey
+ }
+ if c.JumpUsername == "" {
+ return c, errors.New("jump SSH username is required")
+ }
+ }
+ if err := validateDoHURL(c.DoHURL); err != nil {
+ return c, err
+ }
+ for _, fallbackURL := range c.DoHFallbackURLs {
+ if err := validateDoHURL(fallbackURL); err != nil {
+ return c, fmt.Errorf("invalid fallback DoH URL: %w", err)
+ }
+ }
+ if c.Type == "HTTPS" {
+ if _, err := c.helloID(); err != nil {
+ return c, err
+ }
+ }
+ if c.Type != "HTTPS" && c.Type != "SSH" && c.Type != "SSH_JUMP" {
+ return c, fmt.Errorf("unsupported proxy type %q", c.Type)
+ }
+ if c.SSHAuthMode == "" {
+ c.SSHAuthMode = "AUTO"
+ }
+ if c.SSHAuthMode != "AUTO" && c.SSHAuthMode != "PASSWORD_ONLY" && c.SSHAuthMode != "KEY_ONLY" {
+ return c, errors.New("invalid SSH authentication mode")
+ }
+ if c.SSHMaxChannels == 0 {
+ c.SSHMaxChannels = 32
+ }
+ if c.SSHMaxChannels < 1 || c.SSHMaxChannels > 256 {
+ return c, errors.New("invalid SSH channel limit")
+ }
+ return c, nil
+}
+
+func (c config) address() string { return net.JoinHostPort(c.DialHost, strconv.Itoa(c.Port)) }
+
+func (c config) displayAddress() string { return net.JoinHostPort(c.Host, strconv.Itoa(c.Port)) }
+func (c config) jumpAddress() string {
+ return net.JoinHostPort(c.JumpDialHost, strconv.Itoa(c.JumpPort))
+}
+
+func (c config) helloID() (tls.ClientHelloID, error) {
+ switch c.Profile {
+ case "CHROME_ANDROID":
+ return tls.HelloChrome_133, nil
+ case "FIREFOX_ANDROID":
+ return tls.HelloFirefox_120, nil
+ case "EDGE_ANDROID":
+ return tls.HelloEdge_85, nil
+ case "RANDOMIZED":
+ return tls.HelloRandomizedALPN, nil
+ case "CUSTOM":
+ if _, err := parseJA3(c.CustomJA3); err != nil {
+ return tls.ClientHelloID{}, err
+ }
+ return tls.HelloCustom, nil
+ default:
+ return tls.ClientHelloID{}, fmt.Errorf("unsupported TLS profile %q", c.Profile)
+ }
+}
+
+type ja3Spec struct {
+ Version uint16
+ Ciphers []uint16
+ Extensions []uint16
+ Groups []uint16
+ Points []uint8
+}
+
+func parseJA3(raw string) (ja3Spec, error) {
+ var out ja3Spec
+ if len(raw) > 8192 {
+ return out, errors.New("JA3 is too long")
+ }
+ fields := strings.Split(strings.TrimSpace(raw), ",")
+ if len(fields) != 5 {
+ return out, errors.New("JA3 must contain five comma-separated fields")
+ }
+ values := make([][]uint16, 5)
+ for i, field := range fields {
+ if field == "" && i > 0 {
+ continue
+ }
+ for _, token := range strings.Split(field, "-") {
+ if len(values[i]) >= 256 {
+ return out, errors.New("JA3 field contains too many values")
+ }
+ n, err := strconv.ParseUint(token, 10, 16)
+ if err != nil {
+ return out, fmt.Errorf("invalid JA3 number %q", token)
+ }
+ values[i] = append(values[i], uint16(n))
+ }
+ }
+ if len(values[0]) != 1 {
+ return out, errors.New("JA3 TLS version is required")
+ }
+ out.Version, out.Ciphers, out.Extensions, out.Groups = values[0][0], values[1], values[2], values[3]
+ if out.Version != tls.VersionTLS12 && out.Version != tls.VersionTLS13 {
+ return out, errors.New("JA3 TLS version must be TLS 1.2 or TLS 1.3")
+ }
+ if len(out.Ciphers) == 0 {
+ return out, errors.New("JA3 must contain at least one cipher suite")
+ }
+ for _, point := range values[4] {
+ if point > 255 {
+ return out, errors.New("EC point format exceeds 255")
+ }
+ out.Points = append(out.Points, uint8(point))
+ }
+ return out, nil
+}
diff --git a/native/mobile/config_test.go b/native/mobile/config_test.go
new file mode 100644
index 0000000..2496127
--- /dev/null
+++ b/native/mobile/config_test.go
@@ -0,0 +1,45 @@
+package mobile
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestParseJA3(t *testing.T) {
+ s, err := parseJA3("771,4865-4866,0-10-11-13-16-43-51,29-23,0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s.Version != 771 || len(s.Ciphers) != 2 || len(s.Extensions) != 7 {
+ t.Fatalf("unexpected spec: %#v", s)
+ }
+}
+
+func TestParseJA3RejectsUnsafeOrUnboundedInput(t *testing.T) {
+ for _, raw := range []string{
+ "769,4865,0,29,0",
+ "771,,0,29,0",
+ strings.Repeat("1", 8193),
+ "771," + strings.TrimSuffix(strings.Repeat("4865-", 257), "-") + ",0,29,0",
+ } {
+ if _, err := parseJA3(raw); err == nil {
+ t.Fatalf("parseJA3(%q) unexpectedly succeeded", raw)
+ }
+ }
+}
+
+func TestConfigRejectsIPOrSchemeLikeHost(t *testing.T) {
+ _, err := parseConfig(`{"host":"https://proxy.example","dialHost":"192.0.2.1","port":443,"username":"u","password":"p","profile":"CHROME_ANDROID","dohUrl":"https://dns.google/dns-query"}`)
+ if err == nil {
+ t.Fatal("expected invalid hostname")
+ }
+}
+
+func TestDoHRequiresHTTPS(t *testing.T) {
+ if validateDoHURL("http://dns.example/query") == nil {
+ t.Fatal("expected HTTPS requirement")
+ }
+ if validateDoHURL("https://dns.example/dns-query") != nil {
+ t.Fatal("expected valid DoH URL")
+ }
+}
diff --git a/native/mobile/diagnostics.go b/native/mobile/diagnostics.go
new file mode 100644
index 0000000..06fe3f7
--- /dev/null
+++ b/native/mobile/diagnostics.go
@@ -0,0 +1,61 @@
+package mobile
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "strings"
+ "sync/atomic"
+)
+
+var diagnosticConnectionSequence atomic.Uint64
+
+// Reporter is implemented by Android and receives sanitized diagnostic events.
+type Reporter interface {
+ Report(message string)
+}
+
+func nextDiagnosticConnectionID() uint64 { return diagnosticConnectionSequence.Add(1) }
+
+func errorClass(err error) string {
+ if err == nil {
+ return "none"
+ }
+ if errors.Is(err, io.EOF) {
+ return "eof"
+ }
+ if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
+ return "timeout"
+ }
+ message := strings.ToLower(err.Error())
+ switch {
+ case strings.Contains(message, "reset"):
+ return "reset"
+ case strings.Contains(message, "refused"):
+ return "refused"
+ case strings.Contains(message, "unreachable"):
+ return "unreachable"
+ case strings.Contains(message, "certificate") || strings.Contains(message, "x509"):
+ return "certificate"
+ case strings.Contains(message, "tls") && strings.Contains(message, "alert"):
+ return "tls_alert"
+ case strings.Contains(message, "protect rejected"):
+ return "vpn_protect"
+ default:
+ return "other"
+ }
+}
+
+func tlsInterferenceHint(reason string) string {
+ if reason == "reset" || reason == "eof" || reason == "timeout" {
+ return "possible_tls_interference"
+ }
+ return "none"
+}
+
+func report(reporter Reporter, format string, args ...any) {
+ if reporter != nil {
+ reporter.Report(fmt.Sprintf(format, args...))
+ }
+}
diff --git a/native/mobile/dialer.go b/native/mobile/dialer.go
new file mode 100644
index 0000000..ea774e7
--- /dev/null
+++ b/native/mobile/dialer.go
@@ -0,0 +1,478 @@
+package mobile
+
+import (
+ "bufio"
+ "context"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "time"
+
+ tls "github.com/refraction-networking/utls"
+ M "github.com/xjasonlyu/tun2socks/v2/metadata"
+)
+
+var errUDPBlocked = errors.New("UDP is intentionally blocked")
+
+// Protector is implemented by Android and calls VpnService.protect(fd).
+type Protector interface{ Protect(fd int) bool }
+
+type httpsConnectDialer struct {
+ config config
+ protector Protector
+ reporter Reporter
+ cacheMu sync.Mutex
+ sessionCache tls.ClientSessionCache
+ dohClient *http.Client
+ dohInFlight chan struct{}
+ connections chan struct{}
+ h2Session *http2ConnectSession
+ h2Disabled bool
+}
+
+func (d *httpsConnectDialer) Close() error {
+ d.cacheMu.Lock()
+ defer d.cacheMu.Unlock()
+ if d.dohClient != nil {
+ d.dohClient.CloseIdleConnections()
+ d.dohClient = nil
+ }
+ if d.h2Session != nil {
+ _ = d.h2Session.close()
+ d.h2Session = nil
+ }
+ return nil
+}
+
+func (d *httpsConnectDialer) DialContext(ctx context.Context, metadata *M.Metadata) (net.Conn, error) {
+ d.cacheMu.Lock()
+ if d.connections == nil {
+ d.connections = make(chan struct{}, 256)
+ }
+ connections := d.connections
+ d.cacheMu.Unlock()
+ select {
+ case connections <- struct{}{}:
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ connection, err := d.connectTarget(ctx, metadata.DestinationAddress())
+ if err != nil {
+ <-connections
+ return nil, err
+ }
+ return &slotConn{Conn: connection, release: func() { <-connections }}, nil
+}
+
+func (d *httpsConnectDialer) connectTarget(ctx context.Context, target string) (net.Conn, error) {
+ connectionID := nextDiagnosticConnectionID()
+ totalStarted := time.Now()
+ if !d.config.AllowIPv6 {
+ host, _, err := net.SplitHostPort(target)
+ if err == nil {
+ if ip := net.ParseIP(host); ip != nil && ip.To4() == nil {
+ err = errors.New("IPv6 destination blocked by IPv4-only mode")
+ report(d.reporter, "event=connection conn=%d stage=policy result=blocked reason=ipv6_disabled", connectionID)
+ return nil, err
+ }
+ }
+ }
+ if d.config.BypassLocalNetworks && isLocalNetworkTarget(target) {
+ report(d.reporter, "event=connection conn=%d mode=direct stage=tcp_connect result=started", connectionID)
+ directStarted := time.Now()
+ connection, err := d.protectedDialer().DialContext(ctx, "tcp", target)
+ if err != nil {
+ err = fmt.Errorf("dial local network directly: %w", err)
+ report(d.reporter, "event=connection conn=%d mode=direct stage=tcp_connect result=failed reason=%s elapsed_ms=%d", connectionID, errorClass(err), time.Since(directStarted).Milliseconds())
+ return nil, err
+ }
+ report(d.reporter, "event=connection conn=%d mode=direct stage=tcp_connect result=success elapsed_ms=%d", connectionID, time.Since(directStarted).Milliseconds())
+ return &diagnosticConn{Conn: connection, connectionID: connectionID, reporter: d.reporter}, nil
+ }
+ if session := d.currentHTTP2Session(); session != nil {
+ connection, err := d.openHTTP2Tunnel(ctx, session, target, connectionID, totalStarted, true)
+ if err == nil {
+ return connection, nil
+ }
+ if shouldFallbackToHTTP1(err) {
+ d.disableHTTP2(session)
+ report(d.reporter, "event=http2_session result=unsupported action=fallback_http1")
+ return d.connectTarget(ctx, target)
+ }
+ d.invalidateHTTP2Session(session)
+ report(d.reporter, "event=http2_session result=stale action=reconnect reason=%s", errorClass(err))
+ }
+ report(d.reporter, "event=connection conn=%d mode=proxy stage=tcp_connect result=started fingerprint=%s", connectionID, d.config.Profile)
+ dialStarted := time.Now()
+ raw, err := d.protectedDialer().DialContext(ctx, "tcp", d.config.address())
+ if err != nil {
+ recordConnectionOutcome(false)
+ err = fmt.Errorf("dial HTTPS proxy: %w", err)
+ report(d.reporter, "event=connection conn=%d mode=proxy stage=tcp_connect result=failed reason=%s elapsed_ms=%d", connectionID, errorClass(err), time.Since(dialStarted).Milliseconds())
+ return nil, err
+ }
+ recordProxyLatency(time.Since(dialStarted))
+ report(d.reporter, "event=connection conn=%d mode=proxy stage=tcp_connect result=success elapsed_ms=%d", connectionID, time.Since(dialStarted).Milliseconds())
+ closeOnError := true
+ defer func() {
+ if closeOnError {
+ _ = raw.Close()
+ }
+ }()
+
+ hello, err := d.config.helloID()
+ if err != nil {
+ recordConnectionOutcome(false)
+ report(d.reporter, "event=connection conn=%d mode=proxy stage=fingerprint result=failed reason=invalid_configuration", connectionID)
+ return nil, err
+ }
+ uconn := tls.UClient(raw, &tls.Config{
+ ServerName: d.config.Host,
+ MinVersion: tls.VersionTLS12,
+ InsecureSkipVerify: d.config.AllowInvalidProxyCertificate,
+ ClientSessionCache: d.clientSessionCache(),
+ }, hello)
+ if hello == tls.HelloCustom {
+ if err := applyJA3(uconn, d.config.CustomJA3, d.config.Host); err != nil {
+ recordConnectionOutcome(false)
+ report(d.reporter, "event=connection conn=%d mode=proxy stage=fingerprint result=failed reason=invalid_ja3", connectionID)
+ return nil, err
+ }
+ }
+ if d.isHTTP2Disabled() {
+ if err := forceHTTP11ALPN(uconn); err != nil {
+ recordConnectionOutcome(false)
+ return nil, fmt.Errorf("configure HTTP/1.1 ALPN fallback: %w", err)
+ }
+ }
+ tlsStarted := time.Now()
+ handshakeContext, cancelHandshake := context.WithTimeout(ctx, 15*time.Second)
+ err = uconn.HandshakeContext(handshakeContext)
+ cancelHandshake()
+ if err != nil {
+ recordConnectionOutcome(false)
+ reason := errorClass(err)
+ report(d.reporter, "event=connection conn=%d mode=proxy stage=tls_handshake result=failed reason=%s elapsed_ms=%d dpi_hint=%s fingerprint=%s", connectionID, reason, time.Since(tlsStarted).Milliseconds(), tlsInterferenceHint(reason), d.config.Profile)
+ err = fmt.Errorf("TLS handshake with proxy: %w", err)
+ return nil, err
+ }
+ tlsState := uconn.ConnectionState()
+ report(d.reporter, "event=connection conn=%d mode=proxy stage=tls_handshake result=success elapsed_ms=%d version=0x%04x cipher=0x%04x alpn=%q certificates=%d", connectionID, time.Since(tlsStarted).Milliseconds(), tlsState.Version, tlsState.CipherSuite, tlsState.NegotiatedProtocol, len(tlsState.PeerCertificates))
+ h2Negotiated := tlsState.NegotiatedProtocol == "h2"
+ report(d.reporter, "event=transport_capability transport=https_proxy tls_version=0x%04x outer_alpn=%s h2_negotiated=%t h2_connect_supported=true connect_protocol=%s session_resumed=%t", tlsState.Version, normalizedALPN(tlsState.NegotiatedProtocol), h2Negotiated, connectProtocol(h2Negotiated), tlsState.DidResume)
+ if h2Negotiated {
+ session, sessionErr := newHTTP2ConnectSession(uconn)
+ if sessionErr != nil {
+ recordConnectionOutcome(false)
+ return nil, fmt.Errorf("initialize HTTP/2 proxy session: %w", sessionErr)
+ }
+ closeOnError = false // The HTTP/2 session now owns the outer TLS connection.
+ session = d.installHTTP2Session(session)
+ connection, connectErr := d.openHTTP2Tunnel(ctx, session, target, connectionID, totalStarted, false)
+ if shouldFallbackToHTTP1(connectErr) {
+ d.disableHTTP2(session)
+ report(d.reporter, "event=http2_session result=unsupported action=fallback_http1")
+ return d.connectTarget(ctx, target)
+ }
+ return connection, connectErr
+ }
+ deadline := time.Now().Add(15 * time.Second)
+ if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
+ deadline = contextDeadline
+ }
+ if err := uconn.SetDeadline(deadline); err != nil {
+ return nil, fmt.Errorf("set CONNECT deadline: %w", err)
+ }
+
+ auth := base64.StdEncoding.EncodeToString([]byte(d.config.Username + ":" + d.config.Password))
+ if _, err := fmt.Fprintf(uconn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\nProxy-Authorization: Basic %s\r\nProxy-Connection: Keep-Alive\r\n\r\n", target, target, auth); err != nil {
+ recordConnectionOutcome(false)
+ err = fmt.Errorf("write CONNECT: %w", err)
+ report(d.reporter, "event=connection conn=%d mode=proxy stage=connect_write result=failed reason=%s", connectionID, errorClass(err))
+ return nil, err
+ }
+ // A peer-controlled CONNECT header must not be allowed to grow the process heap
+ // without bound. Any tunneled bytes already buffered after the header are retained.
+ reader := bufio.NewReader(&limitedHeaderReader{reader: uconn, remaining: 64 * 1024})
+ response, err := http.ReadResponse(reader, &http.Request{Method: http.MethodConnect})
+ if err != nil {
+ recordConnectionOutcome(false)
+ err = fmt.Errorf("read CONNECT response: %w", err)
+ reason := errorClass(err)
+ report(d.reporter, "event=connection conn=%d mode=proxy stage=connect_response result=failed reason=%s dpi_hint=%s", connectionID, reason, tlsInterferenceHint(reason))
+ return nil, err
+ }
+ if response.StatusCode != http.StatusOK {
+ recordConnectionOutcome(false)
+ _ = response.Body.Close()
+ err = fmt.Errorf("proxy CONNECT returned %s", response.Status)
+ report(d.reporter, "event=connection conn=%d mode=proxy stage=connect_response result=rejected status=%d status_class=%dxx", connectionID, response.StatusCode, response.StatusCode/100)
+ return nil, err
+ }
+ if err := uconn.SetDeadline(time.Time{}); err != nil {
+ return nil, fmt.Errorf("clear CONNECT deadline: %w", err)
+ }
+ recordConnectionOutcome(true)
+ report(d.reporter, "event=connection conn=%d mode=proxy stage=tunnel result=established total_ms=%d", connectionID, time.Since(totalStarted).Milliseconds())
+ closeOnError = false
+ var connection net.Conn = uconn
+ if reader.Buffered() > 0 {
+ connection = &bufferedConn{Conn: uconn, reader: reader}
+ }
+ return &diagnosticConn{Conn: connection, connectionID: connectionID, reporter: d.reporter}, nil
+}
+
+type http2ConnectStatusError struct{ status int }
+
+func (e *http2ConnectStatusError) Error() string {
+ return fmt.Sprintf("HTTP/2 proxy CONNECT returned status %d", e.status)
+}
+
+func shouldFallbackToHTTP1(err error) bool {
+ var statusError *http2ConnectStatusError
+ return errors.As(err, &statusError) && (statusError.status == http.StatusMethodNotAllowed || statusError.status == http.StatusNotImplemented)
+}
+
+func connectProtocol(h2 bool) string {
+ if h2 {
+ return "http2"
+ }
+ return "http1_1"
+}
+
+func (d *httpsConnectDialer) currentHTTP2Session() *http2ConnectSession {
+ d.cacheMu.Lock()
+ defer d.cacheMu.Unlock()
+ if d.h2Session != nil && d.h2Session.canTakeRequest() {
+ return d.h2Session
+ }
+ return nil
+}
+
+func (d *httpsConnectDialer) installHTTP2Session(candidate *http2ConnectSession) *http2ConnectSession {
+ d.cacheMu.Lock()
+ defer d.cacheMu.Unlock()
+ if d.h2Session != nil && d.h2Session.canTakeRequest() {
+ _ = candidate.close()
+ return d.h2Session
+ }
+ if d.h2Session != nil {
+ _ = d.h2Session.close()
+ }
+ d.h2Session = candidate
+ report(d.reporter, "event=http2_session result=established multiplexed=true")
+ return candidate
+}
+
+func (d *httpsConnectDialer) invalidateHTTP2Session(session *http2ConnectSession) {
+ d.cacheMu.Lock()
+ defer d.cacheMu.Unlock()
+ if d.h2Session == session {
+ d.h2Session = nil
+ _ = session.close()
+ }
+}
+
+func (d *httpsConnectDialer) disableHTTP2(session *http2ConnectSession) {
+ d.cacheMu.Lock()
+ defer d.cacheMu.Unlock()
+ d.h2Disabled = true
+ if d.h2Session == session {
+ d.h2Session = nil
+ _ = session.close()
+ }
+}
+
+func (d *httpsConnectDialer) isHTTP2Disabled() bool {
+ d.cacheMu.Lock()
+ defer d.cacheMu.Unlock()
+ return d.h2Disabled
+}
+
+func (d *httpsConnectDialer) openHTTP2Tunnel(ctx context.Context, session *http2ConnectSession, target string, connectionID uint64, totalStarted time.Time, reused bool) (net.Conn, error) {
+ auth := "Basic " + base64.StdEncoding.EncodeToString([]byte(d.config.Username+":"+d.config.Password))
+ started := time.Now()
+ tunnel, status, err := session.openTunnel(ctx, target, auth)
+ if err != nil {
+ recordConnectionOutcome(false)
+ if status != 0 {
+ report(d.reporter, "event=connection conn=%d mode=proxy protocol=http2 stage=connect_response result=rejected status=%d status_class=%dxx reused_session=%t", connectionID, status, status/100, reused)
+ return nil, &http2ConnectStatusError{status: status}
+ }
+ report(d.reporter, "event=connection conn=%d mode=proxy protocol=http2 stage=connect_response result=failed reason=%s reused_session=%t", connectionID, errorClass(err), reused)
+ return nil, fmt.Errorf("HTTP/2 proxy CONNECT: %w", err)
+ }
+ recordProxyLatency(time.Since(started))
+ recordConnectionOutcome(true)
+ report(d.reporter, "event=connection conn=%d mode=proxy protocol=http2 stage=tunnel result=established stream_multiplexed=true reused_session=%t total_ms=%d", connectionID, reused, time.Since(totalStarted).Milliseconds())
+ return &diagnosticConn{Conn: tunnel, connectionID: connectionID, reporter: d.reporter}, nil
+}
+
+func normalizedALPN(value string) string {
+ if value == "" {
+ return "none"
+ }
+ return strings.ReplaceAll(value, " ", "_")
+}
+
+func forceHTTP11ALPN(connection *tls.UConn) error {
+ if err := connection.BuildHandshakeState(); err != nil {
+ return err
+ }
+ for _, extension := range connection.Extensions {
+ if alpn, ok := extension.(*tls.ALPNExtension); ok {
+ alpn.AlpnProtocols = []string{"http/1.1"}
+ return nil
+ }
+ }
+ connection.Extensions = append(connection.Extensions, &tls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}})
+ return nil
+}
+
+func (d *httpsConnectDialer) clientSessionCache() tls.ClientSessionCache {
+ d.cacheMu.Lock()
+ defer d.cacheMu.Unlock()
+ if d.sessionCache == nil {
+ d.sessionCache = tls.NewLRUClientSessionCache(32)
+ }
+ return d.sessionCache
+}
+
+func (d *httpsConnectDialer) protectedDialer() *net.Dialer {
+ return &net.Dialer{
+ Timeout: 15 * time.Second,
+ KeepAlive: 30 * time.Second,
+ Control: func(_, _ string, raw syscall.RawConn) error {
+ var protectErr error
+ if err := raw.Control(func(fd uintptr) {
+ if !d.protector.Protect(int(fd)) {
+ protectErr = errors.New("VpnService.protect rejected upstream socket")
+ }
+ }); err != nil {
+ return err
+ }
+ return protectErr
+ },
+ }
+}
+
+func isLocalNetworkTarget(target string) bool {
+ host, _, err := net.SplitHostPort(target)
+ if err != nil {
+ return false
+ }
+ ip := net.ParseIP(host)
+ if ip == nil {
+ return false
+ }
+ return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()
+}
+
+func (d *httpsConnectDialer) DialUDP(metadata *M.Metadata) (net.PacketConn, error) {
+ if metadata.DstPort != 53 {
+ return nil, errUDPBlocked
+ }
+ d.cacheMu.Lock()
+ if d.dohClient == nil {
+ d.dohClient = newDoHHTTPClient(d.connectTarget)
+ }
+ if d.dohInFlight == nil {
+ d.dohInFlight = make(chan struct{}, 8)
+ }
+ client := d.dohClient
+ limiter := d.dohInFlight
+ d.cacheMu.Unlock()
+ return newDoHPacketConnWithClient(d.config, d.reporter, d.connectTarget, d.config.DoHURL, client, limiter), nil
+}
+
+type bufferedConn struct {
+ net.Conn
+ reader *bufio.Reader
+}
+
+func (c *bufferedConn) Read(p []byte) (int, error) { return c.reader.Read(p) }
+
+type limitedHeaderReader struct {
+ reader io.Reader
+ remaining int
+ window uint32
+ done bool
+}
+
+func (r *limitedHeaderReader) Read(p []byte) (int, error) {
+ if r.done {
+ return r.reader.Read(p)
+ }
+ if r.remaining <= 0 {
+ return 0, errors.New("proxy CONNECT response headers exceed 64 KB")
+ }
+ if len(p) > r.remaining {
+ p = p[:r.remaining]
+ }
+ n, err := r.reader.Read(p)
+ r.remaining -= n
+ for _, value := range p[:n] {
+ r.window = r.window<<8 | uint32(value)
+ if r.window == 0x0d0a0d0a {
+ r.done = true
+ break
+ }
+ }
+ return n, err
+}
+
+type diagnosticConn struct {
+ net.Conn
+ connectionID uint64
+ reporter Reporter
+ once sync.Once
+ transferred atomic.Uint64
+}
+
+type slotConn struct {
+ net.Conn
+ release func()
+ once sync.Once
+}
+
+func (c *slotConn) Close() error {
+ err := c.Conn.Close()
+ c.once.Do(c.release)
+ return err
+}
+
+func (c *diagnosticConn) reportError(operation string, err error) {
+ if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
+ c.once.Do(func() {
+ reason := errorClass(err)
+ report(c.reporter, "event=connection conn=%d stage=tunnel_io result=failed operation=%s reason=%s transferred_bytes=%d dpi_hint=%s", c.connectionID, operation, reason, c.transferred.Load(), tlsInterferenceHint(reason))
+ })
+ }
+}
+
+func (c *diagnosticConn) Read(p []byte) (int, error) {
+ n, err := c.Conn.Read(p)
+ if n > 0 {
+ telemetry.downloadBytes.Add(uint64(n))
+ c.transferred.Add(uint64(n))
+ }
+ c.reportError("read", err)
+ return n, err
+}
+
+func (c *diagnosticConn) Write(p []byte) (int, error) {
+ n, err := c.Conn.Write(p)
+ if n > 0 {
+ telemetry.uploadBytes.Add(uint64(n))
+ c.transferred.Add(uint64(n))
+ }
+ c.reportError("write", err)
+ return n, err
+}
diff --git a/native/mobile/dialer_test.go b/native/mobile/dialer_test.go
new file mode 100644
index 0000000..094ee8d
--- /dev/null
+++ b/native/mobile/dialer_test.go
@@ -0,0 +1,54 @@
+package mobile
+
+import (
+ "bytes"
+ "io"
+ "testing"
+)
+
+func TestLocalNetworkTargets(t *testing.T) {
+ local := []string{
+ "10.0.0.1:443",
+ "172.16.0.1:80",
+ "192.168.1.20:8080",
+ "127.0.0.1:3000",
+ "169.254.1.1:22",
+ "[fd00::1]:443",
+ "[fe80::1]:443",
+ }
+ for _, target := range local {
+ if !isLocalNetworkTarget(target) {
+ t.Errorf("expected local target: %s", target)
+ }
+ }
+}
+
+func TestLimitedHeaderReaderRejectsOversizedHeaders(t *testing.T) {
+ raw := bytes.Repeat([]byte{'x'}, 32)
+ reader := &limitedHeaderReader{reader: bytes.NewReader(raw), remaining: 16}
+ if _, err := io.ReadAll(reader); err == nil {
+ t.Fatal("expected oversized CONNECT headers to fail")
+ }
+}
+
+func TestLimitedHeaderReaderBecomesUnlimitedAfterHeaders(t *testing.T) {
+ body := bytes.Repeat([]byte{'b'}, 128)
+ raw := append([]byte("HTTP/1.1 200 OK\r\n\r\n"), body...)
+ reader := &limitedHeaderReader{reader: bytes.NewReader(raw), remaining: 64}
+ got, err := io.ReadAll(reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(got, raw) {
+ t.Fatalf("unexpected data length: got %d, want %d", len(got), len(raw))
+ }
+}
+
+func TestPublicAndHostnameTargetsAreNotLocal(t *testing.T) {
+ public := []string{"1.1.1.1:443", "8.8.8.8:53", "example.com:443", "[2606:4700:4700::1111]:443"}
+ for _, target := range public {
+ if isLocalNetworkTarget(target) {
+ t.Errorf("expected non-local target: %s", target)
+ }
+ }
+}
diff --git a/native/mobile/doh.go b/native/mobile/doh.go
new file mode 100644
index 0000000..b642b9c
--- /dev/null
+++ b/native/mobile/doh.go
@@ -0,0 +1,277 @@
+package mobile
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+func validateDoHURL(raw string) error {
+ u, err := url.Parse(raw)
+ if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.Path == "" {
+ return errors.New("invalid HTTPS DoH URL")
+ }
+ if u.User != nil || u.Fragment != "" {
+ return errors.New("DoH URL must not contain credentials or fragment")
+ }
+ if port := u.Port(); port != "" {
+ value, parseErr := strconv.Atoi(port)
+ if parseErr != nil || value < 1 || value > 65535 {
+ return errors.New("DoH URL contains an invalid port")
+ }
+ }
+ return nil
+}
+
+type dnsReply struct {
+ payload []byte
+ addr net.Addr
+ err error
+}
+
+type dohPacketConn struct {
+ config config
+ reporter Reporter
+ connect func(context.Context, string) (net.Conn, error)
+ endpoints []string
+ endpointMu sync.Mutex
+ activeEndpoint int
+ replies chan dnsReply
+ inFlight chan struct{}
+ closed chan struct{}
+ closeOnce sync.Once
+ deadlineMu sync.Mutex
+ readDeadline time.Time
+ client *http.Client
+ context context.Context
+ cancel context.CancelFunc
+}
+
+func newDoHPacketConn(c config, reporter Reporter, connect func(context.Context, string) (net.Conn, error), endpoint string) *dohPacketConn {
+ return newDoHPacketConnWithClient(c, reporter, connect, endpoint, newDoHHTTPClient(connect), nil)
+}
+
+func newDoHHTTPClient(connect func(context.Context, string) (net.Conn, error)) *http.Client {
+ transport := &http.Transport{
+ Proxy: nil,
+ ForceAttemptHTTP2: true,
+ TLSHandshakeTimeout: 15 * time.Second,
+ MaxConnsPerHost: 2,
+ MaxIdleConnsPerHost: 2,
+ IdleConnTimeout: 90 * time.Second,
+ MaxResponseHeaderBytes: 64 * 1024,
+ DialContext: func(ctx context.Context, _, address string) (net.Conn, error) {
+ return connect(ctx, address)
+ },
+ }
+ return &http.Client{Transport: transport, Timeout: 20 * time.Second}
+}
+
+func newDoHPacketConnWithClient(c config, reporter Reporter, connect func(context.Context, string) (net.Conn, error), endpoint string, client *http.Client, limiter chan struct{}) *dohPacketConn {
+ ctx, cancel := context.WithCancel(context.Background())
+ if limiter == nil {
+ limiter = make(chan struct{}, 8)
+ }
+ endpoints := []string{endpoint}
+ for _, fallback := range c.DoHFallbackURLs {
+ if fallback != endpoint {
+ endpoints = append(endpoints, fallback)
+ }
+ }
+ return &dohPacketConn{
+ config: c, reporter: reporter, connect: connect, endpoints: endpoints, replies: make(chan dnsReply, 16), closed: make(chan struct{}),
+ inFlight: limiter, client: client, context: ctx, cancel: cancel,
+ }
+}
+
+func (c *dohPacketConn) WriteTo(payload []byte, addr net.Addr) (int, error) {
+ select {
+ case <-c.closed:
+ return 0, net.ErrClosed
+ default:
+ }
+ if !c.config.AllowIPv6 {
+ response, isAAAA, err := emptyAAAAResponse(payload)
+ if err != nil {
+ return 0, err
+ }
+ if isAAAA {
+ report(c.reporter, "event=doh result=suppressed query_type=aaaa reason=ipv4_only")
+ c.deliver(dnsReply{payload: response, addr: addr})
+ return len(payload), nil
+ }
+ }
+ query := append([]byte(nil), payload...)
+ select {
+ case c.inFlight <- struct{}{}:
+ case <-c.closed:
+ return 0, net.ErrClosed
+ case <-c.context.Done():
+ return 0, c.context.Err()
+ }
+ go func() {
+ defer func() { <-c.inFlight }()
+ var lastErr error
+ for _, candidate := range c.endpointOrder() {
+ body, err := c.queryEndpoint(candidate.url, query)
+ if err == nil {
+ c.setActiveEndpoint(candidate.index)
+ c.deliver(dnsReply{payload: body, addr: addr})
+ report(c.reporter, "event=doh result=success provider_index=%d", candidate.index)
+ return
+ }
+ lastErr = err
+ report(c.reporter, "event=doh result=failed provider_index=%d reason=%s", candidate.index, errorClass(err))
+ }
+ c.deliver(dnsReply{addr: addr, err: fmt.Errorf("all DoH providers failed: %w", lastErr)})
+ }()
+ return len(payload), nil
+}
+
+type indexedDoHEndpoint struct {
+ index int
+ url string
+}
+
+func (c *dohPacketConn) endpointOrder() []indexedDoHEndpoint {
+ c.endpointMu.Lock()
+ start := c.activeEndpoint
+ c.endpointMu.Unlock()
+ result := make([]indexedDoHEndpoint, 0, len(c.endpoints))
+ for offset := range len(c.endpoints) {
+ index := (start + offset) % len(c.endpoints)
+ result = append(result, indexedDoHEndpoint{index: index, url: c.endpoints[index]})
+ }
+ return result
+}
+
+func (c *dohPacketConn) setActiveEndpoint(index int) {
+ c.endpointMu.Lock()
+ c.activeEndpoint = index
+ c.endpointMu.Unlock()
+}
+
+func (c *dohPacketConn) queryEndpoint(endpoint string, query []byte) ([]byte, error) {
+ requestContext, cancel := context.WithTimeout(c.context, 3*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(requestContext, http.MethodPost, endpoint, bytes.NewReader(query))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/dns-message")
+ req.Header.Set("Content-Type", "application/dns-message")
+ response, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("DoH returned %s", response.Status)
+ }
+ if !strings.HasPrefix(response.Header.Get("Content-Type"), "application/dns-message") {
+ return nil, errors.New("DoH returned an unexpected content type")
+ }
+ body, err := io.ReadAll(io.LimitReader(response.Body, 65537))
+ if err != nil {
+ return nil, err
+ }
+ if len(body) > 65536 {
+ return nil, errors.New("DoH response exceeds 64 KB")
+ }
+ return body, nil
+}
+
+func emptyAAAAResponse(query []byte) ([]byte, bool, error) {
+ if len(query) < 12 || binary.BigEndian.Uint16(query[4:6]) != 1 {
+ return nil, false, nil
+ }
+ offset, err := skipDNSName(query, 12)
+ if err != nil || offset+4 > len(query) {
+ return nil, false, errors.New("malformed DNS query")
+ }
+ if binary.BigEndian.Uint16(query[offset:offset+2]) != 28 {
+ return nil, false, nil
+ }
+ response := append([]byte(nil), query[:offset+4]...)
+ requestFlags := binary.BigEndian.Uint16(query[2:4])
+ binary.BigEndian.PutUint16(response[2:4], 0x8000|(requestFlags&0x0100)|0x0080)
+ binary.BigEndian.PutUint16(response[6:8], 0)
+ binary.BigEndian.PutUint16(response[8:10], 0)
+ binary.BigEndian.PutUint16(response[10:12], 0)
+ return response, true, nil
+}
+
+func (c *dohPacketConn) deliver(reply dnsReply) {
+ select {
+ case c.replies <- reply:
+ case <-c.closed:
+ }
+}
+
+func (c *dohPacketConn) ReadFrom(buffer []byte) (int, net.Addr, error) {
+ c.deadlineMu.Lock()
+ deadline := c.readDeadline
+ c.deadlineMu.Unlock()
+ var timer <-chan time.Time
+ if !deadline.IsZero() {
+ duration := time.Until(deadline)
+ if duration <= 0 {
+ return 0, nil, timeoutError{}
+ }
+ t := time.NewTimer(duration)
+ defer t.Stop()
+ timer = t.C
+ }
+ select {
+ case reply := <-c.replies:
+ if reply.err != nil {
+ return 0, reply.addr, reply.err
+ }
+ if len(reply.payload) > len(buffer) {
+ return 0, reply.addr, io.ErrShortBuffer
+ }
+ return copy(buffer, reply.payload), reply.addr, nil
+ case <-timer:
+ return 0, nil, timeoutError{}
+ case <-c.closed:
+ return 0, nil, net.ErrClosed
+ }
+}
+
+func (c *dohPacketConn) Close() error {
+ c.closeOnce.Do(func() {
+ close(c.closed)
+ c.cancel()
+ })
+ return nil
+}
+func (c *dohPacketConn) LocalAddr() net.Addr { return dnsAddr("megaproxy-doh") }
+func (c *dohPacketConn) SetDeadline(t time.Time) error {
+ c.deadlineMu.Lock()
+ c.readDeadline = t
+ c.deadlineMu.Unlock()
+ return nil
+}
+func (c *dohPacketConn) SetReadDeadline(t time.Time) error { return c.SetDeadline(t) }
+func (c *dohPacketConn) SetWriteDeadline(time.Time) error { return nil }
+
+type dnsAddr string
+
+func (a dnsAddr) Network() string { return "udp" }
+func (a dnsAddr) String() string { return string(a) }
+
+type timeoutError struct{}
+
+func (timeoutError) Error() string { return "DNS read deadline exceeded" }
+func (timeoutError) Timeout() bool { return true }
+func (timeoutError) Temporary() bool { return true }
diff --git a/native/mobile/doh_test.go b/native/mobile/doh_test.go
new file mode 100644
index 0000000..05ac58f
--- /dev/null
+++ b/native/mobile/doh_test.go
@@ -0,0 +1,74 @@
+package mobile
+
+import (
+ "context"
+ "encoding/binary"
+ "net"
+ "testing"
+)
+
+func TestEmptyAAAAResponseSuppressesIPv6Answer(t *testing.T) {
+ query, id, err := buildAQuery("example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ binary.BigEndian.PutUint16(query[len(query)-4:len(query)-2], 28)
+
+ response, isAAAA, err := emptyAAAAResponse(query)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !isAAAA {
+ t.Fatal("expected an AAAA query")
+ }
+ if binary.BigEndian.Uint16(response[0:2]) != id || binary.BigEndian.Uint16(response[6:8]) != 0 {
+ t.Fatalf("unexpected empty response: %x", response)
+ }
+ if binary.BigEndian.Uint16(response[2:4])&0x8000 == 0 {
+ t.Fatal("expected the DNS response bit")
+ }
+}
+
+func TestEmptyAAAAResponseLeavesAQueryUntouched(t *testing.T) {
+ query, _, err := buildAQuery("example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ response, isAAAA, err := emptyAAAAResponse(query)
+ if err != nil || isAAAA || response != nil {
+ t.Fatalf("unexpected A-query result: response=%x isAAAA=%v err=%v", response, isAAAA, err)
+ }
+}
+
+func TestDoHEndpointOrderStartsWithLastSuccessfulProvider(t *testing.T) {
+ c := newDoHPacketConnWithClient(
+ config{DoHFallbackURLs: []string{"https://two.example/dns-query", "https://three.example/dns-query"}},
+ nil,
+ func(context.Context, string) (net.Conn, error) { return nil, nil },
+ "https://one.example/dns-query",
+ nil,
+ nil,
+ )
+ defer c.Close()
+ c.setActiveEndpoint(1)
+
+ order := c.endpointOrder()
+ if len(order) != 3 || order[0].index != 1 || order[1].index != 2 || order[2].index != 0 {
+ t.Fatalf("unexpected endpoint order: %#v", order)
+ }
+}
+
+func TestDoHEndpointsIgnoreDuplicatePrimary(t *testing.T) {
+ c := newDoHPacketConnWithClient(
+ config{DoHFallbackURLs: []string{"https://one.example/dns-query", "https://two.example/dns-query"}},
+ nil,
+ func(context.Context, string) (net.Conn, error) { return nil, nil },
+ "https://one.example/dns-query",
+ nil,
+ nil,
+ )
+ defer c.Close()
+ if len(c.endpoints) != 2 {
+ t.Fatalf("expected two unique endpoints, got %#v", c.endpoints)
+ }
+}
diff --git a/native/mobile/http2_connect.go b/native/mobile/http2_connect.go
new file mode 100644
index 0000000..2cff3c5
--- /dev/null
+++ b/native/mobile/http2_connect.go
@@ -0,0 +1,224 @@
+package mobile
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "sync"
+ "time"
+
+ "golang.org/x/net/http2"
+)
+
+type http2ClientConn interface {
+ RoundTrip(*http.Request) (*http.Response, error)
+ CanTakeNewRequest() bool
+ Close() error
+}
+
+type http2ConnectSession struct {
+ client http2ClientConn
+ raw net.Conn
+}
+
+func newHTTP2ConnectSession(raw net.Conn) (*http2ConnectSession, error) {
+ transport := &http2.Transport{
+ ReadIdleTimeout: 45 * time.Second,
+ PingTimeout: 10 * time.Second,
+ }
+ client, err := transport.NewClientConn(raw)
+ if err != nil {
+ return nil, err
+ }
+ return &http2ConnectSession{client: client, raw: raw}, nil
+}
+
+func (s *http2ConnectSession) canTakeRequest() bool {
+ return s != nil && s.client != nil && s.client.CanTakeNewRequest()
+}
+
+func (s *http2ConnectSession) close() error {
+ if s == nil {
+ return nil
+ }
+ if s.client != nil {
+ _ = s.client.Close()
+ }
+ if s.raw != nil {
+ return s.raw.Close()
+ }
+ return nil
+}
+
+func (s *http2ConnectSession) openTunnel(ctx context.Context, target, authorization string) (net.Conn, int, error) {
+ reader, writer := io.Pipe()
+ streamContext, cancelStream := context.WithCancel(context.Background())
+ request := &http.Request{
+ Method: http.MethodConnect,
+ URL: &url.URL{Scheme: "https", Host: target},
+ Host: target,
+ Header: make(http.Header),
+ Body: reader,
+ ContentLength: -1,
+ }
+ request = request.WithContext(streamContext)
+ request.Header.Set("Proxy-Authorization", authorization)
+
+ type result struct {
+ response *http.Response
+ err error
+ }
+ resultChannel := make(chan result, 1)
+ go func() {
+ response, err := s.client.RoundTrip(request)
+ select {
+ case resultChannel <- result{response: response, err: err}:
+ case <-streamContext.Done():
+ if response != nil {
+ _ = response.Body.Close()
+ }
+ }
+ }()
+
+ var outcome result
+ select {
+ case outcome = <-resultChannel:
+ case <-ctx.Done():
+ cancelStream()
+ _ = writer.CloseWithError(ctx.Err())
+ _ = reader.CloseWithError(ctx.Err())
+ return nil, 0, ctx.Err()
+ }
+ if outcome.err != nil {
+ cancelStream()
+ _ = writer.CloseWithError(outcome.err)
+ _ = reader.CloseWithError(outcome.err)
+ return nil, 0, outcome.err
+ }
+ if outcome.response.StatusCode != http.StatusOK {
+ _ = outcome.response.Body.Close()
+ cancelStream()
+ _ = writer.Close()
+ _ = reader.Close()
+ return nil, outcome.response.StatusCode, errors.New("HTTP/2 proxy rejected CONNECT")
+ }
+ return newHTTP2StreamConn(s.raw, outcome.response.Body, writer, cancelStream), outcome.response.StatusCode, nil
+}
+
+// http2StreamConn exposes one HTTP/2 CONNECT stream as a net.Conn. A deadline
+// closes only this stream, never the shared outer TLS connection.
+type http2StreamConn struct {
+ raw net.Conn
+ reader io.ReadCloser
+ writer *io.PipeWriter
+ cancel context.CancelFunc
+ closeOnce sync.Once
+ deadlineMu sync.Mutex
+ readTimer *time.Timer
+ writeTimer *time.Timer
+ readExpired bool
+ writeExpired bool
+}
+
+func newHTTP2StreamConn(raw net.Conn, reader io.ReadCloser, writer *io.PipeWriter, cancel context.CancelFunc) *http2StreamConn {
+ return &http2StreamConn{raw: raw, reader: reader, writer: writer, cancel: cancel}
+}
+
+func (c *http2StreamConn) Read(p []byte) (int, error) {
+ n, err := c.reader.Read(p)
+ c.deadlineMu.Lock()
+ expired := c.readExpired
+ c.deadlineMu.Unlock()
+ if expired && err != nil {
+ return n, os.ErrDeadlineExceeded
+ }
+ return n, err
+}
+
+func (c *http2StreamConn) Write(p []byte) (int, error) {
+ n, err := c.writer.Write(p)
+ c.deadlineMu.Lock()
+ expired := c.writeExpired
+ c.deadlineMu.Unlock()
+ if expired && err != nil {
+ return n, os.ErrDeadlineExceeded
+ }
+ return n, err
+}
+
+func (c *http2StreamConn) Close() error {
+ var closeErr error
+ c.closeOnce.Do(func() {
+ c.deadlineMu.Lock()
+ if c.readTimer != nil {
+ c.readTimer.Stop()
+ }
+ if c.writeTimer != nil {
+ c.writeTimer.Stop()
+ }
+ c.deadlineMu.Unlock()
+ c.cancel()
+ _ = c.writer.Close()
+ closeErr = c.reader.Close()
+ })
+ return closeErr
+}
+
+func (c *http2StreamConn) LocalAddr() net.Addr { return c.raw.LocalAddr() }
+func (c *http2StreamConn) RemoteAddr() net.Addr { return c.raw.RemoteAddr() }
+
+func (c *http2StreamConn) SetDeadline(deadline time.Time) error {
+ c.setReadDeadline(deadline)
+ c.setWriteDeadline(deadline)
+ return nil
+}
+
+func (c *http2StreamConn) SetReadDeadline(deadline time.Time) error {
+ c.setReadDeadline(deadline)
+ return nil
+}
+
+func (c *http2StreamConn) SetWriteDeadline(deadline time.Time) error {
+ c.setWriteDeadline(deadline)
+ return nil
+}
+
+func (c *http2StreamConn) setReadDeadline(deadline time.Time) {
+ c.deadlineMu.Lock()
+ defer c.deadlineMu.Unlock()
+ c.readExpired = false
+ if c.readTimer != nil {
+ c.readTimer.Stop()
+ c.readTimer = nil
+ }
+ if !deadline.IsZero() {
+ c.readTimer = time.AfterFunc(time.Until(deadline), func() {
+ c.deadlineMu.Lock()
+ c.readExpired = true
+ c.deadlineMu.Unlock()
+ _ = c.Close()
+ })
+ }
+}
+
+func (c *http2StreamConn) setWriteDeadline(deadline time.Time) {
+ c.deadlineMu.Lock()
+ defer c.deadlineMu.Unlock()
+ c.writeExpired = false
+ if c.writeTimer != nil {
+ c.writeTimer.Stop()
+ c.writeTimer = nil
+ }
+ if !deadline.IsZero() {
+ c.writeTimer = time.AfterFunc(time.Until(deadline), func() {
+ c.deadlineMu.Lock()
+ c.writeExpired = true
+ c.deadlineMu.Unlock()
+ _ = c.Close()
+ })
+ }
+}
diff --git a/native/mobile/http2_connect_test.go b/native/mobile/http2_connect_test.go
new file mode 100644
index 0000000..fde231c
--- /dev/null
+++ b/native/mobile/http2_connect_test.go
@@ -0,0 +1,125 @@
+package mobile
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestHTTP2ConnectSessionMultiplexesStreams(t *testing.T) {
+ var mu sync.Mutex
+ authorities := make([]string, 0, 2)
+ server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodConnect {
+ t.Errorf("method = %q, want CONNECT", r.Method)
+ return
+ }
+ if r.Header.Get("Proxy-Authorization") != "Basic test" {
+ t.Errorf("authorization was not forwarded")
+ }
+ mu.Lock()
+ authorities = append(authorities, r.Host)
+ mu.Unlock()
+ controller := http.NewResponseController(w)
+ if err := controller.EnableFullDuplex(); err != nil {
+ t.Errorf("enable full duplex: %v", err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ _ = controller.Flush()
+ buffer := make([]byte, 1024)
+ for {
+ count, err := r.Body.Read(buffer)
+ if count > 0 {
+ _, _ = w.Write(buffer[:count])
+ _ = controller.Flush()
+ }
+ if err != nil {
+ return
+ }
+ }
+ }))
+ server.EnableHTTP2 = true
+ server.StartTLS()
+ defer server.Close()
+
+ raw, err := tls.Dial("tcp", server.Listener.Addr().String(), &tls.Config{
+ InsecureSkipVerify: true, // Test server certificate.
+ NextProtos: []string{"h2"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ session, err := newHTTP2ConnectSession(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer session.close()
+
+ for index := 0; index < 2; index++ {
+ target := fmt.Sprintf("target-%d.example:443", index)
+ stream, status, err := session.openTunnel(context.Background(), target, "Basic test")
+ if err != nil || status != http.StatusOK {
+ t.Fatalf("open stream %d: status=%d err=%v", index, status, err)
+ }
+ payload := []byte(fmt.Sprintf("stream-%d", index))
+ if _, err := stream.Write(payload); err != nil {
+ t.Fatal(err)
+ }
+ if err := stream.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ response := make([]byte, len(payload))
+ if _, err := io.ReadFull(stream, response); err != nil {
+ t.Fatal(err)
+ }
+ if string(response) != string(payload) {
+ t.Fatalf("response = %q, want %q", response, payload)
+ }
+ _ = stream.Close()
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ if len(authorities) != 2 || authorities[0] != "target-0.example:443" || authorities[1] != "target-1.example:443" {
+ t.Fatalf("unexpected CONNECT authorities: %#v", authorities)
+ }
+}
+
+func TestHTTP2StreamDeadlineDoesNotCloseSharedConnection(t *testing.T) {
+ left, right := net.Pipe()
+ defer left.Close()
+ defer right.Close()
+ reader, writer := io.Pipe()
+ stream := newHTTP2StreamConn(left, io.NopCloser(reader), writer, func() {})
+ if err := stream.SetReadDeadline(time.Now().Add(10 * time.Millisecond)); err != nil {
+ t.Fatal(err)
+ }
+ buffer := make([]byte, 1)
+ if _, err := stream.Read(buffer); err == nil || !isTimeout(err) {
+ t.Fatalf("Read error = %v, want timeout", err)
+ }
+ rawRead := make(chan error, 1)
+ go func() {
+ _, err := left.Read(buffer)
+ rawRead <- err
+ }()
+ if _, err := right.Write([]byte{1}); err != nil {
+ t.Fatalf("shared raw connection was closed by stream deadline: %v", err)
+ }
+ if err := <-rawRead; err != nil {
+ t.Fatalf("shared raw connection read failed: %v", err)
+ }
+}
+
+func isTimeout(err error) bool {
+ value, ok := err.(interface{ Timeout() bool })
+ return ok && value.Timeout()
+}
diff --git a/native/mobile/ja3.go b/native/mobile/ja3.go
new file mode 100644
index 0000000..25d7f47
--- /dev/null
+++ b/native/mobile/ja3.go
@@ -0,0 +1,61 @@
+package mobile
+
+import (
+ "fmt"
+
+ tls "github.com/refraction-networking/utls"
+)
+
+// applyJA3 builds the extension payloads needed for a usable handshake. JA3 identifies
+// extension IDs but not their payloads, so unknown IDs are rejected instead of silently
+// producing a fingerprint different from the requested one.
+func applyJA3(conn *tls.UConn, raw, serverName string) error {
+ spec, err := parseJA3(raw)
+ if err != nil {
+ return err
+ }
+ extensions := make([]tls.TLSExtension, 0, len(spec.Extensions))
+ for _, id := range spec.Extensions {
+ var extension tls.TLSExtension
+ switch id {
+ case 0:
+ extension = &tls.SNIExtension{ServerName: serverName}
+ case 5:
+ extension = &tls.StatusRequestExtension{}
+ case 10:
+ curves := make([]tls.CurveID, len(spec.Groups))
+ for i, group := range spec.Groups {
+ curves[i] = tls.CurveID(group)
+ }
+ extension = &tls.SupportedCurvesExtension{Curves: curves}
+ case 11:
+ extension = &tls.SupportedPointsExtension{SupportedPoints: spec.Points}
+ case 13:
+ extension = &tls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []tls.SignatureScheme{tls.ECDSAWithP256AndSHA256, tls.PSSWithSHA256, tls.PKCS1WithSHA256, tls.ECDSAWithP384AndSHA384, tls.PSSWithSHA384}}
+ case 16:
+ extension = &tls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}}
+ case 18:
+ extension = &tls.SCTExtension{}
+ case 23:
+ extension = &tls.UtlsExtendedMasterSecretExtension{}
+ case 43:
+ extension = &tls.SupportedVersionsExtension{Versions: []uint16{tls.VersionTLS13, tls.VersionTLS12}}
+ case 45:
+ extension = &tls.PSKKeyExchangeModesExtension{Modes: []uint8{tls.PskModeDHE}}
+ case 51:
+ extension = &tls.KeyShareExtension{KeyShares: []tls.KeyShare{{Group: tls.X25519}}}
+ case 65281:
+ extension = &tls.RenegotiationInfoExtension{}
+ default:
+ return fmt.Errorf("manual JA3 extension %d needs an explicit payload and is not supported", id)
+ }
+ extensions = append(extensions, extension)
+ }
+ return conn.ApplyPreset(&tls.ClientHelloSpec{
+ TLSVersMin: tls.VersionTLS12,
+ TLSVersMax: spec.Version,
+ CipherSuites: spec.Ciphers,
+ CompressionMethods: []uint8{0},
+ Extensions: extensions,
+ })
+}
diff --git a/native/mobile/mobile.go b/native/mobile/mobile.go
new file mode 100644
index 0000000..53b33c7
--- /dev/null
+++ b/native/mobile/mobile.go
@@ -0,0 +1,129 @@
+// Package mobile exposes the gomobile-compatible Android API.
+package mobile
+
+import (
+ "context"
+ "errors"
+ "io"
+ "strconv"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/xjasonlyu/tun2socks/v2/core"
+ "github.com/xjasonlyu/tun2socks/v2/core/device"
+ "github.com/xjasonlyu/tun2socks/v2/core/device/fdbased"
+ "github.com/xjasonlyu/tun2socks/v2/tunnel"
+ "gvisor.dev/gvisor/pkg/tcpip/stack"
+)
+
+var state struct {
+ sync.Mutex
+ generation uint64
+ starting bool
+ running bool
+ device device.Device
+ stack *stack.Stack
+ proxyCloser io.Closer
+}
+
+// Start takes ownership of a duplicate of tunFD held by Android's ParcelFileDescriptor.
+func Start(tunFD int, rawConfig string, protector Protector, reporter Reporter) error {
+ c, err := parseConfig(rawConfig)
+ if err != nil {
+ if tunFD >= 0 {
+ _ = syscall.Close(tunFD)
+ }
+ return err
+ }
+ if tunFD < 0 || protector == nil {
+ if tunFD >= 0 {
+ _ = syscall.Close(tunFD)
+ }
+ return errors.New("invalid Android VPN bridge")
+ }
+ state.Lock()
+ if state.running || state.starting {
+ state.Unlock()
+ _ = syscall.Close(tunFD)
+ return errors.New("proxy core is already running")
+ }
+ state.generation++
+ generation := state.generation
+ state.starting = true
+ state.Unlock()
+ committed := false
+ defer func() {
+ if committed {
+ return
+ }
+ state.Lock()
+ state.starting = false
+ state.Unlock()
+ }()
+ resetStats()
+ dev, err := fdbased.Open(strconv.Itoa(tunFD), 1500, 0)
+ if err != nil {
+ _ = syscall.Close(tunFD)
+ return err
+ }
+ t := tunnel.T()
+ var proxyCloser io.Closer
+ if c.Type == "HTTPS" {
+ httpsProxy := &httpsConnectDialer{config: c, protector: protector, reporter: reporter}
+ t.SetProxy(httpsProxy)
+ proxyCloser = httpsProxy
+ } else {
+ sshProxy := &sshDialer{config: c, protector: protector, reporter: reporter}
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ _, sessionErr := sshProxy.session(ctx)
+ cancel()
+ if sessionErr != nil {
+ dev.Close()
+ report(reporter, "event=ssh_session result=failed detail=%s", sessionErr)
+ return sessionErr
+ }
+ t.SetProxy(sshProxy)
+ proxyCloser = sshProxy
+ }
+ netstack, err := core.CreateStack(&core.Config{LinkEndpoint: dev, TransportHandler: t})
+ if err != nil {
+ dev.Close()
+ return err
+ }
+ state.Lock()
+ if state.generation != generation || !state.starting {
+ state.Unlock()
+ netstack.Close()
+ netstack.Wait()
+ if proxyCloser != nil {
+ _ = proxyCloser.Close()
+ }
+ return errors.New("proxy core start was superseded")
+ }
+ state.device, state.stack, state.proxyCloser = dev, netstack, proxyCloser
+ state.starting, state.running = false, true
+ committed = true
+ state.Unlock()
+ report(reporter, "event=native_stack result=started type=%s fingerprint=%s ssh_profile=%s ipv6=%t bypass_local=%t", c.Type, c.Profile, c.SSHProfile, c.AllowIPv6, c.BypassLocalNetworks)
+ return nil
+}
+
+func Stop() {
+ state.Lock()
+ state.generation++
+ state.running = false
+ dev, netstack, proxyCloser := state.device, state.stack, state.proxyCloser
+ state.device, state.stack, state.proxyCloser = nil, nil, nil
+ state.Unlock()
+ if dev != nil {
+ dev.Close()
+ }
+ if netstack != nil {
+ netstack.Close()
+ netstack.Wait()
+ }
+ if proxyCloser != nil {
+ _ = proxyCloser.Close()
+ }
+}
diff --git a/native/mobile/ssh_dialer.go b/native/mobile/ssh_dialer.go
new file mode 100644
index 0000000..c37451b
--- /dev/null
+++ b/native/mobile/ssh_dialer.go
@@ -0,0 +1,462 @@
+package mobile
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "time"
+
+ M "github.com/xjasonlyu/tun2socks/v2/metadata"
+ "golang.org/x/crypto/ssh"
+)
+
+// sshDialer implements OpenSSH-style dynamic forwarding. SOCKS CONNECT semantics
+// are mapped directly to SSH direct-tcpip channels; SSH has no general UDP relay.
+type sshDialer struct {
+ config config
+ protector Protector
+ reporter Reporter
+ mu sync.Mutex
+ client *ssh.Client
+ jumpClient *ssh.Client
+ channels chan struct{}
+ sessionCreated time.Time
+ sessionBytes atomic.Uint64
+ keepaliveStop chan struct{}
+ dohClient *http.Client
+ dohInFlight chan struct{}
+}
+
+func (d *sshDialer) DialContext(ctx context.Context, metadata *M.Metadata) (net.Conn, error) {
+ return d.connectTarget(ctx, metadata.DestinationAddress())
+}
+
+func (d *sshDialer) connectTarget(ctx context.Context, target string) (net.Conn, error) {
+ if !d.config.AllowIPv6 {
+ host, _, _ := net.SplitHostPort(target)
+ if ip := net.ParseIP(host); ip != nil && ip.To4() == nil {
+ return nil, errors.New("IPv6 destination blocked by IPv4-only mode")
+ }
+ }
+ if d.config.BypassLocalNetworks && isLocalNetworkTarget(target) {
+ return d.protectedDialer().DialContext(ctx, "tcp", target)
+ }
+ if d.rotationDue() {
+ d.invalidate()
+ }
+ d.mu.Lock()
+ if d.channels == nil {
+ d.channels = make(chan struct{}, d.config.SSHMaxChannels)
+ }
+ channels := d.channels
+ d.mu.Unlock()
+ select {
+ case channels <- struct{}{}:
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ release := true
+ defer func() {
+ if release {
+ <-channels
+ }
+ }()
+ client, err := d.session(ctx)
+ if err != nil {
+ recordConnectionOutcome(false)
+ report(d.reporter, "event=ssh_session result=failed detail=%s", err)
+ return nil, err
+ }
+ started := time.Now()
+ dialContext, cancelDial := context.WithTimeout(ctx, 20*time.Second)
+ conn, err := client.DialContext(dialContext, "tcp", target)
+ cancelDial()
+ if err != nil {
+ d.invalidate()
+ report(d.reporter, "event=connection mode=ssh stage=direct_tcpip result=failed reason=%s", errorClass(err))
+ recordConnectionOutcome(false)
+ return nil, fmt.Errorf("SSH direct-tcpip: %w", err)
+ }
+ recordConnectionOutcome(true)
+ report(d.reporter, "event=connection mode=ssh stage=direct_tcpip result=success elapsed_ms=%d", time.Since(started).Milliseconds())
+ release = false
+ tracked := &sshTrackedConn{Conn: conn, release: func() { <-channels }, bytes: &d.sessionBytes}
+ return &diagnosticConn{Conn: tracked, connectionID: nextDiagnosticConnectionID(), reporter: d.reporter}, nil
+}
+
+func (d *sshDialer) session(ctx context.Context) (*ssh.Client, error) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if d.client != nil {
+ return d.client, nil
+ }
+ if d.config.Type == "SSH_JUMP" {
+ started := time.Now()
+ raw, err := d.protectedDialer().DialContext(ctx, "tcp", d.config.jumpAddress())
+ if err != nil {
+ report(d.reporter, "event=ssh_transport hop=jump stage=tcp_connect result=failed reason=%s elapsed_ms=%d", errorClass(err), time.Since(started).Milliseconds())
+ return nil, fmt.Errorf("dial jump host: %w", err)
+ }
+ report(d.reporter, "event=ssh_transport hop=jump stage=tcp_connect result=success elapsed_ms=%d", time.Since(started).Milliseconds())
+ jump, err := newSSHClient(ctx, raw, d.config.JumpHost, d.config.JumpUsername, d.config.JumpPassword, d.config.JumpPrivateKey, d.config.JumpTrustedHostKey, d.config.JumpAcceptAnyHostKey, d.config.SSHProfile, d.config.SSHAuthMode, "jump", d.reporter)
+ if err != nil {
+ raw.Close()
+ return nil, err
+ }
+ started = time.Now()
+ nested, err := jump.DialContext(ctx, "tcp", d.config.address())
+ if err != nil {
+ jump.Close()
+ report(d.reporter, "event=ssh_transport hop=destination stage=direct_tcpip result=failed reason=%s elapsed_ms=%d", errorClass(err), time.Since(started).Milliseconds())
+ return nil, fmt.Errorf("jump host could not reach destination SSH host: %w", err)
+ }
+ report(d.reporter, "event=ssh_transport hop=destination stage=direct_tcpip result=success elapsed_ms=%d", time.Since(started).Milliseconds())
+ client, err := newSSHClient(ctx, nested, d.config.Host, d.config.Username, d.config.Password, d.config.PrivateKey, d.config.TrustedHostKey, d.config.AcceptAnyHostKey, d.config.SSHProfile, d.config.SSHAuthMode, "destination", d.reporter)
+ if err != nil {
+ nested.Close()
+ jump.Close()
+ return nil, err
+ }
+ d.jumpClient, d.client = jump, client
+ } else {
+ started := time.Now()
+ raw, err := d.protectedDialer().DialContext(ctx, "tcp", d.config.address())
+ if err != nil {
+ report(d.reporter, "event=ssh_transport hop=destination stage=tcp_connect result=failed reason=%s elapsed_ms=%d", errorClass(err), time.Since(started).Milliseconds())
+ return nil, fmt.Errorf("dial SSH host: %w", err)
+ }
+ report(d.reporter, "event=ssh_transport hop=destination stage=tcp_connect result=success elapsed_ms=%d", time.Since(started).Milliseconds())
+ client, err := newSSHClient(ctx, raw, d.config.Host, d.config.Username, d.config.Password, d.config.PrivateKey, d.config.TrustedHostKey, d.config.AcceptAnyHostKey, d.config.SSHProfile, d.config.SSHAuthMode, "destination", d.reporter)
+ if err != nil {
+ raw.Close()
+ return nil, err
+ }
+ d.client = client
+ }
+ report(d.reporter, "event=ssh_session result=established type=%s profile=%s", d.config.Type, d.config.SSHProfile)
+ d.sessionCreated = time.Now()
+ d.sessionBytes.Store(0)
+ d.startKeepalive()
+ return d.client, nil
+}
+
+func newSSHClient(ctx context.Context, conn net.Conn, hostname, username, password, privateKey, trusted string, acceptAny bool, profile, authMode, hop string, reporter Reporter) (*ssh.Client, error) {
+ auth, err := sshAuthMethods(privateKey, password, authMode)
+ if err != nil {
+ return nil, err
+ }
+ cfg := &ssh.ClientConfig{User: username, Auth: auth, HostKeyCallback: hostKeyCallback(trusted, acceptAny, hop), Timeout: 20 * time.Second}
+ applySSHProfile(cfg, profile)
+ type handshakeResult struct {
+ conn ssh.Conn
+ channels <-chan ssh.NewChannel
+ requests <-chan *ssh.Request
+ err error
+ }
+ result := make(chan handshakeResult, 1)
+ observed := &sshResponseObserver{Conn: conn, reporter: reporter, hop: hop, started: time.Now()}
+ go func() {
+ cc, channels, requests, err := ssh.NewClientConn(observed, net.JoinHostPort(hostname, "22"), cfg)
+ result <- handshakeResult{conn: cc, channels: channels, requests: requests, err: err}
+ }()
+ timer := time.NewTimer(20 * time.Second)
+ defer timer.Stop()
+ var handshake handshakeResult
+ select {
+ case handshake = <-result:
+ case <-ctx.Done():
+ conn.Close()
+ <-result
+ return nil, fmt.Errorf("SSH %s handshake: %w", hop, ctx.Err())
+ case <-timer.C:
+ conn.Close()
+ <-result
+ report(reporter, "event=ssh_transport hop=%s stage=handshake_timeout result=failed read_bytes=%d written_bytes=%d", hop, observed.readBytes.Load(), observed.writtenBytes.Load())
+ if observed.received.Load() {
+ return nil, fmt.Errorf("SSH %s handshake: timeout after server response", hop)
+ }
+ return nil, fmt.Errorf("SSH %s handshake: timeout before server response", hop)
+ }
+ cc, channels, requests, err := handshake.conn, handshake.channels, handshake.requests, handshake.err
+ if err != nil {
+ return nil, fmt.Errorf("SSH %s handshake: %w", hop, err)
+ }
+ report(reporter, "event=ssh_handshake hop=%s result=success profile=%s", hop, profile)
+ report(reporter, "event=transport_capability transport=ssh hop=%s client_family=%s server_family=%s direct_tcpip=true multiplexed=true rekey_mb=%d", hop, sshClientFamily(profile), sshServerFamily(string(cc.ServerVersion())), sshRekeyBytes/(1024*1024))
+ return ssh.NewClient(cc, channels, requests), nil
+}
+
+type sshResponseObserver struct {
+ net.Conn
+ reporter Reporter
+ hop string
+ started time.Time
+ received atomic.Bool
+ readBytes atomic.Uint64
+ writtenBytes atomic.Uint64
+ serverBanner atomic.Bool
+ serverBinary atomic.Bool
+ clientBanner atomic.Bool
+ clientBinary atomic.Bool
+}
+
+func (c *sshResponseObserver) Read(p []byte) (int, error) {
+ n, err := c.Conn.Read(p)
+ if n > 0 {
+ c.readBytes.Add(uint64(n))
+ if c.received.CompareAndSwap(false, true) {
+ report(c.reporter, "event=ssh_transport hop=%s stage=server_response result=received elapsed_ms=%d", c.hop, time.Since(c.started).Milliseconds())
+ }
+ payload := p[:n]
+ if !c.serverBanner.Load() {
+ if newline := bytes.IndexByte(payload, '\n'); newline >= 0 && c.serverBanner.CompareAndSwap(false, true) {
+ report(c.reporter, "event=ssh_transport hop=%s stage=server_banner result=received elapsed_ms=%d", c.hop, time.Since(c.started).Milliseconds())
+ if newline+1 < len(payload) && c.serverBinary.CompareAndSwap(false, true) {
+ report(c.reporter, "event=ssh_transport hop=%s stage=server_binary_packet result=received elapsed_ms=%d", c.hop, time.Since(c.started).Milliseconds())
+ }
+ }
+ } else if c.serverBinary.CompareAndSwap(false, true) {
+ report(c.reporter, "event=ssh_transport hop=%s stage=server_binary_packet result=received elapsed_ms=%d", c.hop, time.Since(c.started).Milliseconds())
+ }
+ }
+ return n, err
+}
+
+func (c *sshResponseObserver) Write(p []byte) (int, error) {
+ n, err := c.Conn.Write(p)
+ if n > 0 {
+ c.writtenBytes.Add(uint64(n))
+ payload := p[:n]
+ if !c.clientBanner.Load() && bytes.HasPrefix(payload, []byte("SSH-")) && c.clientBanner.CompareAndSwap(false, true) {
+ report(c.reporter, "event=ssh_transport hop=%s stage=client_banner result=sent elapsed_ms=%d", c.hop, time.Since(c.started).Milliseconds())
+ if newline := bytes.IndexByte(payload, '\n'); newline >= 0 && newline+1 < len(payload) && c.clientBinary.CompareAndSwap(false, true) {
+ report(c.reporter, "event=ssh_transport hop=%s stage=client_binary_packet result=sent elapsed_ms=%d", c.hop, time.Since(c.started).Milliseconds())
+ }
+ } else if c.clientBanner.Load() && c.clientBinary.CompareAndSwap(false, true) {
+ report(c.reporter, "event=ssh_transport hop=%s stage=client_binary_packet result=sent elapsed_ms=%d", c.hop, time.Since(c.started).Milliseconds())
+ }
+ }
+ return n, err
+}
+
+func sshAuthMethods(privateKey, password, authMode string) ([]ssh.AuthMethod, error) {
+ methods := make([]ssh.AuthMethod, 0, 2)
+ if authMode != "PASSWORD_ONLY" && strings.TrimSpace(privateKey) != "" {
+ signer, err := ssh.ParsePrivateKey([]byte(privateKey))
+ if err != nil {
+ var passphraseErr *ssh.PassphraseMissingError
+ if errors.As(err, &passphraseErr) {
+ return nil, errors.New("passphrase-protected SSH private keys are not supported")
+ }
+ return nil, fmt.Errorf("invalid SSH private key: %w", err)
+ }
+ methods = append(methods, ssh.PublicKeys(signer))
+ }
+ if authMode != "KEY_ONLY" && password != "" {
+ methods = append(methods, ssh.Password(password))
+ methods = append(methods, ssh.KeyboardInteractive(func(_ string, _ string, questions []string, _ []bool) ([]string, error) {
+ answers := make([]string, len(questions))
+ for i := range answers {
+ answers[i] = password
+ }
+ return answers, nil
+ }))
+ }
+ return methods, nil
+}
+
+func hostKeyCallback(trusted string, acceptAny bool, hop string) ssh.HostKeyCallback {
+ return func(_ string, _ net.Addr, key ssh.PublicKey) error {
+ fingerprint := ssh.FingerprintSHA256(key)
+ if acceptAny || trusted == fingerprint {
+ return nil
+ }
+ if trusted == "" {
+ return fmt.Errorf("SSH_HOST_KEY_UNKNOWN|%s|%s|%s", hop, key.Type(), fingerprint)
+ }
+ return fmt.Errorf("SSH_HOST_KEY_CHANGED|%s|%s|%s|%s", hop, key.Type(), trusted, fingerprint)
+ }
+}
+
+func applySSHProfile(c *ssh.ClientConfig, profile string) {
+ switch profile {
+ case "CONNECTBOT":
+ c.ClientVersion = "SSH-2.0-ConnectBot"
+ case "JUICESSH":
+ c.ClientVersion = "SSH-2.0-JuiceSSH"
+ case "TERMIUS_ANDROID":
+ c.ClientVersion = "SSH-2.0-Termius"
+ default:
+ // OpenSSH 8.4 is a better claim than a newer banner whose mandatory/default
+ // post-quantum proposal cannot be reproduced byte-for-byte by this stack.
+ c.ClientVersion = "SSH-2.0-OpenSSH_8.4"
+ }
+ // This is the common modern OpenSSH-style order supported by x/crypto. It
+ // avoids the distinctive Go defaults and weak legacy algorithms while keeping
+ // compatibility with ordinary servers and dynamic forwarding (`ssh -D`).
+ c.Config.KeyExchanges = []string{
+ ssh.KeyExchangeCurve25519,
+ ssh.KeyExchangeECDHP256,
+ ssh.KeyExchangeECDHP384,
+ ssh.KeyExchangeECDHP521,
+ ssh.KeyExchangeDHGEXSHA256,
+ ssh.KeyExchangeDH16SHA512,
+ ssh.KeyExchangeDH14SHA256,
+ }
+ c.Config.Ciphers = []string{"chacha20-poly1305@openssh.com", "aes128-gcm@openssh.com", "aes256-gcm@openssh.com", "aes128-ctr", "aes192-ctr", "aes256-ctr"}
+ c.Config.MACs = []string{ssh.HMACSHA256ETM, ssh.HMACSHA512ETM, ssh.HMACSHA256, ssh.HMACSHA512}
+ c.Config.RekeyThreshold = sshRekeyBytes
+}
+
+const sshRekeyBytes = 1 << 30
+
+func sshClientFamily(profile string) string {
+ switch profile {
+ case "CONNECTBOT":
+ return "connectbot"
+ case "JUICESSH":
+ return "juicessh"
+ case "TERMIUS_ANDROID":
+ return "termius"
+ default:
+ return "openssh"
+ }
+}
+
+func sshServerFamily(version string) string {
+ value := strings.ToLower(version)
+ switch {
+ case strings.Contains(value, "openssh"):
+ return "openssh"
+ case strings.Contains(value, "dropbear"):
+ return "dropbear"
+ default:
+ return "other"
+ }
+}
+
+func (d *sshDialer) protectedDialer() *net.Dialer {
+ return &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second, Control: func(_, _ string, raw syscall.RawConn) error {
+ var protectErr error
+ if err := raw.Control(func(fd uintptr) {
+ if !d.protector.Protect(int(fd)) {
+ protectErr = errors.New("VpnService.protect rejected upstream socket")
+ }
+ }); err != nil {
+ return err
+ }
+ return protectErr
+ }}
+}
+
+func (d *sshDialer) DialUDP(metadata *M.Metadata) (net.PacketConn, error) {
+ if metadata.DstPort != 53 {
+ return nil, errUDPBlocked
+ }
+ client, limiter := d.sharedDoHResources()
+ return newDoHPacketConnWithClient(d.config, d.reporter, d.connectTarget, d.config.DoHURL, client, limiter), nil
+}
+
+func (d *sshDialer) sharedDoHResources() (*http.Client, chan struct{}) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if d.dohClient == nil {
+ d.dohClient = newDoHHTTPClient(d.connectTarget)
+ }
+ if d.dohInFlight == nil {
+ d.dohInFlight = make(chan struct{}, 8)
+ }
+ return d.dohClient, d.dohInFlight
+}
+
+func (d *sshDialer) invalidate() {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if d.client != nil {
+ d.client.Close()
+ }
+ if d.jumpClient != nil {
+ d.jumpClient.Close()
+ }
+ d.client, d.jumpClient = nil, nil
+ if d.keepaliveStop != nil {
+ close(d.keepaliveStop)
+ d.keepaliveStop = nil
+ }
+}
+
+func (d *sshDialer) Close() error {
+ d.invalidate()
+ d.mu.Lock()
+ if d.dohClient != nil {
+ d.dohClient.CloseIdleConnections()
+ d.dohClient = nil
+ }
+ d.mu.Unlock()
+ return nil
+}
+
+func (d *sshDialer) rotationDue() bool {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if d.client == nil {
+ return false
+ }
+ if d.channels != nil && len(d.channels) > 0 {
+ return false
+ }
+ if d.config.SSHRotationMinutes > 0 && time.Since(d.sessionCreated) >= time.Duration(d.config.SSHRotationMinutes)*time.Minute {
+ return true
+ }
+ return d.config.SSHRotationMB > 0 && d.sessionBytes.Load() >= uint64(d.config.SSHRotationMB)*1024*1024
+}
+
+func (d *sshDialer) startKeepalive() {
+ if d.config.SSHKeepaliveSeconds <= 0 || d.client == nil {
+ return
+ }
+ stop := make(chan struct{})
+ d.keepaliveStop = stop
+ client := d.client
+ interval := time.Duration(d.config.SSHKeepaliveSeconds) * time.Second
+ go func() {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err != nil {
+ return
+ }
+ case <-stop:
+ return
+ }
+ }
+ }()
+}
+
+type sshTrackedConn struct {
+ net.Conn
+ release func()
+ bytes *atomic.Uint64
+ once sync.Once
+}
+
+func (c *sshTrackedConn) Read(p []byte) (int, error) {
+ n, err := c.Conn.Read(p)
+ c.bytes.Add(uint64(n))
+ return n, err
+}
+func (c *sshTrackedConn) Write(p []byte) (int, error) {
+ n, err := c.Conn.Write(p)
+ c.bytes.Add(uint64(n))
+ return n, err
+}
+func (c *sshTrackedConn) Close() error { err := c.Conn.Close(); c.once.Do(c.release); return err }
diff --git a/native/mobile/ssh_dialer_test.go b/native/mobile/ssh_dialer_test.go
new file mode 100644
index 0000000..9280d54
--- /dev/null
+++ b/native/mobile/ssh_dialer_test.go
@@ -0,0 +1,67 @@
+package mobile
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "strings"
+ "testing"
+
+ "golang.org/x/crypto/ssh"
+)
+
+func TestHostKeyTOFU(t *testing.T) {
+ public, _, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, err := ssh.NewPublicKey(public)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fingerprint := ssh.FingerprintSHA256(key)
+
+ err = hostKeyCallback("", false, "jump")("host", nil, key)
+ if err == nil || !strings.Contains(err.Error(), "SSH_HOST_KEY_UNKNOWN|jump|") || !strings.Contains(err.Error(), fingerprint) {
+ t.Fatalf("unexpected TOFU error: %v", err)
+ }
+ if err := hostKeyCallback(fingerprint, false, "jump")("host", nil, key); err != nil {
+ t.Fatal(err)
+ }
+ if err := hostKeyCallback("different", true, "jump")("host", nil, key); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestSSHAuthRejectsMalformedPrivateKeyInsteadOfFallingBack(t *testing.T) {
+ _, err := sshAuthMethods("not a private key", "password", "AUTO")
+ if err == nil || !strings.Contains(err.Error(), "invalid SSH private key") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestSSHPasswordIncludesKeyboardInteractiveFallback(t *testing.T) {
+ methods, err := sshAuthMethods("", "password", "AUTO")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(methods) != 2 {
+ t.Fatalf("got %d password auth methods, want password and keyboard-interactive", len(methods))
+ }
+}
+
+func TestSSHAuthenticationModes(t *testing.T) {
+ methods, err := sshAuthMethods("", "password", "KEY_ONLY")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(methods) != 0 {
+ t.Fatalf("key-only unexpectedly used password: %d methods", len(methods))
+ }
+ methods, err = sshAuthMethods("", "password", "PASSWORD_ONLY")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(methods) != 2 {
+ t.Fatalf("password-only got %d methods", len(methods))
+ }
+}
diff --git a/native/mobile/ssh_profile_test.go b/native/mobile/ssh_profile_test.go
new file mode 100644
index 0000000..5738c72
--- /dev/null
+++ b/native/mobile/ssh_profile_test.go
@@ -0,0 +1,35 @@
+package mobile
+
+import (
+ "slices"
+ "testing"
+
+ "golang.org/x/crypto/ssh"
+)
+
+func TestOpenSSHProfileUsesOrdinaryDynamicForwardingFingerprint(t *testing.T) {
+ c := &ssh.ClientConfig{}
+ applySSHProfile(c, "OPENSSH_TERMUX")
+ if c.ClientVersion != "SSH-2.0-OpenSSH_8.4" {
+ t.Fatalf("unexpected client banner %q", c.ClientVersion)
+ }
+ if c.RekeyThreshold != sshRekeyBytes {
+ t.Fatalf("unexpected rekey threshold %d", c.RekeyThreshold)
+ }
+ if len(c.KeyExchanges) < 5 || c.KeyExchanges[0] != ssh.KeyExchangeCurve25519 {
+ t.Fatalf("unexpected KEX order %#v", c.KeyExchanges)
+ }
+ if !slices.Contains(c.Ciphers, ssh.CipherAES192CTR) {
+ t.Fatalf("OpenSSH-compatible AES-192 CTR is missing: %#v", c.Ciphers)
+ }
+}
+
+func TestSSHProfilesDoNotExposeImplementationName(t *testing.T) {
+ for _, profile := range []string{"OPENSSH_TERMUX", "CONNECTBOT", "JUICESSH", "TERMIUS_ANDROID"} {
+ c := &ssh.ClientConfig{}
+ applySSHProfile(c, profile)
+ if c.ClientVersion == "" || c.ClientVersion == "SSH-2.0-Go" {
+ t.Fatalf("profile %s exposes an invalid implementation banner %q", profile, c.ClientVersion)
+ }
+ }
+}
diff --git a/native/mobile/stats.go b/native/mobile/stats.go
new file mode 100644
index 0000000..e2f07b8
--- /dev/null
+++ b/native/mobile/stats.go
@@ -0,0 +1,106 @@
+package mobile
+
+import (
+ "encoding/json"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+const connectionSampleWindow = 50
+
+type connectionStats struct {
+ downloadBytes atomic.Uint64
+ uploadBytes atomic.Uint64
+ totalOutcomes atomic.Uint64
+
+ mu sync.Mutex
+ latencyMillis float64
+ latencySamples uint64
+ latencyAt time.Time
+ outcomes [connectionSampleWindow]bool
+ outcomeCount int
+ outcomeNext int
+}
+
+var telemetry connectionStats
+
+type statsSnapshot struct {
+ DownloadBytes uint64 `json:"downloadBytes"`
+ UploadBytes uint64 `json:"uploadBytes"`
+ ProxyLatencyMillis float64 `json:"proxyLatencyMillis"`
+ ProxyLatencyAtMillis int64 `json:"proxyLatencyAtMillis"`
+ ConnectionErrorRate float64 `json:"connectionErrorRate"`
+ ConnectionSamples int `json:"connectionSamples"`
+ TotalOutcomes uint64 `json:"totalOutcomes"`
+}
+
+func resetStats() {
+ telemetry.downloadBytes.Store(0)
+ telemetry.uploadBytes.Store(0)
+ telemetry.totalOutcomes.Store(0)
+ telemetry.mu.Lock()
+ telemetry.latencyMillis = 0
+ telemetry.latencySamples = 0
+ telemetry.latencyAt = time.Time{}
+ telemetry.outcomes = [connectionSampleWindow]bool{}
+ telemetry.outcomeCount = 0
+ telemetry.outcomeNext = 0
+ telemetry.mu.Unlock()
+}
+
+func recordProxyLatency(elapsed time.Duration) {
+ millis := float64(elapsed.Microseconds()) / 1000
+ telemetry.mu.Lock()
+ if telemetry.latencySamples == 0 {
+ telemetry.latencyMillis = millis
+ } else {
+ const alpha = 0.2
+ telemetry.latencyMillis = alpha*millis + (1-alpha)*telemetry.latencyMillis
+ }
+ telemetry.latencySamples++
+ telemetry.latencyAt = time.Now()
+ telemetry.mu.Unlock()
+}
+
+func recordConnectionOutcome(success bool) {
+ telemetry.totalOutcomes.Add(1)
+ telemetry.mu.Lock()
+ telemetry.outcomes[telemetry.outcomeNext] = success
+ telemetry.outcomeNext = (telemetry.outcomeNext + 1) % connectionSampleWindow
+ if telemetry.outcomeCount < connectionSampleWindow {
+ telemetry.outcomeCount++
+ }
+ telemetry.mu.Unlock()
+}
+
+func snapshotStats() statsSnapshot {
+ result := statsSnapshot{
+ DownloadBytes: telemetry.downloadBytes.Load(),
+ UploadBytes: telemetry.uploadBytes.Load(),
+ TotalOutcomes: telemetry.totalOutcomes.Load(),
+ }
+ telemetry.mu.Lock()
+ result.ProxyLatencyMillis = telemetry.latencyMillis
+ if !telemetry.latencyAt.IsZero() {
+ result.ProxyLatencyAtMillis = telemetry.latencyAt.UnixMilli()
+ }
+ result.ConnectionSamples = telemetry.outcomeCount
+ failures := 0
+ for index := 0; index < telemetry.outcomeCount; index++ {
+ if !telemetry.outcomes[index] {
+ failures++
+ }
+ }
+ if telemetry.outcomeCount > 0 {
+ result.ConnectionErrorRate = float64(failures) / float64(telemetry.outcomeCount)
+ }
+ telemetry.mu.Unlock()
+ return result
+}
+
+// GetStats returns a lightweight JSON snapshot for the Android UI.
+func GetStats() string {
+ encoded, _ := json.Marshal(snapshotStats())
+ return string(encoded)
+}
diff --git a/native/mobile/stats_test.go b/native/mobile/stats_test.go
new file mode 100644
index 0000000..8c9b5e5
--- /dev/null
+++ b/native/mobile/stats_test.go
@@ -0,0 +1,41 @@
+package mobile
+
+import (
+ "testing"
+ "time"
+)
+
+func TestConnectionStatsRollingWindow(t *testing.T) {
+ resetStats()
+ for index := 0; index < connectionSampleWindow+10; index++ {
+ recordConnectionOutcome(index%2 == 0)
+ }
+ snapshot := snapshotStats()
+ if snapshot.ConnectionSamples != connectionSampleWindow {
+ t.Fatalf("samples = %d, want %d", snapshot.ConnectionSamples, connectionSampleWindow)
+ }
+ if snapshot.TotalOutcomes != connectionSampleWindow+10 {
+ t.Fatalf("total outcomes = %d, want %d", snapshot.TotalOutcomes, connectionSampleWindow+10)
+ }
+ if snapshot.ConnectionErrorRate != 0.5 {
+ t.Fatalf("error rate = %f, want 0.5", snapshot.ConnectionErrorRate)
+ }
+}
+
+func TestConnectionStatsCountersAndLatency(t *testing.T) {
+ resetStats()
+ telemetry.downloadBytes.Add(2048)
+ telemetry.uploadBytes.Add(1024)
+ recordProxyLatency(80 * time.Millisecond)
+ recordProxyLatency(100 * time.Millisecond)
+ snapshot := snapshotStats()
+ if snapshot.DownloadBytes != 2048 || snapshot.UploadBytes != 1024 {
+ t.Fatalf("unexpected counters: %+v", snapshot)
+ }
+ if snapshot.ProxyLatencyMillis != 84 {
+ t.Fatalf("EWMA latency = %f, want 84", snapshot.ProxyLatencyMillis)
+ }
+ if snapshot.ProxyLatencyAtMillis == 0 {
+ t.Fatal("latency timestamp was not recorded")
+ }
+}
diff --git a/native/mobile/test_connection.go b/native/mobile/test_connection.go
new file mode 100644
index 0000000..f9926ed
--- /dev/null
+++ b/native/mobile/test_connection.go
@@ -0,0 +1,99 @@
+package mobile
+
+import (
+ "bufio"
+ "context"
+ stdtls "crypto/tls"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// TestConnection verifies the configured proxy path without starting a TUN device.
+func TestConnection(rawConfig string, protector Protector, reporter Reporter) (string, error) {
+ c, err := parseConfig(rawConfig)
+ if err != nil {
+ return "", err
+ }
+ var connect func(context.Context, string) (net.Conn, error)
+ var testReporter Reporter
+ if c.Type == "HTTPS" {
+ dialer := &httpsConnectDialer{config: c, protector: protector, reporter: reporter}
+ connect, testReporter = dialer.connectTarget, dialer.reporter
+ } else {
+ dialer := &sshDialer{config: c, protector: protector, reporter: reporter}
+ connect, testReporter = dialer.connectTarget, dialer.reporter
+ defer dialer.invalidate()
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
+ defer cancel()
+
+ report(reporter, "event=connection_test result=started")
+ if _, err := testHTTPSGet(ctx, connect, testReporter, "example.com", "/", false); err != nil {
+ return "", fmt.Errorf("example.com check: %w", err)
+ }
+ report(reporter, "event=connection_test stage=https_check result=success")
+
+ ip, err := testHTTPSGet(ctx, connect, testReporter, "ifconfig.me", "/ip", true)
+ if err != nil {
+ return "", fmt.Errorf("public IP check: %w", err)
+ }
+ ip = strings.TrimSpace(ip)
+ if ip == "" || len(ip) > 128 {
+ return "", fmt.Errorf("public IP check returned an invalid response")
+ }
+ report(reporter, "event=connection_test stage=exit_ip result=success family=%s", ipFamily(ip))
+ return ip, nil
+}
+
+func ipFamily(value string) string {
+ ip := net.ParseIP(strings.TrimSpace(value))
+ if ip == nil {
+ return "unknown"
+ }
+ if ip.To4() != nil {
+ return "ipv4"
+ }
+ return "ipv6"
+}
+
+func testHTTPSGet(ctx context.Context, connect func(context.Context, string) (net.Conn, error), reporter Reporter, host, path string, readBody bool) (string, error) {
+ tunnel, err := connect(ctx, host+":443")
+ if err != nil {
+ return "", err
+ }
+ defer tunnel.Close()
+
+ connection := stdtls.Client(tunnel, &stdtls.Config{ServerName: host, MinVersion: stdtls.VersionTLS12})
+ if err := connection.HandshakeContext(ctx); err != nil {
+ return "", fmt.Errorf("destination TLS handshake: %w", err)
+ }
+ tlsState := connection.ConnectionState()
+ report(reporter, "event=connection_test stage=destination_tls result=success certificate=verified version=0x%04x cipher=0x%04x alpn=%s h2_negotiated=%t session_resumed=%t", tlsState.Version, tlsState.CipherSuite, normalizedALPN(tlsState.NegotiatedProtocol), tlsState.NegotiatedProtocol == "h2", tlsState.DidResume)
+
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+host+path, nil)
+ if err != nil {
+ return "", err
+ }
+ request.Header.Set("User-Agent", "MegaProxy/0.1 connection-test")
+ request.Header.Set("Connection", "close")
+ if err := request.Write(connection); err != nil {
+ return "", fmt.Errorf("write HTTPS request: %w", err)
+ }
+ response, err := http.ReadResponse(bufio.NewReader(connection), request)
+ if err != nil {
+ return "", fmt.Errorf("read HTTPS response: %w", err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode < 200 || response.StatusCode >= 400 {
+ return "", fmt.Errorf("HTTPS server returned %s", response.Status)
+ }
+ if !readBody {
+ return "", nil
+ }
+ body, err := io.ReadAll(io.LimitReader(response.Body, 256))
+ return string(body), err
+}
diff --git a/scripts/android-jdwp.sh b/scripts/android-jdwp.sh
new file mode 100755
index 0000000..e3d3838
--- /dev/null
+++ b/scripts/android-jdwp.sh
@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+package_name="net.megaproxy487"
+component_name="$package_name/.MainActivity"
+debug_port="8700"
+jdwp_pid_file="${TMPDIR:-/tmp}/megaproxy-jdwp-session.pid"
+
+adb -e wait-for-device
+adb -e shell am force-stop "$package_name"
+adb -e shell am set-debug-app -w "$package_name"
+adb -e shell am start -D -n "$component_name" >/dev/null
+
+app_pid=""
+for _ in {1..50}; do
+ app_pid="$(adb -e shell pidof -s "$package_name" 2>/dev/null | tr -d '\r' || true)"
+ if [[ -n "$app_pid" ]]; then
+ break
+ fi
+ sleep 0.1
+done
+
+if [[ -z "$app_pid" ]]; then
+ echo "MegaProxy process did not start" >&2
+ exit 1
+fi
+
+jdwp_snapshot="$(mktemp "${TMPDIR:-/tmp}/megaproxy-jdwp.XXXXXX")"
+jdwp_tracker_pid=""
+cleanup_jdwp_tracker() {
+ if [[ -n "$jdwp_tracker_pid" ]]; then
+ kill "$jdwp_tracker_pid" >/dev/null 2>&1 || true
+ wait "$jdwp_tracker_pid" >/dev/null 2>&1 || true
+ fi
+ rm -f "$jdwp_snapshot"
+}
+trap cleanup_jdwp_tracker EXIT
+
+adb -e jdwp >"$jdwp_snapshot" &
+jdwp_tracker_pid="$!"
+
+jdwp_ready=false
+for _ in {1..100}; do
+ if grep -Fxq "$app_pid" "$jdwp_snapshot"; then
+ jdwp_ready=true
+ break
+ fi
+ sleep 0.1
+done
+
+if [[ "$jdwp_ready" != "true" ]]; then
+ echo "Process $app_pid did not register a JDWP endpoint" >&2
+ echo "Registered JDWP processes:" >&2
+ cat "$jdwp_snapshot" >&2
+ exit 1
+fi
+
+cleanup_jdwp_tracker
+trap - EXIT
+
+adb -e forward --remove "tcp:$debug_port" >/dev/null 2>&1 || true
+adb -e forward "tcp:$debug_port" "jdwp:$app_pid" >/dev/null
+
+forward_entry="$(adb -e forward --list | awk -v port="tcp:$debug_port" '$2 == port { print $0 }')"
+if [[ "$forward_entry" != *"jdwp:$app_pid"* ]]; then
+ echo "ADB did not create the expected JDWP forward" >&2
+ adb -e forward --list >&2
+ exit 1
+fi
+
+if [[ "$(adb -e get-state)" != "device" ]]; then
+ echo "Android emulator disconnected before debugger attach" >&2
+ exit 1
+fi
+
+echo "JDWP ready: net.megaproxy487 pid=$app_pid port=$debug_port"
+echo "Forward: $forward_entry"
+
+if [[ "${MEGAPROXY_JDWP_KEEP_ALIVE:-0}" == "1" ]]; then
+ printf '%s\n' "$$" >"$jdwp_pid_file"
+ cleanup_debug_session() {
+ adb -e forward --remove "tcp:$debug_port" >/dev/null 2>&1 || true
+ adb -e shell am clear-debug-app >/dev/null 2>&1 || true
+ if [[ -f "$jdwp_pid_file" ]] && [[ "$(tr -dc '0-9' <"$jdwp_pid_file")" == "$$" ]]; then
+ rm -f "$jdwp_pid_file"
+ fi
+ }
+ trap cleanup_debug_session EXIT INT TERM
+
+ while adb -e get-state >/dev/null 2>&1; do
+ sleep 1
+ done
+fi
diff --git a/scripts/android-logcat.sh b/scripts/android-logcat.sh
new file mode 100755
index 0000000..372c967
--- /dev/null
+++ b/scripts/android-logcat.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+package_name="net.megaproxy487"
+adb -e wait-for-device
+
+app_pid="$(adb -e shell pidof -s "$package_name" 2>/dev/null | tr -d '\r' || true)"
+if [[ -z "$app_pid" ]]; then
+ echo "MegaProxy is not running; launch it before opening app Logcat" >&2
+ exit 1
+fi
+
+exec adb -e logcat --pid="$app_pid"
diff --git a/scripts/build-release-apks.sh b/scripts/build-release-apks.sh
new file mode 100755
index 0000000..4d84f8f
--- /dev/null
+++ b/scripts/build-release-apks.sh
@@ -0,0 +1,156 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+if [[ -f "$HOME/.zshrc.extra" ]]; then
+ # VS Code and non-interactive shells may not inherit the Android toolchain.
+ source "$HOME/.zshrc.extra"
+fi
+
+: "${JAVA_HOME:=/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home}"
+: "${ANDROID_HOME:=$HOME/Library/Android/sdk}"
+: "${ANDROID_NDK_HOME:=$ANDROID_HOME/ndk/29.0.14206865}"
+: "${MEGAPROXY_KEYSTORE_PATH:=$HOME/AndroidApkKey}"
+: "${MEGAPROXY_KEY_PASSWORD_FILE:=$HOME/.my-tokens/android-key-password}"
+: "${MEGAPROXY_KEY_ALIAS:=key0}"
+: "${MEGAPROXY_RELEASE_DIR:=$project_dir/dist/release}"
+
+export JAVA_HOME ANDROID_HOME ANDROID_NDK_HOME
+export PATH="$JAVA_HOME/bin:$HOME/go/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH"
+
+for command in go gomobile java keytool; do
+ if ! command -v "$command" >/dev/null 2>&1; then
+ echo "Required command is unavailable: $command" >&2
+ exit 1
+ fi
+done
+
+if [[ ! -x "$project_dir/gradlew" ]]; then
+ echo "Gradle wrapper is missing or not executable: $project_dir/gradlew" >&2
+ exit 1
+fi
+if [[ ! -f "$MEGAPROXY_KEYSTORE_PATH" ]]; then
+ echo "Release keystore is missing: $MEGAPROXY_KEYSTORE_PATH" >&2
+ exit 1
+fi
+if [[ ! -f "$MEGAPROXY_KEY_PASSWORD_FILE" ]]; then
+ echo "Keystore password file is missing: $MEGAPROXY_KEY_PASSWORD_FILE" >&2
+ exit 1
+fi
+if [[ ! -d "$ANDROID_NDK_HOME" ]]; then
+ echo "Android NDK is missing: $ANDROID_NDK_HOME" >&2
+ exit 1
+fi
+
+keystore_password="$(<"$MEGAPROXY_KEY_PASSWORD_FILE")"
+if [[ -z "$keystore_password" ]]; then
+ echo "Keystore password file is empty" >&2
+ exit 1
+fi
+: "${MEGAPROXY_KEY_PASSWORD:=$keystore_password}"
+
+keytool -list \
+ -keystore "$MEGAPROXY_KEYSTORE_PATH" \
+ -storepass:file "$MEGAPROXY_KEY_PASSWORD_FILE" \
+ -alias "$MEGAPROXY_KEY_ALIAS" >/dev/null
+
+version_name="$(sed -nE 's/^[[:space:]]*versionName = "([^"]+)"/\1/p' "$project_dir/app/build.gradle.kts" | head -n 1)"
+if [[ -z "$version_name" ]]; then
+ echo "Unable to determine versionName from app/build.gradle.kts" >&2
+ exit 1
+fi
+
+mkdir -p "$MEGAPROXY_RELEASE_DIR" "$project_dir/app/libs"
+
+temporary_dir="$(mktemp -d "${TMPDIR:-/tmp}/megaproxy-release.XXXXXX")"
+original_aar="$project_dir/app/libs/megaproxy.aar"
+had_original_aar=false
+if [[ -f "$original_aar" ]]; then
+ cp "$original_aar" "$temporary_dir/megaproxy.aar"
+ had_original_aar=true
+fi
+restore_workspace() {
+ if [[ "$had_original_aar" == true ]]; then
+ cp "$temporary_dir/megaproxy.aar" "$original_aar"
+ else
+ rm -f "$original_aar"
+ fi
+ rm -rf "$temporary_dir"
+}
+trap restore_workspace EXIT
+
+export MEGAPROXY_KEYSTORE_PATH
+export MEGAPROXY_KEYSTORE_PASSWORD="$keystore_password"
+export MEGAPROXY_KEY_ALIAS
+export MEGAPROXY_KEY_PASSWORD
+
+echo "Running native tests"
+(
+ cd "$project_dir/native"
+ go test ./...
+)
+
+echo "Running Android unit tests"
+(
+ cd "$project_dir"
+ ./gradlew testReleaseUnitTest
+)
+
+# gomobile architecture names and their corresponding Android ABI names.
+targets=(
+ "arm64:arm64-v8a"
+ "arm:armeabi-v7a"
+ "amd64:x86_64"
+ "386:x86"
+)
+
+for target in "${targets[@]}"; do
+ go_arch="${target%%:*}"
+ android_abi="${target##*:}"
+ output_apk="$MEGAPROXY_RELEASE_DIR/mega-proxy-v${version_name}-${android_abi}.apk"
+
+ echo "Building optimized native AAR for $android_abi"
+ (
+ cd "$project_dir/native"
+ gomobile bind \
+ -target="android/$go_arch" \
+ -androidapi 26 \
+ -trimpath \
+ -ldflags="-s -w -buildid=" \
+ -o ../app/libs/megaproxy.aar \
+ ./mobile
+ )
+
+ echo "Building and signing $android_abi APK"
+ (
+ cd "$project_dir"
+ ./gradlew clean assembleRelease
+ )
+ built_apk="$project_dir/app/build/outputs/apk/release/app-release.apk"
+ if [[ ! -f "$built_apk" ]]; then
+ echo "Gradle did not produce the expected APK: $built_apk" >&2
+ exit 1
+ fi
+ cp "$built_apk" "$output_apk"
+
+ apksigner="$ANDROID_HOME/build-tools/35.0.0/apksigner"
+ if [[ ! -x "$apksigner" ]]; then
+ apksigner="$(find "$ANDROID_HOME/build-tools" -type f -name apksigner -perm -u+x | sort -V | tail -n 1)"
+ fi
+ if [[ -z "$apksigner" || ! -x "$apksigner" ]]; then
+ echo "Android apksigner is unavailable" >&2
+ exit 1
+ fi
+ "$apksigner" verify --verbose --print-certs "$output_apk" >/dev/null
+done
+
+(
+ cd "$MEGAPROXY_RELEASE_DIR"
+ shasum -a 256 ./*.apk > SHA256SUMS
+)
+
+echo
+echo "Signed release APKs:"
+ls -lh "$MEGAPROXY_RELEASE_DIR"/*.apk
+echo "Checksums: $MEGAPROXY_RELEASE_DIR/SHA256SUMS"
diff --git a/scripts/create-android-emulator.sh b/scripts/create-android-emulator.sh
new file mode 100755
index 0000000..7f2de59
--- /dev/null
+++ b/scripts/create-android-emulator.sh
@@ -0,0 +1,86 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+avd_name="${MEGAPROXY_AVD_NAME:-MegaProxy_API_35}"
+system_image="system-images;android-35;google_apis;arm64-v8a"
+device_profile="pixel_8"
+
+if [[ -z "${ANDROID_HOME:-}" ]]; then
+ echo "ANDROID_HOME is not set" >&2
+ exit 1
+fi
+
+sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
+avdmanager="$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager"
+emulator="$ANDROID_HOME/emulator/emulator"
+
+for tool in "$sdkmanager" "$avdmanager"; do
+ if [[ ! -x "$tool" ]]; then
+ echo "Required Android SDK tool is missing: $tool" >&2
+ exit 1
+ fi
+done
+
+configure_avd() {
+ local avd_config="$HOME/.android/avd/${avd_name}.avd/config.ini"
+
+ if [[ ! -f "$avd_config" ]]; then
+ echo "Android emulator configuration is missing: $avd_config" >&2
+ exit 1
+ fi
+
+ set_avd_option() {
+ local key="$1"
+ local value="$2"
+ local temporary_config
+ temporary_config="$(mktemp "${TMPDIR:-/tmp}/megaproxy-avd-config.XXXXXX")"
+
+ awk -F= -v key="$key" -v value="$value" '
+ $1 == key {
+ if (!updated) {
+ print key "=" value
+ updated = 1
+ }
+ next
+ }
+ { print }
+ END {
+ if (!updated) {
+ print key "=" value
+ }
+ }
+ ' "$avd_config" >"$temporary_config"
+ mv "$temporary_config" "$avd_config"
+ }
+
+ # Integrate the host keyboard and use the mouse as a multi-touch input device.
+ set_avd_option "hw.keyboard" "yes"
+ set_avd_option "hw.keyboard.charmap" "qwerty2"
+ set_avd_option "hw.keyboard.lid" "yes"
+ set_avd_option "hw.screen" "multi-touch"
+
+ echo "Configured host keyboard and mouse input for: $avd_name"
+}
+
+if [[ ! -x "$emulator" || ! -d "$ANDROID_HOME/system-images/android-35/google_apis/arm64-v8a" ]]; then
+ "$sdkmanager" \
+ --sdk_root="$ANDROID_HOME" \
+ "emulator" \
+ "$system_image"
+fi
+
+if "$emulator" -list-avds | grep -Fxq "$avd_name"; then
+ echo "Android emulator already exists: $avd_name"
+ configure_avd
+ exit 0
+fi
+
+echo no | "$avdmanager" create avd \
+ --name "$avd_name" \
+ --package "$system_image" \
+ --device "$device_profile"
+
+configure_avd
+
+echo "Created Android emulator: $avd_name"
+echo "Start it with: emulator @$avd_name -gpu auto"
diff --git a/scripts/debug-android-app.sh b/scripts/debug-android-app.sh
new file mode 100755
index 0000000..5183db3
--- /dev/null
+++ b/scripts/debug-android-app.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+if [[ -f "$HOME/.zshrc.extra" ]]; then
+ source "$HOME/.zshrc.extra"
+fi
+
+echo "Starting MegaProxy JDWP setup"
+"$project_dir/scripts/stop-android-app-session.sh"
+"$project_dir/scripts/run-android-app.sh"
+"$project_dir/scripts/android-jdwp.sh"
diff --git a/scripts/run-android-app.sh b/scripts/run-android-app.sh
new file mode 100755
index 0000000..24b29b5
--- /dev/null
+++ b/scripts/run-android-app.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+if [[ -f "$HOME/.zshrc.extra" ]]; then
+ # VS Code launched from Finder may not inherit the interactive shell environment.
+ source "$HOME/.zshrc.extra"
+fi
+
+"$project_dir/scripts/start-android-emulator.sh"
+
+mkdir -p "$project_dir/app/libs"
+(
+ cd "$project_dir/native"
+ gomobile bind \
+ -target=android \
+ -androidapi 26 \
+ -o ../app/libs/megaproxy.aar \
+ ./mobile
+)
+
+(
+ cd "$project_dir"
+ ./gradlew assembleDebug
+)
+
+adb -e install -r "$project_dir/app/build/outputs/apk/debug/app-debug.apk"
+adb -e shell am start -n net.megaproxy487/.MainActivity
diff --git a/scripts/run-without-debugging.sh b/scripts/run-without-debugging.sh
new file mode 100755
index 0000000..4bcb2b2
--- /dev/null
+++ b/scripts/run-without-debugging.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+if [[ -f "$HOME/.zshrc.extra" ]]; then
+ # VS Code launched from Finder may not inherit the interactive shell environment.
+ source "$HOME/.zshrc.extra"
+fi
+
+"$project_dir/scripts/stop-android-app-session.sh"
+
+exec "$project_dir/scripts/run-android-app.sh"
diff --git a/scripts/start-android-emulator.sh b/scripts/start-android-emulator.sh
new file mode 100755
index 0000000..356c197
--- /dev/null
+++ b/scripts/start-android-emulator.sh
@@ -0,0 +1,109 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+avd_name="${MEGAPROXY_AVD_NAME:-MegaProxy_API_35}"
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+"$script_dir/create-android-emulator.sh"
+
+avd_processes() {
+ ps ax -o pid=,command= | awk -v avd="@$avd_name" '
+ index($0, avd) && ($0 ~ /\/emulator / || $0 ~ /qemu-system/) { print $1 }
+ '
+}
+
+emulator_serial="$(adb devices | awk 'NR > 1 && $1 ~ /^emulator-/ { print $1; exit }')"
+emulator_state=""
+
+if [[ -n "$emulator_serial" ]]; then
+ emulator_state="$(adb -s "$emulator_serial" get-state 2>/dev/null || true)"
+fi
+
+if [[ -n "$emulator_serial" && "$emulator_state" != "device" ]]; then
+ echo "Android emulator is $emulator_state; attempting ADB reconnect"
+ adb reconnect offline >/dev/null 2>&1 || true
+ for _ in {1..20}; do
+ emulator_state="$(adb -s "$emulator_serial" get-state 2>/dev/null || true)"
+ [[ "$emulator_state" == "device" ]] && break
+ sleep 0.25
+ done
+fi
+
+if { [[ -n "$emulator_serial" && "$emulator_state" != "device" ]] ||
+ { [[ -z "$emulator_serial" ]] && [[ -n "$(avd_processes)" ]]; }; }; then
+ if [[ -n "$emulator_serial" ]]; then
+ echo "Stopping unresponsive emulator: $emulator_serial"
+ adb -s "$emulator_serial" emu kill >/dev/null 2>&1 || true
+ else
+ echo "Stopping orphaned emulator process for: $avd_name"
+ fi
+ while read -r emulator_pid; do
+ if [[ "$emulator_pid" =~ ^[0-9]+$ ]]; then
+ kill -TERM "$emulator_pid" >/dev/null 2>&1 || true
+ fi
+ done < <(avd_processes)
+ for _ in {1..120}; do
+ [[ -z "$(avd_processes)" ]] && break
+ sleep 0.25
+ done
+ while read -r emulator_pid; do
+ if [[ "$emulator_pid" =~ ^[0-9]+$ ]]; then
+ echo "Force-stopping stale emulator process: $emulator_pid"
+ kill -KILL "$emulator_pid" >/dev/null 2>&1 || true
+ fi
+ done < <(avd_processes)
+ sleep 1
+ emulator_serial=""
+fi
+
+if [[ -n "$emulator_serial" && "$emulator_state" == "device" ]]; then
+ echo "An Android emulator is already running"
+else
+ emulator_log="${TMPDIR:-/tmp}/megaproxy-emulator.log"
+ echo "Starting Android emulator: $avd_name"
+ # Quick Boot snapshots can leave the guest visible but permanently offline in ADB.
+ # Prefer a deterministic cold boot for automated run and debug workflows.
+ # Emulator 37.1.11 can crash its host-side netsimd process during startup on
+ # macOS. MegaProxy does not need simulated Wi-Fi/UWB radio packet exchange;
+ # Android's regular virtual network remains available with these disabled.
+ emulator_options=(
+ "@$avd_name"
+ -gpu auto
+ -no-snapshot-load
+ -no-snapshot-save
+ -feature -WiFiPacketStream
+ -feature -Uwb
+ -feature -Nfc
+ -netsim-args "--no-test-beacons --no-cli-ui --no-web-ui"
+ )
+ nohup emulator "${emulator_options[@]}" "$emulator_log" 2>&1 &
+fi
+
+echo "Waiting for Android emulator transport"
+emulator_online=false
+for _ in {1..120}; do
+ if [[ "$(adb -e get-state 2>/dev/null || true)" == "device" ]]; then
+ emulator_online=true
+ break
+ fi
+ sleep 1
+done
+
+if [[ "$emulator_online" != "true" ]]; then
+ echo "Android emulator did not become available in ADB within 120 seconds" >&2
+ echo "Emulator log: ${emulator_log:-${TMPDIR:-/tmp}/megaproxy-emulator.log}" >&2
+ exit 1
+fi
+
+echo "Waiting for Android to finish booting"
+for _ in {1..180}; do
+ boot_completed="$(adb -e shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')"
+ if [[ "$boot_completed" == "1" ]]; then
+ echo "Android emulator is ready"
+ exit 0
+ fi
+ sleep 1
+done
+
+echo "Android emulator did not finish booting within 180 seconds" >&2
+exit 1
diff --git a/scripts/stop-android-app-session.sh b/scripts/stop-android-app-session.sh
new file mode 100755
index 0000000..6cbb898
--- /dev/null
+++ b/scripts/stop-android-app-session.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+package_name="net.megaproxy487"
+debug_port="8700"
+jdwp_pid_file="${TMPDIR:-/tmp}/megaproxy-jdwp-session.pid"
+
+if [[ -f "$jdwp_pid_file" ]]; then
+ jdwp_session_pid="$(tr -dc '0-9' <"$jdwp_pid_file")"
+ jdwp_session_command="$(ps -p "$jdwp_session_pid" -o command= 2>/dev/null || true)"
+ if [[ -n "$jdwp_session_pid" ]] && [[ "$jdwp_session_command" == *"android-jdwp.sh"* ]] && kill -0 "$jdwp_session_pid" >/dev/null 2>&1; then
+ echo "Stopping previous MegaProxy JDWP session: $jdwp_session_pid"
+ kill "$jdwp_session_pid" >/dev/null 2>&1 || true
+ for _ in {1..20}; do
+ kill -0 "$jdwp_session_pid" >/dev/null 2>&1 || break
+ sleep 0.1
+ done
+ fi
+ rm -f "$jdwp_pid_file"
+fi
+
+if adb -e get-state >/dev/null 2>&1; then
+ adb -e forward --remove "tcp:$debug_port" >/dev/null 2>&1 || true
+ adb -e shell am clear-debug-app >/dev/null 2>&1 || true
+ adb -e shell am force-stop "$package_name" >/dev/null 2>&1 || true
+fi
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..5fe7b11
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "MegaProxy"
+include(":app")