diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 303e238c..53fcf50b 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -6,103 +6,127 @@ on: pull_request: branches: [main] +permissions: + contents: read + +concurrency: + group: android-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} + +env: + LIBBOX_URL: https://github.com/Vauth/sing-box/releases/download/v1.15.0-foxy-masque.2/libbox.aar + LIBBOX_SHA256: f1b59f4fab207b4c19f8ea3d145bc4dd0e834a530081d0dc045f4b795d6eac0b + jobs: build: + if: github.event_name == 'pull_request' runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 + with: + persist-credentials: false + - uses: actions/setup-java@v4 with: distribution: temurin java-version: "17" - - - name: Setup Android SDK - uses: android-actions/setup-android@v3 + - name: Fetch and verify pinned core + shell: bash + run: | + set -euo pipefail + mkdir -p app/libs + curl --fail --location --retry 3 --connect-timeout 15 --max-time 300 \ + --proto '=https' --proto-redir '=https' -o app/libs/libbox.aar "$LIBBOX_URL" + echo "$LIBBOX_SHA256 app/libs/libbox.aar" | sha256sum --check --strict + - uses: gradle/actions/setup-gradle@v4 with: - packages: "platform-tools" - - - name: Install NDK - run: sdkmanager --install "ndk;26.1.10909125" - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 - - - name: Grant execute permission for gradlew - run: chmod +x gradlew || true - - - name: Generate Gradle wrapper (if missing) - run: gradle wrapper --gradle-version 8.10.2 || true - - - name: Assemble debug APK - run: ./gradlew assembleDebug --no-daemon - - - name: Upload debug APK - uses: actions/upload-artifact@v4 + gradle-version: 8.10.2 + - name: Lint and assemble debug + run: gradle :app:lintDebug :app:assembleDebug --no-daemon --console=plain + - uses: actions/upload-artifact@v4 with: name: foxyvpn-debug-apk path: app/build/outputs/apk/debug/*.apk + if-no-files-found: error + - name: Upload diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: lint-reports + path: app/build/reports/lint-results-* + if-no-files-found: ignore release: - needs: build if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: write steps: + - name: Configure keystore environment + run: echo "SIGNING_KEYSTORE_PATH=${{ runner.temp }}/foxyvpn-release.jks" >> "$GITHUB_ENV" - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 + with: + persist-credentials: false + - uses: actions/setup-java@v4 with: distribution: temurin java-version: "17" - - - name: Setup Android SDK - uses: android-actions/setup-android@v3 + - name: Fetch and verify pinned core + shell: bash + run: | + set -euo pipefail + mkdir -p app/libs + curl --fail --location --retry 3 --connect-timeout 15 --max-time 300 \ + --proto '=https' --proto-redir '=https' -o app/libs/libbox.aar "$LIBBOX_URL" + echo "$LIBBOX_SHA256 app/libs/libbox.aar" | sha256sum --check --strict + - uses: gradle/actions/setup-gradle@v4 with: - packages: "platform-tools" - - - name: Install NDK - run: sdkmanager --install "ndk;26.1.10909125" - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 - - - name: Grant execute permission for gradlew - run: chmod +x gradlew || true - - - name: Generate Gradle wrapper (if missing) - run: gradle wrapper --gradle-version 8.10.2 || true - - - name: Decode signing keystore - id: signing + gradle-version: 8.10.2 + - name: Require release signing credentials env: SIGNING_KEYSTORE_BASE64: ${{ secrets.SIGNING_KEYSTORE_BASE64 }} + SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} + SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} + SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} + shell: bash run: | - if [ -n "$SIGNING_KEYSTORE_BASE64" ]; then - printf '%s' "$SIGNING_KEYSTORE_BASE64" | base64 -d > release-keystore.jks - echo "signed=true" >> "$GITHUB_OUTPUT" - echo "Signing keystore decoded; the release APK will be signed." - else - echo "signed=false" >> "$GITHUB_OUTPUT" - echo "No SIGNING_KEYSTORE_BASE64 secret; the release APK will be unsigned." - fi - - - name: Assemble release APK + set -euo pipefail + : "${SIGNING_KEYSTORE_BASE64:?Missing keystore secret}" + : "${SIGNING_KEY_ALIAS:?Missing key alias}" + : "${SIGNING_KEY_PASSWORD:?Missing key password}" + : "${SIGNING_STORE_PASSWORD:?Missing store password}" + umask 077 + printf '%s' "$SIGNING_KEYSTORE_BASE64" | base64 --decode > "$SIGNING_KEYSTORE_PATH" + - name: Assemble signed release env: SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} - run: ./gradlew assembleRelease --no-daemon - - - name: Remove the decoded keystore + run: gradle :app:assembleRelease --no-daemon --console=plain + - name: Verify APK signatures and generate checksums + shell: bash + run: | + set -euo pipefail + APKSIGNER=$(find "$ANDROID_HOME/build-tools" -name "apksigner" | sort -V | tail -n 1) + for apk in app/build/outputs/apk/release/*.apk; do + "$APKSIGNER" verify --verbose "$apk" + done + cd app/build/outputs/apk/release + sha256sum *.apk > SHA256SUMS + - name: Remove keystore if: always() - run: rm -f release-keystore.jks - - - name: Create GitHub Release + run: rm -f "$SIGNING_KEYSTORE_PATH" + - uses: actions/upload-artifact@v4 + with: + name: release-mapping + path: app/build/outputs/mapping/release/mapping.txt + if-no-files-found: error + - name: Publish signed APKs uses: softprops/action-gh-release@v2 with: - files: app/build/outputs/apk/release/*.apk + files: | + app/build/outputs/apk/release/*.apk + app/build/outputs/apk/release/SHA256SUMS + fail_on_unmatched_files: true generate_release_notes: true diff --git a/.gitignore b/.gitignore index 171bb56e..24001fa7 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,7 @@ app/build/ *.keystore *.jks keystore.properties -app/src/main/jni/ +app/libs/*.aar + + +.kotlin/ diff --git a/README.md b/README.md index 4848d886..356a1de9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-# FoxyVPN - Unofficial Firefox VPN client +# FoxyVPN - Unofficial Firefox VPN Client

FoxyVPN Logo @@ -9,37 +9,46 @@ ![Android](https://img.shields.io/badge/Android-212121?style=for-the-badge&logo=android&logoColor=white) ![Kotlin](https://img.shields.io/badge/Kotlin-212121?style=for-the-badge&logo=kotlin&logoColor=white) ![Compose](https://img.shields.io/badge/Jetpack%20Compose-212121?style=for-the-badge&logo=android&logoColor=white) +![Sing-Box](https://img.shields.io/badge/Sing--Box-212121?style=for-the-badge&logo=go&logoColor=white) ![License](https://img.shields.io/badge/License_MIT-212121?style=for-the-badge) - -*FoxyVPN signs in with a Firefox account, obtains a proxy pass from Mozilla's -Guardian service, and carries the whole device's traffic through Firefox VPN's -Fastly edge over an HTTP/2 tunnel. It is a native Kotlin app with a -Jetpack Compose (Material 3) UI.* +*FoxyVPN is a modern, privacy-focused Android client for Mozilla's Firefox VPN service. Powered by a customized sing-box core, it connects directly to Mozilla's global Fastly edge network using high-performance MASQUE protocol with HTTP/2 and HTTP/3 support.*


-## 🔎 Features - -- **System-wide VPN** -- **Firefox account sign-in** -- **Multiple Servers** -- **Split tunneling** -- **Private DNS by default** -- **Encrypted DNS (DNS over HTTPS)** -- **TLS fragmentation** +## ✨ Features + +### Core & Privacy +- **Firefox Account Integration** +- **System-Wide Tunneling** +- **Split Tunneling** +- **DNS-over-HTTPS (DoH)** +- **Local Network Bypass** +- **Proxy-Only Mode** + +### Performance & Censorship Resistance +- **TLS Fragmentation** +- **uTLS Fingerprint Emulation** +- **Upstream Proxy Chaining** +- **Custom Edge Override** +- **TCP Fast Open (TFO)** +- **Multiplexing Controls** - **Custom Tunnel MTU** -- **Upstream proxy chaining** -- **Proxy-only mode** -- **Dark / light / follow-system theme** -- **Exit verification** -- **In-app logs** + +### User Experience & System Integration +- **Real-Time Diagnostics** +- **Public & Exit IP Detection** +- **Global Locations** +- **Quick Settings Tile & Widget** +- **In-App Auto-Updates** +- **Diagnostic Logging**
## 📸 Screenshots +
Screenshot 1 Screenshot 2 @@ -48,64 +57,60 @@ Jetpack Compose (Material 3) UI.*
-## 🚀 How to use? +## 🚀 Getting Started -1. **Get a Firefox account.** If you do not have one, create it for free at - [accounts.firefox.com](https://accounts.firefox.com/signup). -2. **Install the APK** from the [Releases](https://github.com/vauth/foxyvpn/releases) - page. -3. **Sign in** with that account's email and password. -4. **Pick a location** and tap the power button. +1. **Get a Firefox account**: If you don't already have one, create an account at [accounts.firefox.com](https://accounts.firefox.com). +2. **Download & Install**: Grab the latest APK matching your device (`arm64-v8a`, `armeabi-v7a`, or `universal`) from the [Releases](https://github.com/Vauth/FoxyVPN/releases) page. +3. **Sign In**: Tap "Sign in with Firefox" to authenticate via the in-app browser. +4. **Connect**: Choose a server location and tap the power button. > [!NOTE] -> **No subscription is required.** FoxyVPN runs on the free **50 GB of monthly -VPN traffic Mozilla includes with a Firefox account** for browser users, which -is the same allowance the Firefox browser's built-in VPN uses. Once the -allowance is used up for the month, connections stop working until it resets. +> Access, entitlement, quota, and geographic availability are controlled by Mozilla. FoxyVPN connects directly to official endpoints and respects account quotas.
-## 🗃 Requirements +## 🛠 Building from Source -- Android 8.0 (API 26) or newer +### Prerequisites - JDK 17 -- Android SDK 35 and NDK `26.1.10909125` -- `git` on `PATH` - -
- -## ⚙️ Build -* #### Clone Repository: -```bash -git clone https://github.com/vauth/foxyvpn.git -cd foxyvpn +- Android SDK (API 35, Build-Tools 35.0.0) +- Gradle 8.10.2 + +### Build Instructions +1. Clone the repository: + ```bash + git clone https://github.com/Vauth/FoxyVPN.git + cd FoxyVPN ``` -* #### Build and Install: -```bash -gradle :app:assembleRelease --no-daemon -``` -
+2. Download and verify the pinned sing-box core (`libbox.aar`): + ```bash + mkdir -p app/libs + curl -L -o app/libs/libbox.aar https://github.com/Vauth/sing-box/releases/download/v1.15.0-foxy-masque.2/libbox.aar + echo "f1b59f4fab207b4c19f8ea3d145bc4dd0e834a530081d0dc045f4b795d6eac0b app/libs/libbox.aar" | sha256sum --check --strict + ``` -### 📝 To-Do List -- [x] Improve stability. -- [x] Better Logo. -- [ ] HTTP/3 implementation (Not Planned Yet). +3. Build the APKs: + ```bash + gradle :app:assembleDebug + ``` + Output APKs will be located in `app/build/outputs/apk/debug/`.
## ✍️ Acknowledgements -- [firefox-vpn-client](https://github.com/UjuiUjuMandan/firefox-vpn-client) - the Go reference client this app is a port of -- [hev-socks5-tunnel](https://github.com/heiher/hev-socks5-tunnel) - the native tun2socks engine -- [Conscrypt](https://github.com/google/conscrypt) - ALPN-capable TLS on every supported API level +- [sing-box](https://github.com/SagerNet/sing-box) - the universal proxy platform +- [firefox-vpn-client](https://github.com/UjuiUjuMandan/firefox-vpn-client) - the Go reference client
## 🛠 Contributing + Contributions are welcome! Feel free to submit a pull request or report an issue.
## 📄 License + This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cdc35f01..3bccbe7e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,33 +2,10 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") - id("org.jetbrains.kotlin.plugin.serialization") } -val hevSocks5TunnelVersion = "2.17.1" -val hevSocks5TunnelDir = file("src/main/jni") - -val fetchHevSocks5Tunnel = tasks.register("fetchHevSocks5Tunnel") { - val marker = File(hevSocks5TunnelDir, "Android.mk") - outputs.file(marker) - onlyIf { !marker.exists() } - doFirst { hevSocks5TunnelDir.mkdirs() } - commandLine( - "git", "clone", - "--branch", hevSocks5TunnelVersion, - "--depth", "1", - "--recursive", - "--shallow-submodules", - "https://github.com/heiher/hev-socks5-tunnel.git", - hevSocks5TunnelDir.absolutePath, - ) -} - -tasks.matching { it.name.startsWith("externalNativeBuild") || it.name.contains("NdkBuild") } - .configureEach { dependsOn(fetchHevSocks5Tunnel) } -tasks.named("preBuild").configure { dependsOn(fetchHevSocks5Tunnel) } - -val releaseKeystoreFile = rootProject.file("release-keystore.jks") +val releaseKeystoreFile = System.getenv("SIGNING_KEYSTORE_PATH")?.let { file(it) } + ?: rootProject.file("release-keystore.jks") val releaseKeyAlias: String? = System.getenv("SIGNING_KEY_ALIAS") val releaseKeyPassword: String? = System.getenv("SIGNING_KEY_PASSWORD") val releaseStorePassword: String? = System.getenv("SIGNING_STORE_PASSWORD") @@ -40,32 +17,22 @@ val hasReleaseSigning = releaseKeystoreFile.exists() && android { namespace = "com.vauth.foxyvpn" compileSdk = 35 - - ndkVersion = "26.1.10909125" + buildToolsVersion = "35.0.0" defaultConfig { applicationId = "com.vauth.foxyvpn" minSdk = 26 targetSdk = 35 - versionCode = 45 - versionName = "1.0.8" - - externalNativeBuild { - ndkBuild { - abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64", "x86") - arguments += listOf( - - "APP_PLATFORM=android-26", - - "APP_CFLAGS=-DPKGNAME=com/vauth/foxyvpn/vpn/tun -DCLSNAME=HevSocks5Tunnel", - ) - } - } + versionCode = 46 + versionName = "2.0.0" } - externalNativeBuild { - ndkBuild { - path = file("src/main/jni/Android.mk") + splits { + abi { + isEnable = true + isUniversalApk = true + reset() + include("armeabi-v7a", "arm64-v8a") } } @@ -98,6 +65,7 @@ android { kotlinOptions { jvmTarget = "17" + allWarningsAsErrors = true freeCompilerArgs += listOf( "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", "-opt-in=androidx.compose.material3.ExperimentalMaterial3ExpressiveApi", @@ -110,20 +78,26 @@ android { } lint { - checkReleaseBuilds = false - abortOnError = false + checkReleaseBuilds = true + abortOnError = true } packaging { + jniLibs { + useLegacyPackaging = true + keepDebugSymbols.add("**/libbox.so") + keepDebugSymbols.add("**/libandroidx.graphics.path.so") + } resources.excludes.add("META-INF/INDEX.LIST") resources.excludes.add("META-INF/*.SF") resources.excludes.add("META-INF/*.DSA") resources.excludes.add("META-INF/*.RSA") - resources.pickFirsts.add("META-INF/io.netty.versions.properties") } } dependencies { + implementation(files("libs/libbox.aar")) + implementation(platform("androidx.compose:compose-bom:2025.01.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") @@ -131,26 +105,18 @@ dependencies { implementation("androidx.compose.material3:material3:1.4.0-alpha10") implementation("androidx.compose.material:material-icons-extended") implementation("androidx.activity:activity-compose:1.9.3") - implementation("androidx.navigation:navigation-compose:2.8.5") + implementation("androidx.navigation:navigation-compose:2.9.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.core:core-ktx:1.15.0") implementation("com.squareup.okhttp3:okhttp:4.12.0") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") - implementation("androidx.security:security-crypto:1.1.0-alpha06") - - implementation("io.netty:netty-handler:4.1.115.Final") - implementation("io.netty:netty-codec-http2:4.1.115.Final") - implementation("io.netty:netty-transport:4.1.115.Final") - - implementation("io.netty:netty-handler-proxy:4.1.115.Final") + implementation("androidx.security:security-crypto:1.1.0") - implementation("org.conscrypt:conscrypt-android:2.5.2") + implementation("com.google.errorprone:error_prone_annotations:2.27.0") - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.2.1") debugImplementation("androidx.compose.ui:ui-tooling") } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e3012acb..dd575ad6 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,25 +1,18 @@ --dontwarn io.netty.** --keep class io.netty.** { *; } - --keep class com.vauth.foxyvpn.vpn.tun.HevSocks5Tunnel { *; } --keepnames class com.vauth.foxyvpn.vpn.tun.HevSocks5Tunnel -keepclasseswithmembernames class * { native ; } --keep class org.conscrypt.** { *; } --dontwarn org.conscrypt.** - --dontwarn io.netty.internal.tcnative.** --dontwarn io.netty.handler.ssl.OpenSsl** - --keepclasseswithmembers class * { - kotlinx.serialization.KSerializer serializer(...); -} +-keep class io.nekohasekai.libbox.** { *; } +-dontwarn io.nekohasekai.libbox.** --dontwarn com.google.errorprone.annotations.** +-keep class go.** { *; } +-dontwarn go.** --dontwarn reactor.blockhound.** +-keep class * implements io.nekohasekai.libbox.PlatformInterface { *; } +-keep class * implements io.nekohasekai.libbox.CommandServerHandler { *; } +-keep class * implements io.nekohasekai.libbox.CommandClientHandler { *; } +-keep class * implements io.nekohasekai.libbox.AutoRedirectHandler { *; } +-keep class com.vauth.foxyvpn.vpn.FoxyPlatformInterface { *; } -keepattributes JavascriptInterface, *Annotation* @@ -27,3 +20,11 @@ -keepclassmembers class * { @android.webkit.JavascriptInterface ; } + +-keep class com.vauth.foxyvpn.data.** { *; } +-keepclassmembers class * extends androidx.lifecycle.ViewModel { + (...); +} +-keep class com.vauth.foxyvpn.vpn.IpLocationInfo { *; } +-keep class com.vauth.foxyvpn.vpn.VpnLiveStats { *; } + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a1a1049e..e9584b6e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,26 +1,28 @@ - + + + - - - - - - - @@ -62,7 +64,7 @@ android:name=".widget.QuickConnectWidgetProvider" android:label="@string/quick_connect" android:icon="@drawable/widget_preview" - android:exported="true"> + android:exported="false"> diff --git a/app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt b/app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt index 05b21339..7c19e9e6 100644 --- a/app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt +++ b/app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt @@ -4,9 +4,6 @@ import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import android.content.res.Configuration -import android.os.Build -import android.util.Log -import com.vauth.foxyvpn.data.ControlPlaneHttp import com.vauth.foxyvpn.data.CrashReporter import com.vauth.foxyvpn.data.FxaAuthRepository import com.vauth.foxyvpn.data.GuardianClient @@ -14,21 +11,34 @@ import com.vauth.foxyvpn.data.ProxyStateStore import com.vauth.foxyvpn.data.ServerListClient import com.vauth.foxyvpn.data.SettingsStore import com.vauth.foxyvpn.data.TokenStore +import com.vauth.foxyvpn.update.AppUpdater import com.vauth.foxyvpn.vpn.FoxyVpnService -import com.vauth.foxyvpn.vpn.upstream.NettyLoggingBridge -import com.vauth.foxyvpn.vpn.upstream.UpstreamTrust import com.vauth.foxyvpn.widget.QuickConnectWidgetProvider -import org.conscrypt.Conscrypt -import java.security.Security -class FoxyVpnApp : Application() { +interface AppContainer { + val tokenStore: TokenStore + val proxyStateStore: ProxyStateStore + val settingsStore: SettingsStore + val guardianClient: GuardianClient + val serverListClient: ServerListClient + val authRepository: FxaAuthRepository +} + +class FoxyVpnApp : Application(), AppContainer { - lateinit var tokenStore: TokenStore - lateinit var proxyStateStore: ProxyStateStore - lateinit var settingsStore: SettingsStore - lateinit var guardianClient: GuardianClient - lateinit var serverListClient: ServerListClient - lateinit var authRepository: FxaAuthRepository + internal lateinit var appUpdater: AppUpdater + override lateinit var tokenStore: TokenStore + internal set + override lateinit var proxyStateStore: ProxyStateStore + internal set + override lateinit var settingsStore: SettingsStore + internal set + override lateinit var guardianClient: GuardianClient + internal set + override lateinit var serverListClient: ServerListClient + internal set + override lateinit var authRepository: FxaAuthRepository + internal set override fun onCreate() { super.onCreate() @@ -36,34 +46,23 @@ class FoxyVpnApp : Application() { CrashReporter.install(this) CrashReporter.replayLastCrashIfAny(this) - NettyLoggingBridge.install() - installConscrypt() - UpstreamTrust.init(this) + appUpdater = AppUpdater(this) tokenStore = TokenStore(this) proxyStateStore = ProxyStateStore(this) settingsStore = SettingsStore(this) - ControlPlaneHttp.tlsSplitModeProvider = { settingsStore.tlsSplitMode } guardianClient = GuardianClient() serverListClient = ServerListClient(this) authRepository = FxaAuthRepository(tokenStore) createNotificationChannel() } - private fun installConscrypt() { - runCatching { - Security.insertProviderAt(Conscrypt.newProvider(), 1) - }.onFailure { - Log.w("FoxyVpnApp", "failed to install Conscrypt security provider", it) - } - } - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - val channel = NotificationChannel( - VPN_NOTIFICATION_CHANNEL_ID, - "VPN status", - NotificationManager.IMPORTANCE_LOW, - ) + val channel = + NotificationChannel( + VPN_NOTIFICATION_CHANNEL_ID, + "VPN status", + NotificationManager.IMPORTANCE_LOW, + ) getSystemService(NotificationManager::class.java).createNotificationChannel(channel) } diff --git a/app/src/main/java/com/vauth/foxyvpn/MainActivity.kt b/app/src/main/java/com/vauth/foxyvpn/MainActivity.kt index 01b66da8..af9a9e82 100644 --- a/app/src/main/java/com/vauth/foxyvpn/MainActivity.kt +++ b/app/src/main/java/com/vauth/foxyvpn/MainActivity.kt @@ -17,30 +17,29 @@ import androidx.navigation.compose.rememberNavController import com.vauth.foxyvpn.ui.navigation.FoxyNavGraph import com.vauth.foxyvpn.ui.theme.FoxyVpnTheme import com.vauth.foxyvpn.ui.theme.rememberThemeController +import com.vauth.foxyvpn.update.UpdatePrompt import com.vauth.foxyvpn.vpn.FoxyVpnService class MainActivity : ComponentActivity() { - private val vpnPermissionLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult(), - ) { result -> - if (result.resultCode == RESULT_OK) { - requestNotificationPermissionIfNeeded() - FoxyVpnService.start(this) - } else { + private val vpnPermissionLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + requestNotificationPermissionIfNeeded() + FoxyVpnService.start(this) + } else { - Toast.makeText( - this, - "FoxyVPN needs Android's VPN permission to tunnel this device's traffic. " + - "Proxy-only mode in Settings works without it.", - Toast.LENGTH_LONG, - ).show() + Toast.makeText( + this, + getString(R.string.vpn_permission_denied_help), + Toast.LENGTH_LONG, + ) + .show() + } } - } - private val notificationPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { } + private val notificationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) {} private fun requestConnect() { if ((application as FoxyVpnApp).settingsStore.proxyOnlyMode) { @@ -60,27 +59,31 @@ class MainActivity : ComponentActivity() { private fun requestNotificationPermissionIfNeeded() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return - val alreadyGranted = ContextCompat.checkSelfPermission( - this, - Manifest.permission.POST_NOTIFICATIONS, - ) == PackageManager.PERMISSION_GRANTED + val alreadyGranted = + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED if (!alreadyGranted) { notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } + override fun onStart() { + super.onStart() + (application as FoxyVpnApp).appUpdater.check(automatic = true) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val app = application as FoxyVpnApp setContent { - val themeController = rememberThemeController(app.settingsStore) FoxyVpnTheme(themeMode = themeController.mode) { Surface(modifier = Modifier.fillMaxSize()) { val navController = rememberNavController() + UpdatePrompt(app.appUpdater) FoxyNavGraph( navController = navController, - app = app, + container = app, themeController = themeController, onRequestConnect = { requestConnect() }, onDisconnect = { FoxyVpnService.stop(this) }, diff --git a/app/src/main/java/com/vauth/foxyvpn/data/AppLinks.kt b/app/src/main/java/com/vauth/foxyvpn/data/AppLinks.kt new file mode 100644 index 00000000..f3d86cb7 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/data/AppLinks.kt @@ -0,0 +1,8 @@ +package com.vauth.foxyvpn.data + +object AppLinks { + const val REPOSITORY = "Vauth/FoxyVPN" + const val SOURCE = "https://github.com/$REPOSITORY" + const val RELEASE_API = "https://api.github.com/repos/$REPOSITORY/releases/latest" + const val RELEASE_DOWNLOAD_PATH = "/$REPOSITORY/releases/download/" +} diff --git a/app/src/main/java/com/vauth/foxyvpn/data/AppLogger.kt b/app/src/main/java/com/vauth/foxyvpn/data/AppLogger.kt index d0923bf3..80ddf393 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/AppLogger.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/AppLogger.kt @@ -1,18 +1,21 @@ package com.vauth.foxyvpn.data import android.util.Log -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import com.vauth.foxyvpn.BuildConfig import java.text.SimpleDateFormat +import java.util.ArrayDeque import java.util.Date import java.util.Locale -import java.util.concurrent.ConcurrentLinkedDeque import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow -enum class LogLevel { INFO, WARN, ERROR } +enum class LogLevel { + INFO, + WARN, + ERROR, +} data class LogEntry( val id: Long, @@ -27,87 +30,114 @@ object AppLogger { private const val MAX_ENTRIES = 2_000 private const val MIN_PUBLISH_INTERVAL_MS = 200L - private val buffer = ConcurrentLinkedDeque() + private val buffer = ArrayDeque() private val _entries = MutableStateFlow>(emptyList()) val entries: StateFlow> = _entries - private val lastPublishAt = AtomicLong(0L) - private val flushScheduled = AtomicBoolean(false) + private var lastPublishAt = + System.nanoTime() - TimeUnit.MILLISECONDS.toNanos(MIN_PUBLISH_INTERVAL_MS) + private var flushScheduled = false - private val scheduler = Executors.newSingleThreadScheduledExecutor { runnable -> - Thread(runnable, "AppLogger-flush").apply { isDaemon = true } - } + private val scheduler = + Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "AppLogger-flush").apply { isDaemon = true } + } - private val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + private val secretFields = + Regex( + """(?i)(["']?(?:access_?token|refresh_?token|session_?token|bearer_?token|proxy_?token|authPW|password|token|secret)["']?\s*[:=]\s*)(?:"(?:\\[\s\S]|[^"\\])*+"?|'(?:\\[\s\S]|[^'\\])*+'?|[^\s,}&]+)""" + ) + private val authorization = Regex("(?i)(?:Bearer|Basic|Hawk)\\s+[^\\r\\n]+") + private val cookies = Regex("(?i)(?:cookie|set-cookie)\\s*:\\s*[^\\r\\n]+") + private val urlCredentials = Regex("""(?i)(\b(?:https?|socks5h?)://)[^\s/?#]*@""") + + internal fun redact(message: String): String { + val withoutAuthorization = authorization.replace(message, "[redacted authorization]") + val withoutCookies = cookies.replace(withoutAuthorization, "[redacted cookie]") + val withoutUrlCredentials = + urlCredentials.replace(withoutCookies) { "${it.groupValues[1]}[redacted]@" } + return secretFields.replace(withoutUrlCredentials) { "${it.groupValues[1]}[redacted]" } + } fun d(tag: String, message: String) { - Log.d(tag, message) + if (BuildConfig.DEBUG) Log.d(tag, redact(message)) } fun i(tag: String, message: String) { - Log.i(tag, message) + Log.i(tag, redact(message)) log(LogLevel.INFO, tag, message) } fun w(tag: String, message: String, error: Throwable? = null) { - if (error != null) Log.w(tag, message, error) else Log.w(tag, message) + Log.w(tag, redact(if (error != null) "$message: ${error.message}" else message)) log(LogLevel.WARN, tag, if (error != null) "$message: ${error.message}" else message) } fun e(tag: String, message: String, error: Throwable? = null) { - if (error != null) Log.e(tag, message, error) else Log.e(tag, message) + Log.e(tag, redact(if (error != null) "$message: ${error.message}" else message)) log(LogLevel.ERROR, tag, if (error != null) "$message: ${error.message}" else message) } - private val currentSize = java.util.concurrent.atomic.AtomicInteger(0) - - private val entryCounter = java.util.concurrent.atomic.AtomicLong(0) + private var entryCounter = 0L + @Synchronized private fun log(level: LogLevel, tag: String, message: String) { - buffer.addLast(LogEntry(entryCounter.incrementAndGet(), System.currentTimeMillis(), level, tag, message)) - var current = currentSize.incrementAndGet() - - while (current > MAX_ENTRIES) { - if (buffer.pollFirst() != null) { - current = currentSize.decrementAndGet() - } else { - currentSize.set(0) - break - } - } + buffer.addLast( + LogEntry( + ++entryCounter, + System.currentTimeMillis(), + level, + tag, + redact(message).take(8192), + ) + ) + while (buffer.size > MAX_ENTRIES) buffer.removeFirst() schedulePublish() } private fun schedulePublish() { - val now = System.currentTimeMillis() - val last = lastPublishAt.get() - if (now - last >= MIN_PUBLISH_INTERVAL_MS) { - if (lastPublishAt.compareAndSet(last, now)) { - _entries.value = buffer.toList() - } + val now = System.nanoTime() + val elapsedNanos = now - lastPublishAt + val intervalNanos = TimeUnit.MILLISECONDS.toNanos(MIN_PUBLISH_INTERVAL_MS) + if (elapsedNanos >= intervalNanos) { + lastPublishAt = now + _entries.value = buffer.toList() return } - if (flushScheduled.compareAndSet(false, true)) { - val delayMs = (MIN_PUBLISH_INTERVAL_MS - (now - last)).coerceAtLeast(0) - val scheduledOk = runCatching { - scheduler.schedule({ - flushScheduled.set(false) - lastPublishAt.set(System.currentTimeMillis()) - _entries.value = buffer.toList() - }, delayMs, TimeUnit.MILLISECONDS) - }.isSuccess - if (!scheduledOk) flushScheduled.set(false) + if (!flushScheduled) { + flushScheduled = true + val delayNanos = (intervalNanos - elapsedNanos).coerceAtLeast(0) + val scheduledOk = + runCatching { + scheduler.schedule( + { + synchronized(this) { + flushScheduled = false + lastPublishAt = System.nanoTime() + _entries.value = buffer.toList() + } + }, + delayNanos, + TimeUnit.NANOSECONDS, + ) + } + .isSuccess + if (!scheduledOk) flushScheduled = false } } + @Synchronized fun clear() { buffer.clear() - currentSize.set(0) _entries.value = emptyList() } - fun exportAsText(): String = buffer.toList().joinToString("\n") { entry -> - val ts = timeFormat.format(Date(entry.timestampMillis)) - "$ts ${entry.level.name.padEnd(5)} [${entry.tag}] ${entry.message}" + fun exportAsText(): String { + val snapshot = synchronized(this) { buffer.toList() } + val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + return snapshot.joinToString("\n") { entry -> + val ts = formatter.format(Date(entry.timestampMillis)) + "$ts ${entry.level.name.padEnd(5)} [${entry.tag}] ${entry.message}" + } } } diff --git a/app/src/main/java/com/vauth/foxyvpn/data/BoundedInput.kt b/app/src/main/java/com/vauth/foxyvpn/data/BoundedInput.kt new file mode 100644 index 00000000..496d7625 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/data/BoundedInput.kt @@ -0,0 +1,30 @@ +package com.vauth.foxyvpn.data + +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import okhttp3.ResponseBody + +internal fun InputStream.readBoundedUtf8(maximumBytes: Int): String { + require(maximumBytes > 0) + val output = ByteArrayOutputStream(minOf(maximumBytes, 8192)) + val buffer = ByteArray(8192) + while (true) { + val count = read(buffer) + if (count < 0) break + if (count > maximumBytes - output.size()) + throw IOException("Response exceeds the size limit") + output.write(buffer, 0, count) + } + return output.toString(Charsets.UTF_8.name()) +} + +internal fun ResponseBody.readBoundedText(maximumBytes: Int = 1_048_576): String { + require(maximumBytes > 0) + if (contentLength() > maximumBytes) throw IOException("Response exceeds the size limit") + return source().use { source -> + if (source.request(maximumBytes.toLong() + 1)) + throw IOException("Response exceeds the size limit") + source.readString(contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8) + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/data/ConnectionCandidates.kt b/app/src/main/java/com/vauth/foxyvpn/data/ConnectionCandidates.kt new file mode 100644 index 00000000..16c6615e --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/data/ConnectionCandidates.kt @@ -0,0 +1,24 @@ +package com.vauth.foxyvpn.data + +import com.vauth.foxyvpn.data.model.ProxyCandidate + +internal fun preferredConnectionCandidates( + candidates: List, + saved: ProxyCandidate?, +): List { + if (saved == null) { + val recommended = + candidates.filter { it.countryCode.equals(RECOMMENDED_COUNTRY_CODE, true) } + if (recommended.isNotEmpty()) return recommended + val firstCountry = candidates.firstOrNull()?.countryCode ?: return emptyList() + return candidates.filter { it.countryCode.equals(firstCountry, true) } + } + val country = candidates.filter { it.countryCode.equals(saved.countryCode, true) } + country + .firstOrNull { it.host.equals(saved.host, true) && it.port == saved.port } + ?.let { + return listOf(it) + } + val city = country.filter { it.cityCode.equals(saved.cityCode, true) } + return city.ifEmpty { country } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt b/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt index e054a883..6c22d020 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt @@ -1,176 +1,228 @@ package com.vauth.foxyvpn.data -import okhttp3.Cookie -import okhttp3.CookieJar -import okhttp3.HttpUrl -import okhttp3.OkHttpClient -import okhttp3.Request -import com.vauth.foxyvpn.vpn.upstream.TlsClientHelloSplitter -import java.io.FilterOutputStream -import java.io.OutputStream +import android.net.Network +import android.webkit.CookieManager import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import javax.net.SocketFactory +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request -private const val TAG = "ControlPlaneHttp" - -const val MOZILLA_VPN_USER_AGENT = "MozillaVPN/2.35.0 (sys:android 14; iap:true)" - -fun Request.Builder.applyMozillaVpnHeaders(): Request.Builder { - header("User-Agent", MOZILLA_VPN_USER_AGENT) - header("Accept", "application/json") - return this -} +fun Request.Builder.applyFirefoxHeaders(): Request.Builder = FirefoxAccountHeaders.apply(this) class SimpleCookieJar : CookieJar { - private val byDomain = ConcurrentHashMap>() + private val cookies = mutableListOf() + @Synchronized override fun saveFromResponse(url: HttpUrl, cookies: List) { for (cookie in cookies) { - val bucket = byDomain.getOrPut(cookie.domain) { mutableListOf() } - synchronized(bucket) { - bucket.removeAll { it.name == cookie.name } - bucket.add(cookie) + this.cookies.removeAll { + it.name == cookie.name && it.domain == cookie.domain && it.path == cookie.path } + if (cookie.expiresAt > System.currentTimeMillis()) this.cookies.add(cookie) } } + @Synchronized override fun loadForRequest(url: HttpUrl): List { - val now = System.currentTimeMillis() - val result = mutableListOf() - for ((domain, cookies) in byDomain) { - if (url.host == domain || url.host.endsWith(".$domain")) { - synchronized(cookies) { - result.addAll(cookies.filter { it.expiresAt > now }) - } - } - } - return result + cookies.removeAll { it.expiresAt <= System.currentTimeMillis() } + return cookies + .filter { it.matches(url) } + .sortedByDescending { it.domain.length } + .distinctBy { it.name } } - fun set(name: String, value: String, domain: String) { - val bucket = byDomain.getOrPut(domain) { mutableListOf() } - synchronized(bucket) { - bucket.removeAll { it.name == name } - bucket.add( - Cookie.Builder() - .name(name) - .value(value) - .domain(domain) - .path("/") - .expiresAt(Long.MAX_VALUE) - .build(), - ) - } - } - - fun snapshotAll(): List = byDomain.values.flatten() -} - -private class SplitClientHelloSocket( - private val modeProvider: () -> SettingsStore.TlsSplitMode, -) : Socket() { - private var splitDone = false - - override fun getOutputStream(): OutputStream { - val realOut = super.getOutputStream() - return object : FilterOutputStream(realOut) { - override fun write(b: ByteArray, off: Int, len: Int) { - val mode = modeProvider() - if (splitDone || mode == SettingsStore.TlsSplitMode.OFF) { - realOut.write(b, off, len) - return - } - - if (!TlsClientHelloSplitter.looksLikeClientHello(b, off, len)) { - realOut.write(b, off, len) - return - } - - splitDone = true - val segments = try { - TlsClientHelloSplitter.splitBytes(b, off, len, mode) - } catch (e: Throwable) { - null - } + @Synchronized fun snapshotAll(): List = cookies.toList() - if (segments.isNullOrEmpty()) { - realOut.write(b, off, len) - return - } + @Synchronized + fun clear() { + cookies.clear() + } - runCatching { this@SplitClientHelloSocket.tcpNoDelay = true } - for (seg in segments) { - realOut.write(seg) - realOut.flush() + @Synchronized + fun syncFromWebView( + urls: List = + listOf( + "https://accounts.firefox.com", + "https://api.accounts.firefox.com", + "https://oauth.accounts.firefox.com", + "https://vpn.mozilla.org", + ), + ) { + val manager = runCatching { CookieManager.getInstance() }.getOrNull() ?: return + for (urlStr in urls) { + val httpUrl = urlStr.toHttpUrlOrNull() ?: continue + val cookieHeader = runCatching { manager.getCookie(urlStr) }.getOrNull() ?: continue + val parsedCookies = mutableListOf() + for (raw in cookieHeader.split(";")) { + val trimmed = raw.trim() + if (trimmed.isEmpty()) continue + val eqIdx = trimmed.indexOf('=') + if (eqIdx <= 0) continue + val name = trimmed.substring(0, eqIdx).trim() + val value = trimmed.substring(eqIdx + 1).trim() + if (name.isEmpty() || value.isEmpty()) continue + + val hostCookie = + runCatching { + Cookie.Builder() + .name(name) + .value(value) + .domain(httpUrl.host) + .path("/") + .apply { if (httpUrl.isHttps) secure() } + .build() + }.getOrNull() + if (hostCookie != null) parsedCookies.add(hostCookie) + + if (httpUrl.host.endsWith("firefox.com") && httpUrl.host != "firefox.com") { + val domainCookie = + runCatching { + Cookie.Builder() + .name(name) + .value(value) + .domain("firefox.com") + .path("/") + .apply { if (httpUrl.isHttps) secure() } + .build() + }.getOrNull() + if (domainCookie != null) parsedCookies.add(domainCookie) + for (target in listOf("api.accounts.firefox.com", "oauth.accounts.firefox.com", "vpn.mozilla.org")) { + val targetCookie = + runCatching { + Cookie.Builder() + .name(name) + .value(value) + .domain(target) + .path("/") + .apply { if (httpUrl.isHttps) secure() } + .build() + }.getOrNull() + if (targetCookie != null) parsedCookies.add(targetCookie) + } } } - - override fun write(b: ByteArray) { - write(b, 0, b.size) + if (parsedCookies.isNotEmpty()) { + saveFromResponse(httpUrl, parsedCookies) } } } + + @Synchronized + fun syncToWebView(cookies: List) { + val manager = runCatching { CookieManager.getInstance() }.getOrNull() ?: return + for (cookie in cookies) { + val host = cookie.domain.trimStart('.') + if (host.isEmpty()) continue + val urlStr = "https://$host" + val cookieString = StringBuilder().apply { + append(cookie.name).append("=").append(cookie.value) + append("; Domain=").append(cookie.domain) + append("; Path=").append(cookie.path) + if (cookie.secure) append("; Secure") + if (cookie.httpOnly) append("; HttpOnly") + }.toString() + runCatching { manager.setCookie(urlStr, cookieString) } + } + runCatching { manager.flush() } + } } private class ProtectingSocketFactory : SocketFactory() { - private fun createRawSocket(): Socket = - SplitClientHelloSocket { ControlPlaneHttp.tlsSplitModeProvider?.invoke() ?: SettingsStore.TlsSplitMode.OFF } - - private fun protect(socket: Socket, localAddress: InetSocketAddress?): Socket { - runCatching { - if (!socket.isBound) socket.bind(localAddress ?: InetSocketAddress(0)) - }.onFailure { - AppLogger.w(TAG, "could not bind a control-plane socket before protecting it", it) - } - val protector = ControlPlaneHttp.socketProtector - if (protector != null) { - val protectedOk = runCatching { protector.invoke(socket) }.getOrDefault(false) - if (!protectedOk) { - AppLogger.w( - TAG, - "VpnService.protect() refused a control-plane socket; this request would be routed " + - "back into the tunnel instead of out to the network", - ) + private fun createProtectedSocket( + localAddress: InetSocketAddress? = null, + remoteAddress: InetSocketAddress? = null, + ): Socket { + val socket = Socket() + try { + socket.bind(localAddress ?: InetSocketAddress(0)) + val protector = ControlPlaneHttp.socketProtector + if (protector != null && !protector(socket)) { + throw java.io.IOException("VPN refused control-plane socket protection") } + ControlPlaneHttp.underlyingNetwork()?.bindSocket(socket) + if (remoteAddress != null) socket.connect(remoteAddress) + return socket + } catch (error: Exception) { + runCatching { socket.close() }.onFailure { error.addSuppressed(it) } + throw error } - return socket } - override fun createSocket(): Socket = protect(createRawSocket(), null) + override fun createSocket(): Socket = createProtectedSocket() override fun createSocket(host: String, port: Int): Socket = - protect(createRawSocket(), null).apply { connect(InetSocketAddress(host, port)) } - - override fun createSocket(host: String, port: Int, localAddr: InetAddress, localPort: Int): Socket = - protect(createRawSocket(), InetSocketAddress(localAddr, localPort)) - .apply { connect(InetSocketAddress(host, port)) } + createProtectedSocket(remoteAddress = InetSocketAddress(host, port)) + + override fun createSocket( + host: String, + port: Int, + localAddr: InetAddress, + localPort: Int, + ): Socket = + createProtectedSocket( + InetSocketAddress(localAddr, localPort), + InetSocketAddress(host, port), + ) override fun createSocket(host: InetAddress, port: Int): Socket = - protect(createRawSocket(), null).apply { connect(InetSocketAddress(host, port)) } - - override fun createSocket(host: InetAddress, port: Int, localAddr: InetAddress, localPort: Int): Socket = - protect(createRawSocket(), InetSocketAddress(localAddr, localPort)) - .apply { connect(InetSocketAddress(host, port)) } + createProtectedSocket(remoteAddress = InetSocketAddress(host, port)) + + override fun createSocket( + host: InetAddress, + port: Int, + localAddr: InetAddress, + localPort: Int, + ): Socket = + createProtectedSocket( + InetSocketAddress(localAddr, localPort), + InetSocketAddress(host, port), + ) } object ControlPlaneHttp { val cookieJar = SimpleCookieJar() - @Volatile - var socketProtector: ((Socket) -> Boolean)? = null + @Volatile var socketProtector: ((Socket) -> Boolean)? = null + + @Volatile var networkProvider: (() -> Network?)? = null - @Volatile - var tlsSplitModeProvider: (() -> SettingsStore.TlsSplitMode)? = null + internal fun underlyingNetwork(): Network? { + val provider = networkProvider ?: return null + return provider() ?: throw java.io.IOException("No underlying network is available") + } val client: OkHttpClient by lazy { OkHttpClient.Builder() + .protocols(listOf(Protocol.HTTP_1_1)) .cookieJar(cookieJar) .socketFactory(ProtectingSocketFactory()) + .dns( + object : okhttp3.Dns { + override fun lookup(hostname: String): List { + val network = runCatching { underlyingNetwork() }.getOrNull() + val fromNetwork = + if (network != null) { + runCatching { network.getAllByName(hostname).toList() }.getOrNull() + } else { + null + } + return if (!fromNetwork.isNullOrEmpty()) { + fromNetwork + } else { + okhttp3.Dns.SYSTEM.lookup(hostname) + } + } + } + ) .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .callTimeout(30, TimeUnit.SECONDS) diff --git a/app/src/main/java/com/vauth/foxyvpn/data/CrashReporter.kt b/app/src/main/java/com/vauth/foxyvpn/data/CrashReporter.kt index 24088545..85013386 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/CrashReporter.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/CrashReporter.kt @@ -30,9 +30,7 @@ object CrashReporter { runCatching { file.delete() } if (text.isNullOrBlank()) return AppLogger.e(TAG, "app crashed last run; captured stack trace follows") - text.lineSequence().forEach { line -> - if (line.isNotBlank()) AppLogger.e(TAG, line) - } + text.lineSequence().forEach { line -> if (line.isNotBlank()) AppLogger.e(TAG, line) } } private fun writeCrashFile(context: Context, thread: Thread, throwable: Throwable) { @@ -41,7 +39,7 @@ object CrashReporter { pw.println("Thread: ${thread.name}") throwable.printStackTrace(pw) } - crashFile(context).writeText(writer.toString()) + crashFile(context).writeText(AppLogger.redact(writer.toString()).take(128 * 1024)) } private fun crashFile(context: Context): File = File(context.filesDir, CRASH_FILE_NAME) diff --git a/app/src/main/java/com/vauth/foxyvpn/data/CredentialKeys.kt b/app/src/main/java/com/vauth/foxyvpn/data/CredentialKeys.kt new file mode 100644 index 00000000..ba0d648b --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/data/CredentialKeys.kt @@ -0,0 +1,38 @@ +package com.vauth.foxyvpn.data + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +internal object CredentialKeys { + private val lock = Any() + + fun get(alias: String, create: Boolean): SecretKey = + synchronized(lock) { + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + val existing = store.getKey(alias, null) + if (existing != null) { + check(existing is SecretKey) { "Unexpected credential key type" } + return@synchronized existing + } + check(create && !store.containsAlias(alias)) { + "The credential encryption key is unavailable" + } + val generator = + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + generator.init( + KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setKeySize(256) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true) + .build() + ) + generator.generateKey() + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt b/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt index 4ca4f865..e7feda03 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt @@ -1,80 +1,132 @@ package com.vauth.foxyvpn.data +import java.net.URLEncoder +import java.security.MessageDigest +import java.util.concurrent.TimeUnit import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.Cookie +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient +import okhttp3.Protocol import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject -import java.net.URLEncoder -import java.security.MessageDigest -import java.util.concurrent.TimeUnit class FastlyChallengeError(message: String) : Exception(message) private const val SOLVE_TIMEOUT_SECONDS = 60L -private const val MAX_POST_BACK_ROUNDS = 3 +private const val MAX_POST_BACK_ROUNDS = 8 private const val SOLVER_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" private const val POW_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" -private const val HTML_ACCEPT = - "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" +private const val HTML_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" private val CHALLENGE_PREFIX_REGEX = Regex("/_fs-ch-[A-Za-z0-9]+") -private val INIT_CALL_REGEX = Regex("init\\((\\[[^\\]]*\\]),\\s*\"([^\"]+)\"\\s*,\\s*\"([^\"]+)\"") +private val INIT_CALL_REGEX = Regex("\\binit\\s*\\(\\s*\\[") class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { + private companion object { + val solverMutex = Mutex() + } + suspend fun solveAndInstall( challengedBody: String? = null, challengedOrigin: String? = null, - ) = withContext(Dispatchers.IO) { - if (!challengedBody.isNullOrEmpty() && looksLikeChallenge(challengedBody)) { - val origin = challengedOrigin?.takeIf { it.startsWith("https://") } - ?: baseOriginFromPrefix(challengedBody) - ?: throw FastlyChallengeError("challenge page did not contain a usable origin") - solveChallengePage(challengedBody, origin) - return@withContext - } - - var lastError: Exception? = null - for (base in listOf("https://api.accounts.firefox.com", "https://accounts.firefox.com")) { - for (userAgent in listOf(MOZILLA_VPN_USER_AGENT, SOLVER_USER_AGENT)) { - try { - val page = fetchPage(newSolverClient(SimpleCookieJar()), base + "/", userAgent) - if (!looksLikeChallenge(page)) continue - solveChallengePage(page, base, userAgent) - return@withContext - } catch (e: FastlyChallengeError) { - lastError = e + userAgent: String = SOLVER_USER_AGENT, + ) = + withTimeout(90_000L) { + solverMutex.withLock { + withContext(Dispatchers.IO) { + val origin = + (challengedOrigin ?: "https://api.accounts.firefox.com").toHttpUrl() + require( + origin.scheme == "https" && + origin.port == 443 && + origin.username.isEmpty() && + origin.password.isEmpty() && + origin.host in + setOf( + "vpn.mozilla.org", + "api.accounts.firefox.com", + "oauth.accounts.firefox.com", + "accounts.firefox.com", + ) + ) { + "Untrusted challenge origin" + } + cookieJar.syncFromWebView() + val base = "https://${origin.host}" + if (!challengedBody.isNullOrEmpty() && isChallengePage(challengedBody)) { + solveChallengePage(challengedBody, base, userAgent) + return@withContext + } + val bases = + listOf( + base, + "https://accounts.firefox.com", + "https://api.accounts.firefox.com", + ).distinct() + val candidateUrls = + bases.flatMap { baseHost -> + if (baseHost.contains("accounts.firefox.com") && !baseHost.contains("api.")) { + listOf("$baseHost/", "$baseHost/?context=fx_desktop_v3&service=sync&action=email") + } else { + listOf("$baseHost/") + } + } + var lastError: FastlyChallengeError? = null + for (candidateUrl in candidateUrls) { + val candidateBase = + if (candidateUrl.contains('?')) candidateUrl.substringBefore('?').trimEnd('/') + else candidateUrl.trimEnd('/') + for (candidateUA in listOf(userAgent, SOLVER_USER_AGENT, FirefoxAccountHeaders.userAgent).distinct()) { + try { + val page = + fetchText( + newSolverClient(SimpleCookieJar()), + candidateUrl, + userAgent = candidateUA, + allowChallenge = true, + ) + if (!isChallengePage(page)) continue + solveChallengePage(page, candidateBase, candidateUA) + return@withContext + } catch (error: FastlyChallengeError) { + lastError = error + } catch (e: Exception) { + lastError = FastlyChallengeError("Error probing $candidateUrl: ${e.message}") + } + } + } + throw lastError + ?: FastlyChallengeError("host did not serve a Fastly challenge page") } } } - throw lastError ?: FastlyChallengeError("host did not serve a Fastly challenge page") - } private fun baseOrigin(prefixUrl: String): String { val idx = prefixUrl.indexOf("/_fs-ch-") return if (idx > 0) prefixUrl.substring(0, idx) else prefixUrl } - private fun baseOriginFromPrefix(html: String): String? { - val absolute = Regex("https://[A-Za-z0-9.-]+/_fs-ch-[A-Za-z0-9]+").find(html)?.value - return absolute?.let { baseOrigin(it) } - } - private fun solvePow(base: String, targetHex: String): String? { - val target = try { - targetHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - } catch (e: Exception) { - return null - } + val target = + try { + targetHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } catch (e: Exception) { + return null + } if (target.size != 32) return null val digest = MessageDigest.getInstance("SHA-256") for (a in POW_ALPHABET) { @@ -87,99 +139,155 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { return null } - private fun fetchPage(client: OkHttpClient, url: String, userAgent: String): String { - val request = Request.Builder().url(url) - .header("User-Agent", userAgent) - .header("Accept", HTML_ACCEPT) - .build() - client.newCall(request).execute().use { response -> - return response.body?.string().orEmpty() - } - } - - private fun fetchText( + private suspend fun fetchText( client: OkHttpClient, url: String, - userAgent: String, accept: String = HTML_ACCEPT, + userAgent: String = SOLVER_USER_AGENT, + allowChallenge: Boolean = false, ): String { - val request = Request.Builder().url(url) - .header("User-Agent", userAgent) - .header("Accept", accept) - .build() - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) throw FastlyChallengeError("GET $url returned HTTP ${response.code}") - return response.body?.string().orEmpty() + val request = + Request.Builder() + .url(url) + .header("User-Agent", userAgent) + .header("Accept", accept) + .build() + client.newCall(request).awaitResponse().use { response -> + val text = response.body?.readBoundedText().orEmpty() + if (!response.isSuccessful && !(allowChallenge && isChallengePage(text))) + throw FastlyChallengeError("Challenge page returned HTTP ${response.code}") + return text } } - private fun looksLikeChallenge(body: String): Boolean { - if (body.length !in 1..2_000_000) return false - if (!CHALLENGE_PREFIX_REGEX.containsMatchIn(body)) return false - return body.contains("Client Challenge", ignoreCase = true) || - body.contains("script.js") || - body.contains("fst-post-back") - } + private fun isChallengePage(text: String): Boolean = + text.length in 1..2_000_000 && + CHALLENGE_PREFIX_REGEX.containsMatchIn(text) && + (text.contains("Client Challenge", ignoreCase = true) || + text.contains("script.js") || + text.contains("fst-post-back")) private fun parseChallengeInit(script: String): Pair { - val matches = INIT_CALL_REGEX.findAll(script).toList() - if (matches.isEmpty()) throw FastlyChallengeError("challenge init() call not found in script") - val last = matches.last() - val challenges = try { - JSONArray(last.groupValues[1]) - } catch (e: Exception) { - throw FastlyChallengeError("could not parse challenge list: ${e.message}") + for (match in INIT_CALL_REGEX.findAll(script).toList().asReversed()) { + val start = match.range.last + var depth = 0 + var quoted = false + var escaped = false + for (index in start until script.length) { + val char = script[index] + if (quoted) { + when { + escaped -> escaped = false + char == '\\' -> escaped = true + char == '"' -> quoted = false + } + continue + } + when (char) { + '"' -> quoted = true + '[' -> depth++ + ']' -> { + depth-- + if (depth != 0) continue + val tail = script.substring(index + 1).trimStart() + if (!tail.startsWith(",")) break + val args = tail.drop(1).trimStart() + if (!args.startsWith('"')) break + var end = 1 + var escape = false + while (end < args.length) { + val next = args[end] + if (!escape && next == '"') break + escape = !escape && next == '\\' + end++ + } + if ( + end >= args.length || + !args.substring(end + 1).trimStart().startsWith(",") + ) + break + try { + val challenges = JSONArray(script.substring(start, index + 1)) + val token = + JSONArray("[" + args.substring(0, end + 1) + "]").getString(0) + if ( + challenges.length() > 0 && + challenges.length() <= 16 && + token.isNotBlank() && + (0 until challenges.length()).all { + val item = challenges.optJSONObject(it) + item != null && + item.opt("ty") is String && + item.getString("ty").isNotBlank() + } + ) + return challenges to token + } catch (_: org.json.JSONException) { + break + } + break + } + } + } } - if (challenges.length() == 0) throw FastlyChallengeError("empty challenge list") - return challenges to last.groupValues[2] + throw FastlyChallengeError("Could not read the Fastly challenge") } - private fun fetchPat(client: OkHttpClient, prefixUrl: String, token: String, userAgent: String): String { + private suspend fun fetchPat( + client: OkHttpClient, + prefixUrl: String, + token: String, + userAgent: String, + ): String { val patUrl = "$prefixUrl/pat?token=${URLEncoder.encode(token, "UTF-8")}" - val request = Request.Builder() - .url(patUrl) - .header("Accept", "text/plain") - .header("Content-Type", "application/json") - .header("User-Agent", userAgent) - .header("Origin", baseOrigin(prefixUrl)) - .post(ByteArray(0).toRequestBody(null)) - .build() - client.newCall(request).execute().use { response -> + val request = + Request.Builder() + .url(patUrl) + .header("Accept", "text/plain") + .header("Content-Type", "application/json") + .header("User-Agent", userAgent) + .header("Origin", baseOrigin(prefixUrl)) + .post(ByteArray(0).toRequestBody(null)) + .build() + client.newCall(request).awaitResponse().use { response -> if (response.code == 400 || response.code == 401) return "" - if (!response.isSuccessful) throw FastlyChallengeError("PAT request returned HTTP ${response.code}") - val json = try { - JSONObject(response.body?.string().orEmpty()) - } catch (e: Exception) { - throw FastlyChallengeError("could not parse PAT response: ${e.message}") - } + if (!response.isSuccessful) + throw FastlyChallengeError("PAT request returned HTTP ${response.code}") + val json = + try { + JSONObject(response.body?.readBoundedText().orEmpty()) + } catch (e: Exception) { + throw FastlyChallengeError("could not parse PAT response: ${e.message}") + } val auth = json.optString("auth", "") if (auth.isEmpty()) throw FastlyChallengeError("empty PAT auth token") return auth } } - private fun clientMetricsAnswer(): JSONObject = JSONObject().apply { - put("ty", "clientmetrics") - put("webdriver", false) - put( - "bot_detection_result", - JSONObject().apply { - put("bot_detected", false) - put("bot_kind", JSONObject.NULL) - }, - ) - put( - "browser_metrics", - JSONObject().apply { - put("client_data", "{}") - put("error_trace", JSONObject.NULL) - }, - ) - put("detector_results", JSONObject()) - put("v", 2) - } + private fun clientMetricsAnswer(): JSONObject = + JSONObject().apply { + put("ty", "clientmetrics") + put("webdriver", false) + put( + "bot_detection_result", + JSONObject().apply { + put("bot_detected", false) + put("bot_kind", JSONObject.NULL) + }, + ) + put( + "browser_metrics", + JSONObject().apply { + put("client_data", "{}") + put("error_trace", JSONObject.NULL) + }, + ) + put("detector_results", JSONObject()) + put("v", 2) + } - private fun answerChallenge( + private suspend fun answerChallenge( client: OkHttpClient, prefixUrl: String, token: String, @@ -190,8 +298,11 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { return when (val type = challenge.optString("ty")) { "pow" -> { val base = data.optString("base", "") - val answer = solvePow(base, data.optString("hash", "")) - ?: throw FastlyChallengeError("no proof-of-work solution found for base $base") + val answer = + solvePow(base, data.optString("hash", "")) + ?: throw FastlyChallengeError( + "no proof-of-work solution found for base $base" + ) JSONObject().apply { put("ty", "pow") put("base", base) @@ -200,85 +311,171 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { put("expires", data.optString("expires", "")) } } - "pat" -> JSONObject().apply { - put("ty", "pat") - put("auth", fetchPat(client, prefixUrl, token, userAgent)) - } + "pat" -> + JSONObject().apply { + put("ty", "pat") + put("auth", fetchPat(client, prefixUrl, token, userAgent)) + } "clientmetrics" -> clientMetricsAnswer() - else -> throw FastlyChallengeError("unsupported Fastly challenge type '$type' (captcha cannot be solved automatically)") + "captcha" -> + throw FastlyChallengeError( + "Firefox Accounts requires a CAPTCHA. Sign in again to continue." + ) + else -> throw FastlyChallengeError("unsupported Fastly challenge type '$type'") } } private fun installChallengeCookies(solverJar: SimpleCookieJar) { val cookies = solverJar.snapshotAll() - if (cookies.isEmpty()) throw FastlyChallengeError("challenge completed but no cookies were issued") + if (cookies.isEmpty()) + throw FastlyChallengeError("challenge completed but no cookies were issued") + + val targetHosts = + listOf( + "api.accounts.firefox.com", + "accounts.firefox.com", + "oauth.accounts.firefox.com", + "vpn.mozilla.org", + ) + for (cookie in cookies) { - cookieJar.set(cookie.name, cookie.value, "firefox.com") + val hostClean = cookie.domain.trimStart('.') + val origin = + runCatching { + okhttp3.HttpUrl.Builder().scheme("https").host(hostClean).build() + }.getOrNull() + if (origin != null) { + cookieJar.saveFromResponse(origin, listOf(cookie)) + } + + val domainCookie = + if (hostClean.endsWith("firefox.com")) { + runCatching { + Cookie.Builder() + .name(cookie.name) + .value(cookie.value) + .domain("firefox.com") + .path("/") + .apply { if (cookie.secure) secure() } + .apply { if (cookie.httpOnly) httpOnly() } + .expiresAt(cookie.expiresAt) + .build() + }.getOrNull() + } else null + + if (domainCookie != null) { + val firefoxOrigin = + runCatching { + okhttp3.HttpUrl.Builder().scheme("https").host("firefox.com").build() + }.getOrNull() + if (firefoxOrigin != null) { + cookieJar.saveFromResponse(firefoxOrigin, listOf(domainCookie)) + } + } + + for (targetHost in targetHosts) { + val targetOrigin = + runCatching { + okhttp3.HttpUrl.Builder().scheme("https").host(targetHost).build() + }.getOrNull() ?: continue + val hostCookie = + runCatching { + Cookie.Builder() + .name(cookie.name) + .value(cookie.value) + .domain(targetHost) + .path("/") + .apply { if (cookie.secure) secure() } + .apply { if (cookie.httpOnly) httpOnly() } + .expiresAt(cookie.expiresAt) + .build() + }.getOrNull() + if (hostCookie != null) { + cookieJar.saveFromResponse(targetOrigin, listOf(hostCookie)) + } + } } + + cookieJar.syncToWebView(cookieJar.snapshotAll()) } - private fun solveChallengePage( - page: String, - origin: String, - userAgent: String = MOZILLA_VPN_USER_AGENT, - ) { + private fun newSolverClient(solverJar: SimpleCookieJar): OkHttpClient = + ControlPlaneHttp.client + .newBuilder() + .protocols(listOf(Protocol.HTTP_1_1)) + .cookieJar(solverJar) + .followRedirects(false) + .followSslRedirects(false) + .connectTimeout(SOLVE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(SOLVE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + + private suspend fun solveChallengePage(page: String, base: String, userAgent: String) { val solverJar = SimpleCookieJar() - for (cookie in cookieJar.snapshotAll()) { - solverJar.set(cookie.name, cookie.value, cookie.domain) - } val solver = newSolverClient(solverJar) + val pageUrl = "$base/" - val prefixMatch = CHALLENGE_PREFIX_REGEX.find(page) - ?: throw FastlyChallengeError("challenge asset prefix not found on $origin") - val prefixUrl = origin + prefixMatch.value + val prefixMatch = + CHALLENGE_PREFIX_REGEX.find(page) + ?: throw FastlyChallengeError("challenge asset prefix not found on $base") + val prefixUrl = base + prefixMatch.value - val script = fetchText(solver, "$prefixUrl/script.js?reload=true", userAgent) + val script = fetchText(solver, "$prefixUrl/script.js?reload=true", userAgent = userAgent) var (challenges, token) = parseChallengeInit(script) repeat(MAX_POST_BACK_ROUNDS) { val answers = JSONArray() for (i in 0 until challenges.length()) { - answers.put(answerChallenge(solver, prefixUrl, token, challenges.getJSONObject(i), userAgent)) + answers.put( + answerChallenge( + solver, + prefixUrl, + token, + challenges.getJSONObject(i), + userAgent, + ) + ) } - val postBody = JSONObject().apply { - put("token", token) - put("data", answers) - }.toString().toRequestBody("application/json".toMediaType()) - val request = Request.Builder() - .url("$prefixUrl/fst-post-back") - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .header("User-Agent", userAgent) - .header("Origin", origin) - .post(postBody) - .build() - solver.newCall(request).execute().use { response -> - if (!response.isSuccessful) throw FastlyChallengeError("challenge post-back returned HTTP ${response.code}") - val body = JSONObject(response.body?.string().orEmpty()) + val postBody = + JSONObject() + .apply { + put("token", token) + put("data", answers) + } + .toString() + .toRequestBody("application/json".toMediaType()) + val request = + Request.Builder() + .url("$prefixUrl/fst-post-back") + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("User-Agent", userAgent) + .header("Origin", baseOrigin(prefixUrl)) + .post(postBody) + .build() + solver.newCall(request).awaitResponse().use { response -> + if (!response.isSuccessful) + throw FastlyChallengeError("challenge post-back returned HTTP ${response.code}") + val body = JSONObject(response.body?.readBoundedText().orEmpty()) if (body.optString("status") == "success") { - val recheck = fetchPage(solver, "$origin/", userAgent) - if (looksLikeChallenge(recheck)) { + val verificationPage = + fetchText(solver, pageUrl, userAgent = userAgent, allowChallenge = true) + if (isChallengePage(verificationPage)) throw FastlyChallengeError("challenge cookie not accepted on this exit IP") - } installChallengeCookies(solverJar) return } val nextChallenges = body.optJSONArray("ch") val nextToken = body.optString("tok", "") if (nextChallenges == null || nextChallenges.length() == 0 || nextToken.isEmpty()) { - throw FastlyChallengeError("unexpected post-back response: $body") + throw FastlyChallengeError("unexpected challenge post-back response") } challenges = nextChallenges token = nextToken } } - throw FastlyChallengeError("Fastly challenge did not complete within $MAX_POST_BACK_ROUNDS rounds") + throw FastlyChallengeError( + "Fastly challenge did not complete within $MAX_POST_BACK_ROUNDS rounds" + ) } - - private fun newSolverClient(solverJar: SimpleCookieJar): OkHttpClient = - ControlPlaneHttp.client.newBuilder() - .cookieJar(solverJar) - .connectTimeout(SOLVE_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .readTimeout(SOLVE_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .build() } diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FirefoxAccountHeaders.kt b/app/src/main/java/com/vauth/foxyvpn/data/FirefoxAccountHeaders.kt new file mode 100644 index 00000000..ad25ca9d --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/data/FirefoxAccountHeaders.kt @@ -0,0 +1,59 @@ +package com.vauth.foxyvpn.data + +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject + +internal object FirefoxAccountHeaders { + private const val PRODUCT_DETAILS_URL = + "https://product-details.mozilla.org/1.0/firefox_versions.json" + private val mutex = Mutex() + @Volatile private var major = "135" + private var nextRefreshNanos: Long? = null + + val userAgent: String + get() = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:$major.0) Gecko/20100101 Firefox/$major.0" + + fun apply(builder: Request.Builder): Request.Builder { + return builder + .header("User-Agent", userAgent) + .header("Accept", "*/*") + } + + suspend fun refresh(client: OkHttpClient) { + mutex.withLock { + nextRefreshNanos?.let { if (System.nanoTime() - it < 0L) return } + val refreshed = + try { + withTimeoutOrNull(5_000L) { + val request = + apply(Request.Builder().url(PRODUCT_DETAILS_URL)).get().build() + client.newCall(request).awaitResponse().use { response -> + if (!response.isSuccessful) return@withTimeoutOrNull false + val text = + response.body?.readBoundedText(65_536) + ?: return@withTimeoutOrNull false + val version = + JSONObject(text).optString("LATEST_FIREFOX_VERSION").trim() + val candidate = version.substringBefore('.') + if (!candidate.matches(Regex("[0-9]{1,4}"))) + return@withTimeoutOrNull false + major = candidate + true + } + } ?: false + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + false + } + nextRefreshNanos = + System.nanoTime() + TimeUnit.HOURS.toNanos(if (refreshed) 24L else 1L) + } + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt b/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt index 19762fb2..b346b5e4 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt @@ -2,20 +2,26 @@ package com.vauth.foxyvpn.data import android.util.Base64 import com.vauth.foxyvpn.data.model.RuntimeAuth +import java.security.MessageDigest +import java.security.SecureRandom +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlin.coroutines.coroutineContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Protocol import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import org.json.JSONObject -import java.security.MessageDigest -import java.security.SecureRandom -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec private const val TAG = "FxaAuthRepository" @@ -28,18 +34,17 @@ enum class SessionStatus { class FxaApiError(message: String, val errno: Int?, val statusCode: Int?) : Exception(message) val FxaApiError.isUnconfirmedSession: Boolean - get() = errno == 104 || (statusCode == 400 && message?.contains("Unconfirmed session", ignoreCase = true) == true) + get() = + errno == 104 || + (statusCode == 400 && + message?.contains("Unconfirmed session", ignoreCase = true) == true) fun Throwable.isUnconfirmedSession(): Boolean = (this as? FxaApiError)?.isUnconfirmedSession == true || message?.contains("Unconfirmed session", ignoreCase = true) == true - -class FxaRefreshFailure( - message: String, - val permanent: Boolean, - cause: Throwable? = null, -) : Exception(message, cause) +class FxaRefreshFailure(message: String, val permanent: Boolean, cause: Throwable? = null) : + Exception(message, cause) class FastlyBlockedException(message: String, cause: Throwable? = null) : Exception(message, cause) @@ -48,7 +53,7 @@ private const val FXA_OAUTH_SERVER = "https://oauth.accounts.firefox.com/v1" private const val FIREFOX_CLIENT_ID = "5882386c6d801776" private const val OAUTH_SCOPE = "profile https://identity.mozilla.com/apps/vpn" private const val PROTOCOL_VERSION = "identity.mozilla.com/picl/v1/" -private const val FXA_MAX_CHALLENGE_ATTEMPTS = 3 +private const val FXA_MAX_CHALLENGE_ATTEMPTS = 5 private const val DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 24 * 60 * 60 private const val WEBCHANNEL_LOGIN_URL = @@ -58,24 +63,98 @@ private val jsonMediaType = "application/json".toMediaType() class FxaAuthRepository( private val tokenStore: TokenStore, - private val challengeSolver: FastlyChallengeSolver = FastlyChallengeSolver(ControlPlaneHttp.cookieJar), + private val challengeSolver: FastlyChallengeSolver = + FastlyChallengeSolver(ControlPlaneHttp.cookieJar), ) { + private val refreshMutex = Mutex() + private val loginMutex = Mutex() + private val client = + ControlPlaneHttp.client + .newBuilder() + .protocols(listOf(Protocol.HTTP_1_1)) + .followRedirects(false) + .followSslRedirects(false) + .build() + val storageError = tokenStore.storageError + fun getBrowserLoginUrl(): String = WEBCHANNEL_LOGIN_URL - suspend fun completeBrowserLogin(sessionToken: String): Result = runCatching { + fun buildOAuthHawkHeader(sessionToken: String): Pair { val clean = sessionToken.trim().replace(Regex("[\\s\"]"), "").lowercase() - if (!Regex("^[0-9a-f]{64}$").matches(clean)) { - error("The sign-in page did not hand over a usable session token") + require(Regex("^[0-9a-f]{64}$").matches(clean)) { + "The sign-in page did not hand over a usable session token" } - completeLogin(clean) + val url = "$FXA_AUTH_SERVER/oauth/token".toHttpUrl() + val body = + JSONObject().apply { + put("client_id", FIREFOX_CLIENT_ID) + put("grant_type", "fxa-credentials") + put("scope", OAUTH_SCOPE) + put("access_type", "offline") + } + val bodyStr = body.toString() + val (tokenId, hmacKey) = deriveHawkCredentials(clean) + val header = hawkHeader("POST", url, tokenId, hmacKey, bodyStr.toByteArray(Charsets.UTF_8)) + return header to bodyStr } - suspend fun restoreSession(): SessionStatus { - val stored = runCatching { tokenStore.loadAuth() }.getOrNull() - ?: return SessionStatus.NEEDS_LOGIN + suspend fun completeBrowserLoginWithTokens( + accessToken: String, + refreshToken: String?, + expiresInSeconds: Long, + ): Result = + withContext(Dispatchers.IO) { + val epoch = tokenStore.sessionEpoch + ControlPlaneHttp.cookieJar.syncFromWebView() + runCatching { + loginMutex.withLock { + check(tokenStore.sessionEpoch == epoch) { "Sign-in was superseded" } + val ttl = + if (expiresInSeconds > 0) expiresInSeconds + else DEFAULT_ACCESS_TOKEN_TTL_SECONDS.toLong() + tokenStore.completeLoginIfCurrent( + RuntimeAuth( + accessToken = accessToken.trim(), + refreshToken = refreshToken?.trim()?.ifBlank { null }, + expiresAtEpochSeconds = System.currentTimeMillis() / 1000 + ttl, + ), + epoch, + ) + Unit + } + } + .onFailure { if (it is CancellationException) throw it } + } + + suspend fun completeBrowserLogin(sessionToken: String): Result = + withContext(Dispatchers.IO) { + val epoch = tokenStore.sessionEpoch + ControlPlaneHttp.cookieJar.syncFromWebView() + runCatching { + loginMutex.withLock { + val clean = sessionToken.trim().replace(Regex("[\\s\"]"), "").lowercase() + require(Regex("^[0-9a-f]{64}$").matches(clean)) { + "The sign-in page did not hand over a usable session token" + } + check(tokenStore.sessionEpoch == epoch) { "Sign-in was superseded" } + FirefoxAccountHeaders.refresh(client) + completeLogin(clean, epoch) + } + } + .onFailure { if (it is CancellationException) throw it } + } + + suspend fun restoreSession(): SessionStatus = + withContext(Dispatchers.IO) { restoreSessionInternal() } + + private suspend fun restoreSessionInternal(): SessionStatus { + val epoch = tokenStore.sessionEpoch + val stored = + runCatching { tokenStore.loadAuth() }.getOrNull() ?: return SessionStatus.NEEDS_LOGIN if (stored.refreshToken == null && !tokenStore.hasValidAccessToken()) { - runCatching { tokenStore.clear() } - return SessionStatus.NEEDS_LOGIN + return if (tokenStore.clearIfCurrent(epoch)) SessionStatus.NEEDS_LOGIN + else if (tokenStore.hasValidAccessToken()) SessionStatus.ACTIVE + else SessionStatus.UNREACHABLE } if (tokenStore.hasValidAccessToken()) return SessionStatus.ACTIVE @@ -87,10 +166,15 @@ class FxaAuthRepository( } catch (failure: FxaRefreshFailure) { if (failure.permanent) { AppLogger.w(TAG, "FxA rejected the stored session; signing out", failure) - runCatching { tokenStore.clear() } - SessionStatus.NEEDS_LOGIN + if (tokenStore.clearIfCurrent(epoch)) SessionStatus.NEEDS_LOGIN + else if (tokenStore.hasValidAccessToken()) SessionStatus.ACTIVE + else SessionStatus.UNREACHABLE } else { - AppLogger.w(TAG, "could not renew the session right now; staying signed in", failure) + AppLogger.w( + TAG, + "could not renew the session right now; staying signed in", + failure, + ) SessionStatus.UNREACHABLE } } catch (unexpected: Exception) { @@ -99,107 +183,131 @@ class FxaAuthRepository( } } - suspend fun ensureFreshAccessToken(force: Boolean = false): RuntimeAuth { - val current = tokenStore.loadAuth() - ?: throw FxaRefreshFailure("Not signed in", permanent = true) - if (!force && tokenStore.hasValidAccessToken()) return current + suspend fun ensureFreshAccessToken(force: Boolean = false): RuntimeAuth = + withContext(Dispatchers.IO) { ensureFreshAccessTokenInternal(force) } + + private suspend fun ensureFreshAccessTokenInternal(force: Boolean): RuntimeAuth = + refreshMutex.withLock { + val epoch = tokenStore.sessionEpoch + val current = + tokenStore.loadAuth() + ?: run { + tokenStore.storageError.value?.let { + throw FxaRefreshFailure(it, permanent = false) + } + throw FxaRefreshFailure("Not signed in", permanent = true) + } + if (!force && tokenStore.hasValidAccessToken()) return@withLock current + + val refreshToken = + current.refreshToken + ?: throw FxaRefreshFailure( + "The stored session has no refresh token; sign in again.", + permanent = true, + ) + + val body = + JSONObject().apply { + put("client_id", FIREFOX_CLIENT_ID) + put("grant_type", "refresh_token") + put("refresh_token", refreshToken) + put("scope", OAUTH_SCOPE) + } - val refreshToken = current.refreshToken - ?: throw FxaRefreshFailure( - "The stored session has no refresh token; sign in again.", - permanent = true, - ) + val tokenData = + try { + FirefoxAccountHeaders.refresh(client) + executeFxaRequest(FXA_OAUTH_SERVER, "/oauth/token", jsonBody = body) + } catch (rejected: FxaApiError) { + throw FxaRefreshFailure( + rejected.message ?: "FxA rejected the refresh token", + permanent = rejected.isPermanentRefreshRejection(), + cause = rejected, + ) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (transport: Exception) { + throw FxaRefreshFailure( + transport.message ?: "Could not reach the Firefox Accounts server", + permanent = false, + cause = transport, + ) + } - val body = JSONObject().apply { - put("client_id", FIREFOX_CLIENT_ID) - put("grant_type", "refresh_token") - put("refresh_token", refreshToken) - put("scope", OAUTH_SCOPE) + val accessToken = + tokenData.optString("access_token").takeIf { it.isNotBlank() } + ?: throw FxaRefreshFailure( + "FxA returned no access token for the refresh grant", + permanent = false, + ) + + val renewed = + RuntimeAuth( + accessToken = accessToken, + refreshToken = + tokenData.optString("refresh_token", "").ifBlank { null } ?: refreshToken, + expiresAtEpochSeconds = expiryFrom(tokenData), + ) + tokenStore.saveAuthIfCurrent(renewed, epoch) + renewed } - val tokenData = try { - executeFxaRequest( - baseUrl = FXA_OAUTH_SERVER, - path = "/oauth/token", - jsonBody = body, - ) - } catch (rejected: FxaApiError) { - throw FxaRefreshFailure( - rejected.message ?: "FxA rejected the refresh token", - permanent = rejected.isPermanentRefreshRejection(), - cause = rejected, - ) + suspend fun currentAccessToken(): String? = + withContext(Dispatchers.IO) { + try { + ensureFreshAccessToken().accessToken + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: FxaRefreshFailure) { + if (!failure.permanent && tokenStore.hasValidAccessToken()) + tokenStore.loadAuth()?.accessToken + else null + } + } + + suspend fun refreshAccessToken(): RuntimeAuth? = + try { + ensureFreshAccessToken(force = true) } catch (cancellation: CancellationException) { throw cancellation - } catch (transport: Exception) { - throw FxaRefreshFailure( - transport.message ?: "Could not reach the Firefox Accounts server", - permanent = false, - cause = transport, - ) + } catch (failure: FxaRefreshFailure) { + AppLogger.w(TAG, "access token renewal failed", failure) + null } - val accessToken = tokenData.optString("access_token").takeIf { it.isNotBlank() } - ?: throw FxaRefreshFailure( - "FxA returned no access token for the refresh grant", - permanent = false, - ) - - val renewed = RuntimeAuth( - accessToken = accessToken, - refreshToken = tokenData.optString("refresh_token", "").ifBlank { null } ?: refreshToken, - expiresAtEpochSeconds = expiryFrom(tokenData), - ) - runCatching { tokenStore.saveAuth(renewed) } - .onFailure { AppLogger.w(TAG, "could not persist the renewed token", it) } - return renewed - } - - suspend fun currentAccessToken(): String? = try { - ensureFreshAccessToken().accessToken - } catch (cancellation: CancellationException) { - throw cancellation - } catch (failure: FxaRefreshFailure) { - if (failure.permanent) null else tokenStore.loadAuth()?.accessToken - } - - suspend fun refreshAccessToken(): RuntimeAuth? = try { - ensureFreshAccessToken(force = true) - } catch (cancellation: CancellationException) { - throw cancellation - } catch (failure: FxaRefreshFailure) { - AppLogger.w(TAG, "access token renewal failed", failure) - null - } - private fun FxaApiError.isPermanentRefreshRejection(): Boolean { return statusCode == 400 || statusCode == 401 || statusCode == 403 } private fun expiryFrom(tokenData: JSONObject): Long { - val expiresIn = tokenData.optInt("expires_in", 0) - .takeIf { it > 0 } ?: DEFAULT_ACCESS_TOKEN_TTL_SECONDS + val expiresIn = + tokenData.optInt("expires_in", 0).takeIf { it > 0 } ?: DEFAULT_ACCESS_TOKEN_TTL_SECONDS return System.currentTimeMillis() / 1000 + expiresIn } - private suspend fun completeLogin(sessionToken: String) { + private suspend fun completeLogin(sessionToken: String, epoch: Long) { val tokenData = oauthToken(sessionToken) - tokenStore.saveAuth( + coroutineContext.ensureActive() + tokenStore.completeLoginIfCurrent( RuntimeAuth( - accessToken = tokenData.getString("access_token"), + accessToken = + tokenData.optString("access_token").takeIf { it.isNotBlank() } + ?: error("Firefox Accounts returned no access token"), refreshToken = tokenData.optString("refresh_token", "").ifBlank { null }, expiresAtEpochSeconds = expiryFrom(tokenData), ), + epoch, ) } private suspend fun oauthToken(sessionToken: String): JSONObject { - val body = JSONObject().apply { - put("client_id", FIREFOX_CLIENT_ID) - put("grant_type", "fxa-credentials") - put("scope", OAUTH_SCOPE) - put("access_type", "offline") - } + val body = + JSONObject().apply { + put("client_id", FIREFOX_CLIENT_ID) + put("grant_type", "fxa-credentials") + put("scope", OAUTH_SCOPE) + put("access_type", "offline") + } return executeFxaRequest( baseUrl = FXA_AUTH_SERVER, path = "/oauth/token", @@ -214,66 +322,77 @@ class FxaAuthRepository( method: String = "POST", jsonBody: JSONObject, sessionToken: String? = null, - ): JSONObject = withContext(Dispatchers.IO) { - val url = "$baseUrl$path".toHttpUrl() - val origin = "${url.scheme}://${url.host}" - val bodyBytes = jsonBody.toString().toByteArray(Charsets.UTF_8) - var tokenId: String? = null - var hmacKey: ByteArray? = null - if (sessionToken != null) { - val (id, key) = deriveHawkCredentials(sessionToken) - tokenId = id - hmacKey = key - } - - fun buildRequest(): Request { - val builder = Request.Builder().url(url).applyMozillaVpnHeaders() - .header("Content-Type", "application/json") - if (sessionToken != null) { - builder.header("Authorization", hawkHeader(method, url, tokenId!!, hmacKey!!, bodyBytes)) - } - builder.method(method, bodyBytes.toRequestBody(jsonMediaType)) - return builder.build() - } - - var response: Response = ControlPlaneHttp.client.newCall(buildRequest()).execute() - var attempts = 1 - while (response.code == 406 && attempts < FXA_MAX_CHALLENGE_ATTEMPTS) { - val challengedBody = response.use { it.body?.string().orEmpty() } - try { - challengeSolver.solveAndInstall(challengedBody, origin) - } catch (cancellation: CancellationException) { - throw cancellation - } catch (solveFailure: Exception) { - AppLogger.w(TAG, "could not solve the Fastly challenge", solveFailure) - throw FastlyBlockedException(FRIENDLY_406_MESSAGE, solveFailure) - } - response = ControlPlaneHttp.client.newCall(buildRequest()).execute() - attempts++ - } - - response.use { resp -> - val text = resp.body?.string().orEmpty() - if (resp.code >= 400) { - if (resp.code == 406) { - throw FastlyBlockedException(FRIENDLY_406_MESSAGE) + ): JSONObject = + withTimeout(120_000L) { + withContext(Dispatchers.IO) { + val url = "$baseUrl$path".toHttpUrl() + val origin = "${url.scheme}://${url.host}" + val bodyBytes = jsonBody.toString().toByteArray(Charsets.UTF_8) + val hawkCreds = sessionToken?.let { deriveHawkCredentials(it) } + + fun buildRequest(): Request { + val builder = + Request.Builder() + .url(url) + .applyFirefoxHeaders() + .header("Content-Type", "application/json") + if (hawkCreds != null) { + builder.header( + "Authorization", + hawkHeader(method, url, hawkCreds.first, hawkCreds.second, bodyBytes), + ) + } + builder.method(method, bodyBytes.toRequestBody(jsonMediaType)) + return builder.build() } - val errorBody = try { - JSONObject(text) - } catch (e: Exception) { - JSONObject() + + var response: Response = client.newCall(buildRequest()).awaitResponse() + var attempts = 1 + while (response.code == 406 && attempts < FXA_MAX_CHALLENGE_ATTEMPTS) { + val challengedBody = response.use { it.body?.readBoundedText().orEmpty() } + try { + ControlPlaneHttp.cookieJar.syncFromWebView() + challengeSolver.solveAndInstall( + challengedBody, + origin, + FirefoxAccountHeaders.userAgent, + ) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (solveFailure: Exception) { + AppLogger.w(TAG, "could not solve the Fastly challenge", solveFailure) + throw FastlyBlockedException(FRIENDLY_406_MESSAGE, solveFailure) + } + response = client.newCall(buildRequest()).awaitResponse() + attempts++ } - val errno = if (errorBody.has("errno")) errorBody.optInt("errno") else null - val message = when { - errorBody.length() > 0 -> errorBody.optString("message", "HTTP ${resp.code}") - text.isEmpty() -> "HTTP ${resp.code}" - else -> text.take(200) + + response.use { resp -> + val text = resp.body?.readBoundedText().orEmpty() + if (resp.code >= 400) { + if (resp.code == 406) { + throw FastlyBlockedException(FRIENDLY_406_MESSAGE) + } + val errorBody = + try { + JSONObject(text) + } catch (e: Exception) { + JSONObject() + } + val errno = if (errorBody.has("errno")) errorBody.optInt("errno") else null + val message = + when { + errorBody.length() > 0 -> + errorBody.optString("message", "HTTP ${resp.code}") + text.isEmpty() -> "HTTP ${resp.code}" + else -> text.take(200) + } + throw FxaApiError(message, errno, resp.code) + } + if (text.isEmpty()) JSONObject() else JSONObject(text) } - throw FxaApiError(message, errno, resp.code) } - if (text.isEmpty()) JSONObject() else JSONObject(text) } - } private fun deriveHawkCredentials(sessionTokenHex: String): Pair { val sessionToken = sessionTokenHex.hexToBytes() @@ -281,10 +400,20 @@ class FxaAuthRepository( return expanded.copyOfRange(0, 32).toHex() to expanded.copyOfRange(32, 64) } - private fun hawkHeader(method: String, url: HttpUrl, tokenId: String, hmacKey: ByteArray, body: ByteArray): String { + private fun hawkHeader( + method: String, + url: HttpUrl, + tokenId: String, + hmacKey: ByteArray, + body: ByteArray, + ): String { val ts = (System.currentTimeMillis() / 1000).toString() val nonceBytes = ByteArray(6).also { SecureRandom().nextBytes(it) } - val nonce = Base64.encodeToString(nonceBytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + val nonce = + Base64.encodeToString( + nonceBytes, + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP, + ) val path = url.encodedPath + if (url.encodedQuery != null) "?${url.encodedQuery}" else "" var payloadHash = "" @@ -296,10 +425,15 @@ class FxaAuthRepository( payloadHash = Base64.encodeToString(digest.digest(), Base64.NO_WRAP) } - val normalized = "hawk.1.header\n$ts\n$nonce\n${method.uppercase()}\n$path\n${url.host}\n${url.port}\n$payloadHash\n\n" + val normalized = + "hawk.1.header\n$ts\n$nonce\n${method.uppercase()}\n$path\n${url.host}\n${url.port}\n$payloadHash\n\n" val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(hmacKey, "HmacSHA256")) } - val macB64 = Base64.encodeToString(mac.doFinal(normalized.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP) + val macB64 = + Base64.encodeToString( + mac.doFinal(normalized.toByteArray(Charsets.UTF_8)), + Base64.NO_WRAP, + ) var header = "Hawk id=\"$tokenId\", ts=\"$ts\", nonce=\"$nonce\", mac=\"$macB64\"" if (payloadHash.isNotEmpty()) header += ", hash=\"$payloadHash\"" @@ -318,7 +452,12 @@ private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray { return mac.doFinal(data) } -private fun hkdf(ikm: ByteArray, info: String, length: Int, salt: ByteArray = ByteArray(32)): ByteArray { +private fun hkdf( + ikm: ByteArray, + info: String, + length: Int, + salt: ByteArray = ByteArray(32), +): ByteArray { val prk = hmacSha256(salt, ikm) val infoBytes = info.toByteArray(Charsets.UTF_8) val result = ByteArray(length) @@ -337,4 +476,5 @@ private fun hkdf(ikm: ByteArray, info: String, length: Int, salt: ByteArray = By } private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray() diff --git a/app/src/main/java/com/vauth/foxyvpn/data/GuardianClient.kt b/app/src/main/java/com/vauth/foxyvpn/data/GuardianClient.kt index 47442d82..d17b62d0 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/GuardianClient.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/GuardianClient.kt @@ -5,6 +5,7 @@ import com.vauth.foxyvpn.data.model.Entitlement import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody @@ -14,9 +15,21 @@ import org.json.JSONObject const val GUARDIAN_ENDPOINT_DEFAULT = "https://vpn.mozilla.org" private const val TAG = "GuardianClient" private const val GUARDIAN_MAX_CHALLENGE_ATTEMPTS = 5 +private const val GUARDIAN_USER_AGENT = "MozillaVPN/2.35.0 (sys:android 14; iap:true)" + +class GuardianHttpError( + message: String, + val statusCode: Int? = null, + val retryAfterMs: Long? = null, +) : Exception(message) + +class QuotaExceededError( + message: String, + val quotaMax: Long? = null, + val quotaRemaining: Long? = 0L, + val quotaReset: Long? = null, +) : Exception(message) -class GuardianHttpError(message: String, val statusCode: Int? = null) : Exception(message) -class QuotaExceededError(message: String) : Exception(message) class TokenInvalidError(message: String) : Exception(message) data class ProxyPass( @@ -25,96 +38,192 @@ data class ProxyPass( val quotaMax: Long? = null, val quotaRemaining: Long? = null, val quotaReset: Long? = null, -) +) { + override fun toString() = "ProxyPass(expiresAtEpochSeconds=$expiresAtEpochSeconds)" +} class GuardianClient( - private val challengeSolver: FastlyChallengeSolver = FastlyChallengeSolver(ControlPlaneHttp.cookieJar), + private val challengeSolver: FastlyChallengeSolver = + FastlyChallengeSolver(ControlPlaneHttp.cookieJar) ) { - suspend fun fetchProxyPass(endpoint: String, accessToken: String): ProxyPass = withContext(Dispatchers.IO) { - val url = "${endpoint.trimEnd('/')}/api/v1/fpn/token" - authorizedRequest("GET", url, accessToken).use { resp -> - when (resp.code) { - 401, 403 -> throw TokenInvalidError("access token was rejected by Guardian") - 429 -> throw QuotaExceededError("account proxy quota exceeded") - } - val text = resp.body?.string().orEmpty() - if (resp.code != 200) { - throw GuardianHttpError("failed to fetch proxy pass: HTTP ${resp.code}: ${text.take(2048)}", resp.code) + suspend fun fetchProxyPass(endpoint: String, accessToken: String): ProxyPass = + withTimeout(120_000L) { + withContext(Dispatchers.IO) { + val url = "${endpoint.trimEnd('/')}/api/v1/fpn/token" + authorizedRequest("GET", url, accessToken).use { resp -> + when (resp.code) { + 401, + 403 -> throw TokenInvalidError("access token was rejected by Guardian") + 429 -> { + val remaining = resp.header("X-Quota-Remaining")?.trim()?.toLongOrNull() + ?: resp.header("Quota-Remaining")?.trim()?.toLongOrNull() + if (remaining != null && remaining <= 0L) { + throw QuotaExceededError( + "account proxy quota exceeded", + quotaMax = resp.header("X-Quota-Limit")?.trim()?.toLongOrNull() + ?: resp.header("Quota-Limit")?.trim()?.toLongOrNull(), + quotaRemaining = 0L, + quotaReset = resp.header("X-Quota-Reset")?.trim()?.toLongOrNull() + ?: resp.header("Quota-Reset")?.trim()?.toLongOrNull(), + ) + } else { + throw GuardianHttpError( + "Guardian rate limited the request; retry later", + 429, + retryAfterMillis(resp.header("Retry-After")), + ) + } + } + } + val text = resp.body?.readBoundedText().orEmpty() + if (resp.code != 200) { + throw GuardianHttpError( + "failed to fetch proxy pass: HTTP ${resp.code}", + resp.code, + retryAfterMillis(resp.header("Retry-After")), + ) + } + val body = JSONObject(text) + val token = body.optString("token", "") + if (token.isBlank()) + throw GuardianHttpError("proxy pass response did not contain a valid token") + val expiresAt = + body.optLong("expires_at", 0L).takeIf { it > 0 } + ?: jwtExpiryEpochSeconds(token) + + ProxyPass( + token = token, + expiresAtEpochSeconds = expiresAt, + quotaMax = resp.header("X-Quota-Limit")?.trim()?.toLongOrNull() + ?: resp.header("Quota-Limit")?.trim()?.toLongOrNull(), + quotaRemaining = resp.header("X-Quota-Remaining")?.trim()?.toLongOrNull() + ?: resp.header("Quota-Remaining")?.trim()?.toLongOrNull(), + quotaReset = resp.header("X-Quota-Reset")?.trim()?.toLongOrNull() + ?: resp.header("Quota-Reset")?.trim()?.toLongOrNull(), + ) + } } - val body = JSONObject(text) - val token = body.optString("token", "") - if (token.isEmpty()) throw GuardianHttpError("proxy pass response did not contain a token") - ProxyPass( - token = token, - expiresAtEpochSeconds = body.optLong("expires_at", 0L).takeIf { it > 0 } - ?: jwtExpiryEpochSeconds(token), - quotaMax = resp.header("X-Quota-Limit")?.toLongOrNull(), - quotaRemaining = resp.header("X-Quota-Remaining")?.toLongOrNull(), - quotaReset = resp.header("X-Quota-Reset")?.toLongOrNull(), - ) } - } - suspend fun fetchUserInfo(endpoint: String, accessToken: String): Entitlement = withContext(Dispatchers.IO) { - val url = "${endpoint.trimEnd('/')}/api/v1/fpn/status" - val entitlement = authorizedRequest("GET", url, accessToken).use { resp -> - val text = resp.body?.string().orEmpty() - if (resp.code != 200) { - throw GuardianHttpError("failed to fetch account info: HTTP ${resp.code}: ${text.take(2048)}", resp.code) + suspend fun fetchUserInfo(endpoint: String, accessToken: String): Entitlement = + withTimeout(120_000L) { + withContext(Dispatchers.IO) { + var pass: ProxyPass? = null + var quotaExceeded: QuotaExceededError? = null + var passFailure: Exception? = null + try { + pass = fetchProxyPass(endpoint, accessToken) + } catch (invalid: TokenInvalidError) { + runCatching { activateGuardian(endpoint, accessToken) } + pass = runCatching { fetchProxyPass(endpoint, accessToken) }.getOrNull() + } catch (exceeded: QuotaExceededError) { + quotaExceeded = exceeded + } catch (e: Exception) { + passFailure = e + AppLogger.w(TAG, "could not fetch proxy pass for quota", e) + } + + val statusUrl = "${endpoint.trimEnd('/')}/api/v1/fpn/status" + val statusEntitlement = + runCatching { + authorizedRequest("GET", statusUrl, accessToken).use { resp -> + if (resp.code == 200) { + val text = resp.body?.readBoundedText().orEmpty() + parseEntitlement(JSONObject(text)) + } else null + } + }.getOrNull() + + val remaining = + pass?.quotaRemaining + ?: quotaExceeded?.quotaRemaining + ?: statusEntitlement?.quotaRemaining + val max = + pass?.quotaMax + ?: quotaExceeded?.quotaMax + ?: statusEntitlement?.maxBytes + + if (pass == null && quotaExceeded == null && statusEntitlement == null) { + if (passFailure != null) throw passFailure + throw GuardianHttpError("could not obtain entitlement from Guardian") + } + + val limited = + if (remaining != null || max != null) true + else (statusEntitlement?.limitedBandwidth ?: true) + + Entitlement( + maxBytes = max, + limitedBandwidth = limited, + quotaRemaining = remaining, + ) } - parseEntitlement(JSONObject(text)) } - if (entitlement.limitedBandwidth) { - val pass = runCatching { fetchProxyPass(endpoint, accessToken) }.getOrNull() - entitlement.copy(quotaRemaining = pass?.quotaRemaining ?: entitlement.quotaRemaining) - } else { - entitlement + suspend fun activateGuardian(endpoint: String, accessToken: String): Entitlement = + withTimeout(120_000L) { + withContext(Dispatchers.IO) { + val url = "${endpoint.trimEnd('/')}/api/v1/fpn/activate" + authorizedRequest("POST", url, accessToken).use { resp -> + val text = resp.body?.readBoundedText().orEmpty() + if (resp.code != 200) { + throw GuardianHttpError( + "failed to activate guardian entitlement: HTTP ${resp.code}", + resp.code, + retryAfterMillis(resp.header("Retry-After")), + ) + } + parseEntitlement(JSONObject(text)) + } + } } - } - suspend fun activateGuardian(endpoint: String, accessToken: String): Entitlement = withContext(Dispatchers.IO) { - val url = "${endpoint.trimEnd('/')}/api/v1/fpn/activate" - authorizedRequest("POST", url, accessToken).use { resp -> - val text = resp.body?.string().orEmpty() - if (resp.code != 200) { - throw GuardianHttpError("failed to activate guardian entitlement: HTTP ${resp.code}: ${text.take(2048)}", resp.code) - } - parseEntitlement(JSONObject(text)) + private fun parseEntitlement(body: JSONObject): Entitlement { + val max = when { + body.has("maxBytes") && !body.isNull("maxBytes") -> body.optLong("maxBytes", -1L).takeIf { it >= 0 } + body.has("max_bytes") && !body.isNull("max_bytes") -> body.optLong("max_bytes", -1L).takeIf { it >= 0 } + body.has("quota_limit") && !body.isNull("quota_limit") -> body.optLong("quota_limit", -1L).takeIf { it >= 0 } + else -> null + } + val remaining = when { + body.has("quota_remaining") && !body.isNull("quota_remaining") -> body.optLong("quota_remaining", -1L).takeIf { it >= 0 } + body.has("quotaRemaining") && !body.isNull("quotaRemaining") -> body.optLong("quotaRemaining", -1L).takeIf { it >= 0 } + else -> null } + val limited = body.optBoolean("limited_bandwidth", body.optBoolean("limitedBandwidth", max != null || remaining != null)) + return Entitlement( + maxBytes = max, + limitedBandwidth = limited, + quotaRemaining = remaining, + ) } - private fun parseEntitlement(body: JSONObject): Entitlement = Entitlement( - subscribed = body.optBoolean("subscribed", false), - uid = body.optString("uid", ""), - - maxBytes = if (body.has("maxBytes") && !body.isNull("maxBytes")) { - body.optLong("maxBytes", -1L).takeIf { it >= 0 } - } else { - null - }, - limitedBandwidth = body.optBoolean("limited_bandwidth", false), - ) - - private suspend fun authorizedRequest(method: String, url: String, accessToken: String): Response { - val origin = runCatching { url.toHttpUrl() }.getOrNull() - ?.let { "${it.scheme}://${it.host}" } - ?: url + private suspend fun authorizedRequest( + method: String, + url: String, + accessToken: String, + ): Response { + val origin = url.toHttpUrl().let { "${it.scheme}://${it.host}" } fun buildRequest(): Request { - val builder = Request.Builder().url(url).applyMozillaVpnHeaders() - builder.header("Content-Type", "application/json") - builder.header("Authorization", "Bearer $accessToken") + val builder = + Request.Builder() + .url(url) + .header("User-Agent", GUARDIAN_USER_AGENT) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .header("Cache-Control", "max-age=0") + .header("Authorization", "Bearer $accessToken") if (method == "POST") builder.post(ByteArray(0).toRequestBody(null)) return builder.build() } - var response = ControlPlaneHttp.client.newCall(buildRequest()).execute() + var response = ControlPlaneHttp.client.newCall(buildRequest()).awaitResponse() var attempts = 1 while (response.code == 406 && attempts < GUARDIAN_MAX_CHALLENGE_ATTEMPTS) { - val challengedBody = response.use { it.body?.string().orEmpty() } + val challengedBody = response.use { it.body?.readBoundedText().orEmpty() } try { - challengeSolver.solveAndInstall(challengedBody, origin) + challengeSolver.solveAndInstall(challengedBody, origin, GUARDIAN_USER_AGENT) } catch (cancellation: CancellationException) { throw cancellation } catch (solveFailure: Exception) { @@ -125,7 +234,7 @@ class GuardianClient( 406, ) } - response = ControlPlaneHttp.client.newCall(buildRequest()).execute() + response = ControlPlaneHttp.client.newCall(buildRequest()).awaitResponse() attempts++ } return response @@ -135,13 +244,18 @@ class GuardianClient( internal fun jwtExpiryEpochSeconds(token: String): Long? { val parts = token.split('.') if (parts.size < 2) return null - val payload = runCatching { - var b64 = parts[1] - while (b64.length % 4 != 0) b64 += "=" - String( - android.util.Base64.decode(b64, android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP), - Charsets.UTF_8, - ) - }.getOrNull() ?: return null + val payload = + runCatching { + var b64 = parts[1] + while (b64.length % 4 != 0) b64 += "=" + String( + android.util.Base64.decode( + b64, + android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP, + ), + Charsets.UTF_8, + ) + } + .getOrNull() ?: return null return runCatching { JSONObject(payload).optLong("exp", 0L) }.getOrNull()?.takeIf { it > 0 } } diff --git a/app/src/main/java/com/vauth/foxyvpn/data/HttpCall.kt b/app/src/main/java/com/vauth/foxyvpn/data/HttpCall.kt new file mode 100644 index 00000000..563e73f5 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/data/HttpCall.kt @@ -0,0 +1,23 @@ +package com.vauth.foxyvpn.data + +import java.io.IOException +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Response + +internal suspend fun Call.awaitResponse(): Response = suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cancel() } + enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (!continuation.isCancelled) continuation.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) { _, unclaimed, _ -> unclaimed.close() } + } + } + ) +} diff --git a/app/src/main/java/com/vauth/foxyvpn/data/InstalledApps.kt b/app/src/main/java/com/vauth/foxyvpn/data/InstalledApps.kt new file mode 100644 index 00000000..84dee48b --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/data/InstalledApps.kt @@ -0,0 +1,103 @@ +package com.vauth.foxyvpn.data + +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.os.SystemClock +import java.text.Collator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +internal data class InstalledApp( + val packageName: String, + val label: String, + val isSystem: Boolean, + val isLaunchable: Boolean, +) + +private val cacheMutex = Mutex() +@Volatile private var cachedApps: List? = null +@Volatile private var cachedAtElapsedMs = 0L +private const val CACHE_TTL_MS = 300_000L + +internal fun invalidateInstalledAppsCache() { + cachedApps = null + cachedAtElapsedMs = 0L +} + +internal suspend fun loadInstalledApps(context: Context, forceReload: Boolean = false): List = + withContext(Dispatchers.IO) { + val now = SystemClock.elapsedRealtime() + val cached = cachedApps + if (!forceReload && cached != null && (now - cachedAtElapsedMs < CACHE_TTL_MS)) { + return@withContext cached + } + cacheMutex.withLock { + val lockedNow = SystemClock.elapsedRealtime() + val lockedCached = cachedApps + if (!forceReload && lockedCached != null && (lockedNow - cachedAtElapsedMs < CACHE_TTL_MS)) { + return@withLock lockedCached + } + val pm = context.applicationContext.packageManager + val launchablePackages = + pm.queryIntentActivities( + Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), + 0, + ) + .mapTo(hashSetOf()) { it.activityInfo.packageName } + val coroutineContext = currentCoroutineContext() + val apps = + pm.getInstalledApplications(0) + .asSequence() + .filter { it.packageName != context.packageName } + .distinctBy { it.packageName } + .map { info -> + coroutineContext.ensureActive() + val label = + try { + info.loadLabel(pm).toString().ifBlank { info.packageName } + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { + info.packageName + } + InstalledApp( + packageName = info.packageName, + label = label, + isSystem = + info.flags and + (ApplicationInfo.FLAG_SYSTEM or + ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0, + isLaunchable = info.packageName in launchablePackages, + ) + } + .toList() + val collator = Collator.getInstance() + val sorted = apps.sortedWith { first, second -> + collator.compare(first.label, second.label).takeIf { it != 0 } + ?: first.packageName.compareTo(second.packageName) + } + cachedApps = sorted + cachedAtElapsedMs = lockedNow + sorted + } + } + +internal fun filterInstalledApps( + apps: List, + query: String, + showSystemApps: Boolean, + selected: Set, +): List { + val search = query.trim() + return apps.filter { app -> + (showSystemApps || !app.isSystem || app.isLaunchable || app.packageName in selected) && + (search.isEmpty() || + app.label.contains(search, ignoreCase = true) || + app.packageName.contains(search, ignoreCase = true)) + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/data/LegacyCredentialReader.kt b/app/src/main/java/com/vauth/foxyvpn/data/LegacyCredentialReader.kt new file mode 100644 index 00000000..e2114ada --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/data/LegacyCredentialReader.kt @@ -0,0 +1,25 @@ +package com.vauth.foxyvpn.data + +import android.content.Context +import android.content.SharedPreferences + +internal object LegacyCredentialReader { + @Suppress("DEPRECATION") + fun open(context: Context, name: String): SharedPreferences { + CredentialKeys.get( + androidx.security.crypto.MasterKey.DEFAULT_MASTER_KEY_ALIAS, + create = false, + ) + val masterKey = + androidx.security.crypto.MasterKey.Builder(context) + .setKeyScheme(androidx.security.crypto.MasterKey.KeyScheme.AES256_GCM) + .build() + return androidx.security.crypto.EncryptedSharedPreferences.create( + context, + name, + masterKey, + androidx.security.crypto.EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + androidx.security.crypto.EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/data/ProxySettingsRepository.kt b/app/src/main/java/com/vauth/foxyvpn/data/ProxySettingsRepository.kt new file mode 100644 index 00000000..3ca1a492 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/data/ProxySettingsRepository.kt @@ -0,0 +1,84 @@ +package com.vauth.foxyvpn.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class LocalProxyOptions( + val port: Int, + val authentication: Boolean, + val username: String, + val password: String, +) { + override fun toString() = "LocalProxyOptions(port=$port, authentication=$authentication)" +} + +class UpstreamProxyOptions( + val enabled: Boolean, + val type: SettingsStore.UpstreamProxyType, + val host: String, + val port: Int, + val username: String, + val password: String, +) { + override fun toString() = "UpstreamProxyOptions(enabled=$enabled, type=$type)" +} + +data class ProxySummary( + val localPort: Int, + val upstreamEnabled: Boolean, + val upstreamType: SettingsStore.UpstreamProxyType, + val upstreamHost: String, + val upstreamPort: Int, + val upstreamReviewRequired: Boolean, +) + +class ProxySettingsRepository(private val store: SettingsStore) { + suspend fun summary(): ProxySummary = + withContext(Dispatchers.IO) { + synchronized(store.proxySettingsLock) { + ProxySummary( + store.socksPort, + store.upstreamProxyEnabled, + store.upstreamProxyType, + store.upstreamProxyHost, + store.upstreamProxyPort, + store.upstreamReviewRequired, + ) + } + } + + suspend fun local(): LocalProxyOptions = + withContext(Dispatchers.IO) { + synchronized(store.proxySettingsLock) { + LocalProxyOptions( + store.socksPort, + store.localProxyAuthEnabled, + store.localProxyUsername, + store.localProxyPassword, + ) + } + } + + suspend fun upstream(): UpstreamProxyOptions = + withContext(Dispatchers.IO) { + synchronized(store.proxySettingsLock) { + UpstreamProxyOptions( + store.upstreamProxyEnabled, + store.upstreamProxyType, + store.upstreamProxyHost, + store.upstreamProxyPort, + store.upstreamProxyUsername, + store.upstreamProxyPassword, + ) + } + } + + suspend fun setUpstreamEnabled(enabled: Boolean) = + withContext(Dispatchers.IO) { store.setUpstreamProxyEnabled(enabled) } + + suspend fun save(options: LocalProxyOptions) = + withContext(Dispatchers.IO) { store.saveLocalProxy(options) } + + suspend fun save(options: UpstreamProxyOptions) = + withContext(Dispatchers.IO) { store.saveUpstreamProxy(options) } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/data/ProxyStateStore.kt b/app/src/main/java/com/vauth/foxyvpn/data/ProxyStateStore.kt index ab0f0dd8..475beed1 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/ProxyStateStore.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/ProxyStateStore.kt @@ -2,6 +2,8 @@ package com.vauth.foxyvpn.data import android.content.Context import com.vauth.foxyvpn.data.model.ProxyCandidate +import com.vauth.foxyvpn.data.model.displayName +import com.vauth.foxyvpn.data.model.normalizeCountryName import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -11,41 +13,48 @@ class ProxyStateStore(context: Context) { private val _selectedProxyFlow = MutableStateFlow(load()) val selectedProxyFlow: StateFlow = _selectedProxyFlow + @Synchronized fun load(): ProxyCandidate? { val host = prefs.getString(KEY_HOST, null) ?: return null val port = prefs.getInt(KEY_PORT, 0) - if (port == 0) return null + if (port !in 1..65_535 || !SettingsStore.isValidEdgeHost(host)) return null + val code = prefs.getString(KEY_COUNTRY_CODE, "").orEmpty() + val rawName = prefs.getString(KEY_COUNTRY_NAME, "").orEmpty() + val name = normalizeCountryName(code, rawName) return ProxyCandidate( host = host, port = port, - countryCode = prefs.getString(KEY_COUNTRY_CODE, "").orEmpty(), - countryName = prefs.getString(KEY_COUNTRY_NAME, "").orEmpty(), + countryCode = code, + countryName = name, cityCode = prefs.getString(KEY_CITY_CODE, "").orEmpty(), ) } + @Synchronized fun save(candidate: ProxyCandidate) { - prefs.edit() + require(candidate.port in 1..65_535 && SettingsStore.isValidEdgeHost(candidate.host)) { + "Invalid server address" + } + val name = candidate.displayName + prefs + .edit() .putString(KEY_HOST, candidate.host) .putInt(KEY_PORT, candidate.port) .putString(KEY_COUNTRY_CODE, candidate.countryCode) - .putString(KEY_COUNTRY_NAME, candidate.countryName) + .putString(KEY_COUNTRY_NAME, name) .putString(KEY_CITY_CODE, candidate.cityCode) - .putInt(KEY_FAILURES, 0) .apply() - _selectedProxyFlow.value = candidate + _selectedProxyFlow.value = candidate.copy(countryName = name) } - fun recordFailure(): Pair { - val failures = prefs.getInt(KEY_FAILURES, 0) + 1 - if (failures >= FAILURE_THRESHOLD) { - clear() - return failures to true - } - prefs.edit().putInt(KEY_FAILURES, failures).apply() - return failures to false + @Synchronized + fun saveIfUnchanged(expected: ProxyCandidate?, candidate: ProxyCandidate): Boolean { + if (load() != expected) return false + save(candidate) + return true } + @Synchronized fun clear() { prefs.edit().clear().apply() _selectedProxyFlow.value = null @@ -57,7 +66,5 @@ class ProxyStateStore(context: Context) { private const val KEY_COUNTRY_CODE = "country_code" private const val KEY_COUNTRY_NAME = "country_name" private const val KEY_CITY_CODE = "city_code" - private const val KEY_FAILURES = "failures" - private const val FAILURE_THRESHOLD = 3 } } diff --git a/app/src/main/java/com/vauth/foxyvpn/data/RetryAfter.kt b/app/src/main/java/com/vauth/foxyvpn/data/RetryAfter.kt new file mode 100644 index 00000000..f62c679c --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/data/RetryAfter.kt @@ -0,0 +1,27 @@ +package com.vauth.foxyvpn.data + +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +internal fun retryAfterMillis(value: String?, nowMillis: Long = System.currentTimeMillis()): Long? { + val raw = value?.trim()?.takeIf { it.isNotEmpty() } ?: return null + if (raw.all { it in '0'..'9' }) { + val seconds = raw.toLongOrNull() ?: return Long.MAX_VALUE + return if (seconds > Long.MAX_VALUE / 1_000L) Long.MAX_VALUE else seconds * 1_000L + } + return runCatching { + (ZonedDateTime.parse(raw, DateTimeFormatter.RFC_1123_DATE_TIME) + .toInstant() + .toEpochMilli() - nowMillis) + .coerceAtLeast(0L) + } + .getOrNull() +} + +internal fun retryDelayMillis(backoff: Long, serverHint: Long?, jitter: Long): Long { + val base = maxOf(backoff, serverHint ?: 0L) + return if (jitter > Long.MAX_VALUE - base) Long.MAX_VALUE else base + jitter +} + +internal fun retryDeadlineMillis(nowElapsed: Long, delayMillis: Long): Long = + if (delayMillis > Long.MAX_VALUE - nowElapsed) Long.MAX_VALUE else nowElapsed + delayMillis diff --git a/app/src/main/java/com/vauth/foxyvpn/data/SecurePreferences.kt b/app/src/main/java/com/vauth/foxyvpn/data/SecurePreferences.kt new file mode 100644 index 00000000..0d9e0c6f --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/data/SecurePreferences.kt @@ -0,0 +1,148 @@ +package com.vauth.foxyvpn.data + +import android.content.Context +import android.util.Base64 +import java.util.concurrent.ConcurrentHashMap +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import org.json.JSONObject + +internal class SecurePreferences( + context: Context, + private val legacyName: String, + private val failure: (Exception) -> SecureStorageException = { SecureStorageException(it) }, +) { + private val context = context.applicationContext + private val namespace = "${context.packageName}.$legacyName.v1" + private val prefs = + context.getSharedPreferences("${legacyName}_secure_v1", Context.MODE_PRIVATE) + private val lock = locks.getOrPut(namespace) { Any() } + private var cachedPayload: String? = null + private var cachedValues: Map? = null + + fun getString(key: String, default: String?): String? = guarded { + val value = load()[key] ?: return@guarded default + check(value is String) { "Unexpected credential value type" } + value + } + + fun getLong(key: String, default: Long): Long = guarded { + val value = load()[key] ?: return@guarded default + check(value is Long) { "Unexpected credential value type" } + value + } + + fun edit() = Editor() + + inner class Editor { + private val changes = mutableMapOf() + private var clear = false + + fun putString(key: String, value: String?) = apply { changes[key] = value } + + fun putLong(key: String, value: Long) = apply { changes[key] = value } + + fun remove(key: String) = apply { changes[key] = null } + + fun clear() = apply { clear = true } + + fun commit() = guarded { + val current = load() + val next = if (clear) mutableMapOf() else current.toMutableMap() + for ((key, value) in changes) if (value == null) next.remove(key) else next[key] = value + write(next) + } + } + + private fun guarded(action: () -> T): T = + synchronized(lock) { + try { + action() + } catch (error: Exception) { + cachedPayload = null + cachedValues = null + throw error as? SecureStorageException ?: failure(error) + } + } + + private fun load(): Map { + val payload = prefs.getString(PAYLOAD, null) + if (payload != null) { + if (payload == cachedPayload) return checkNotNull(cachedValues) + return decode(payload).also { + cachedPayload = payload + cachedValues = it + } + } + check(prefs.all.isEmpty()) { "Encrypted credential payload is missing" } + val rawLegacy = context.getSharedPreferences(legacyName, Context.MODE_PRIVATE) + if (rawLegacy.all.isEmpty()) return emptyMap() + val legacy = LegacyCredentialReader.open(context, legacyName) + val values = + legacy.all.mapValues { (_, value) -> + check(value is String || value is Long) { + "Unsupported legacy credential value type" + } + checkNotNull(value) + } + write(values) + check(decode(checkNotNull(prefs.getString(PAYLOAD, null))) == values) { + "Credential migration verification failed" + } + if (!legacy.edit().clear().commit()) + AppLogger.w( + "SecurePreferences", + "Legacy credential cleanup could not be persisted; verified encrypted copy retained", + ) + return values + } + + private fun write(values: Map) { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init( + Cipher.ENCRYPT_MODE, + CredentialKeys.get(namespace, create = !prefs.contains(PAYLOAD)), + ) + cipher.updateAAD(namespace.toByteArray(Charsets.UTF_8)) + check(cipher.iv.size == 12) { "Invalid encryption IV" } + val ciphertext = cipher.doFinal(JSONObject(values).toString().toByteArray(Charsets.UTF_8)) + val payload = + "1:${Base64.encodeToString(cipher.iv, Base64.NO_WRAP)}:${Base64.encodeToString(ciphertext, Base64.NO_WRAP)}" + check(payload.length <= MAX_PAYLOAD_CHARS) { "Credential payload is too large" } + val editor = prefs.edit().putString(PAYLOAD, payload) + check(editor.commit()) { "Could not persist encrypted credentials" } + cachedPayload = payload + cachedValues = values.toMap() + } + + private fun decode(payload: String): Map { + check(payload.length <= MAX_PAYLOAD_CHARS) { "Credential payload is too large" } + val parts = payload.split(':') + check(parts.size == 3 && parts[0] == "1") { "Unsupported credential format" } + val iv = Base64.decode(parts[1], Base64.NO_WRAP) + val ciphertext = Base64.decode(parts[2], Base64.NO_WRAP) + check(iv.size == 12 && ciphertext.size >= 16) { "Invalid credential payload" } + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init( + Cipher.DECRYPT_MODE, + CredentialKeys.get(namespace, create = false), + GCMParameterSpec(128, iv), + ) + cipher.updateAAD(namespace.toByteArray(Charsets.UTF_8)) + val json = JSONObject(String(cipher.doFinal(ciphertext), Charsets.UTF_8)) + return json.keys().asSequence().associateWith { key -> + when (val value = json.get(key)) { + is String -> value + is Number -> value.toLong() + else -> error("Unexpected credential value type") + } + } + } + + companion object { + private val locks = ConcurrentHashMap() + private const val PAYLOAD = "encrypted_payload" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val MAX_PAYLOAD_CHARS = 256 * 1024 + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/data/SecureStorageException.kt b/app/src/main/java/com/vauth/foxyvpn/data/SecureStorageException.kt new file mode 100644 index 00000000..b68d672f --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/data/SecureStorageException.kt @@ -0,0 +1,7 @@ +package com.vauth.foxyvpn.data + +class SecureStorageException(cause: Exception) : + IllegalStateException( + "Encrypted storage is unavailable. Unlock the device and try again. Stored credentials have not been reset.", + cause, + ) diff --git a/app/src/main/java/com/vauth/foxyvpn/data/ServerListClient.kt b/app/src/main/java/com/vauth/foxyvpn/data/ServerListClient.kt index bc3f8037..809047c6 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/ServerListClient.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/ServerListClient.kt @@ -6,21 +6,23 @@ import com.vauth.foxyvpn.data.model.VpnCity import com.vauth.foxyvpn.data.model.VpnCountry import com.vauth.foxyvpn.data.model.VpnProtocol import com.vauth.foxyvpn.data.model.VpnServerNode +import com.vauth.foxyvpn.data.model.displayName +import com.vauth.foxyvpn.data.model.normalizeCountryName +import java.io.File +import java.io.IOException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONArray import org.json.JSONObject -import java.io.File -import java.io.IOException private const val TAG = "ServerListClient" private const val REMOTE_SETTINGS_URL = "https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/vpn-serverlist/records" -const val RECOMMENDED_COUNTRY_CODE = "REC" +const val RECOMMENDED_COUNTRY_CODE = com.vauth.foxyvpn.data.model.RECOMMENDED_COUNTRY_CODE private val EXCLUDED_COUNTRY_NAMES = setOf("CatchAll Anycast") @@ -32,51 +34,59 @@ class ServerListClient( private val cacheFile: File? get() = context?.let { File(it.filesDir, "vpn_serverlist_cache.json") } - suspend fun fetchCountries(): List = withContext(Dispatchers.IO) { - val request = Request.Builder().url(REMOTE_SETTINGS_URL).applyMozillaVpnHeaders().build() - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - throw IOException("Remote Settings fetch failed: HTTP ${response.code}") - } - val raw = response.body?.string().orEmpty() - val countries = parseRecords(raw) - if (countries.isNotEmpty()) { + fun hasFreshCache(nowMillis: Long = System.currentTimeMillis()): Boolean { + val modified = cacheFile?.takeIf { it.isFile }?.lastModified() ?: return false + return modified > 0L && nowMillis - modified in 0 until 6 * 60 * 60_000L + } + + suspend fun fetchCountries(): List = + withContext(Dispatchers.IO) { + val request = + Request.Builder().url(REMOTE_SETTINGS_URL).applyFirefoxHeaders().build() + client.newCall(request).awaitResponse().use { response -> + if (!response.isSuccessful) { + throw IOException("Remote Settings fetch failed: HTTP ${response.code}") + } + val raw = response.body?.readBoundedText(4 * 1024 * 1024).orEmpty() + val countries = parseRecords(raw) + if (countries.isEmpty()) throw IOException("Server directory contains no locations") saveCache(raw) + AppLogger.i(TAG, "fetched ${countries.size} countries from Remote Settings") + countries } - AppLogger.i(TAG, "fetched ${countries.size} countries from Remote Settings") - countries } - } fun loadCachedCountries(): List { val file = cacheFile ?: return emptyList() if (!file.exists() || !file.isFile) return emptyList() return runCatching { - val raw = file.readText() - val parsed = parseRecords(raw) - if (parsed.isNotEmpty()) { - AppLogger.d(TAG, "loaded ${parsed.size} countries from local cache") + val raw = file.inputStream().use { it.readBoundedUtf8(4 * 1024 * 1024) } + val parsed = parseRecords(raw) + if (parsed.isNotEmpty()) { + AppLogger.d(TAG, "loaded ${parsed.size} countries from local cache") + } + parsed + } + .getOrElse { + AppLogger.w(TAG, "failed to read server list cache", it) + emptyList() } - parsed - }.getOrElse { - AppLogger.w(TAG, "failed to read server list cache", it) - emptyList() - } } private fun saveCache(rawJson: String) { val file = cacheFile ?: return runCatching { - val tempFile = File.createTempFile("serverlist", ".tmp", file.parentFile) - tempFile.writeText(rawJson) - if (!tempFile.renameTo(file)) { - tempFile.copyTo(file, overwrite = true) - tempFile.delete() + val tempFile = File.createTempFile("serverlist", ".tmp", file.parentFile) + try { + tempFile.writeText(rawJson) + if (!tempFile.renameTo(file)) + throw IOException("Could not atomically replace server directory cache") + } finally { + tempFile.delete() + } + AppLogger.d(TAG, "updated local server list cache") } - AppLogger.d(TAG, "updated local server list cache") - }.onFailure { - AppLogger.w(TAG, "failed to save server list cache", it) - } + .onFailure { AppLogger.w(TAG, "failed to save server list cache", it) } } private fun parseRecords(rawJson: String): List { @@ -85,52 +95,109 @@ class ServerListClient( val records = body.optJSONArray("data") ?: JSONArray() val countries = mutableListOf() for (i in 0 until records.length()) { - val record = records.getJSONObject(i) + val record = records.optJSONObject(i) ?: continue val countryJson = record.optJSONObject("country") ?: record val country = parseCountry(countryJson) - val isExcluded = EXCLUDED_COUNTRY_NAMES.any { it.equals(country.name, ignoreCase = true) } + val isExcluded = + EXCLUDED_COUNTRY_NAMES.any { it.equals(country.name.trim(), ignoreCase = true) } if (country.code.isNotBlank() && country.cities.isNotEmpty() && !isExcluded) { countries.add(country) } } return countries + .groupBy { it.code } + .values + .map { locations -> + val first = locations.first() + first.copy( + cities = + locations + .flatMap { it.cities } + .groupBy { it.code } + .values + .map { cities -> + cities + .first() + .copy( + servers = + cities + .flatMap { it.servers } + .groupBy { + it.hostname.lowercase(java.util.Locale.ROOT) to + it.port + } + .values + .map { nodes -> + nodes + .first() + .copy( + quarantined = + nodes.any { it.quarantined }, + protocols = + nodes + .flatMap { it.protocols } + .distinct(), + ) + } + ) + } + ) + } + } + + private fun parseCountry(json: JSONObject): VpnCountry { + val code = json.optString("code").trim().uppercase(java.util.Locale.ROOT) + val rawName = json.optString("name") + val name = normalizeCountryName(code, rawName) + return VpnCountry( + name = name, + code = code, + cities = + buildList { + val cities = json.optJSONArray("cities") ?: JSONArray() + for (i in 0 until cities.length()) { + val city = cities.optJSONObject(i) ?: continue + add(parseCity(city)) + } + }, + ) } - private fun parseCountry(json: JSONObject): VpnCountry = VpnCountry( - name = json.optString("name"), - code = json.optString("code"), - cities = buildList { - val cities = json.optJSONArray("cities") ?: JSONArray() - for (i in 0 until cities.length()) add(parseCity(cities.getJSONObject(i))) - }, - ) - - private fun parseCity(json: JSONObject): VpnCity = VpnCity( - name = json.optString("name"), - code = json.optString("code"), - servers = buildList { - val servers = json.optJSONArray("servers") ?: JSONArray() - for (i in 0 until servers.length()) add(parseServer(servers.getJSONObject(i))) - }, - ) - - private fun parseServer(json: JSONObject): VpnServerNode = VpnServerNode( - hostname = json.optString("hostname"), - port = json.optInt("port", 0), - quarantined = json.optBoolean("quarantined", false), - protocols = buildList { - val protocols = json.optJSONArray("protocols") ?: JSONArray() - for (i in 0 until protocols.length()) add(parseProtocol(protocols.getJSONObject(i))) - }, - ) - - private fun parseProtocol(json: JSONObject): VpnProtocol = VpnProtocol( - name = json.optString("name"), - host = json.optString("host"), - port = json.optInt("port", 0), - scheme = json.optString("scheme"), - templateString = json.optString("templateString"), - ) + private fun parseCity(json: JSONObject): VpnCity = + VpnCity( + name = json.optString("name"), + code = json.optString("code").trim().uppercase(java.util.Locale.ROOT), + servers = + buildList { + val servers = json.optJSONArray("servers") ?: JSONArray() + for (i in 0 until servers.length()) { + val server = servers.optJSONObject(i) ?: continue + add(parseServer(server)) + } + }, + ) + + private fun parseServer(json: JSONObject): VpnServerNode = + VpnServerNode( + hostname = json.optString("hostname"), + port = json.optInt("port", 0), + quarantined = json.optBoolean("quarantined", false), + protocols = + buildList { + val protocols = json.optJSONArray("protocols") ?: JSONArray() + for (i in 0 until protocols.length()) { + val protocol = protocols.optJSONObject(i) ?: continue + add(parseProtocol(protocol)) + } + }, + ) + + private fun parseProtocol(json: JSONObject): VpnProtocol = + VpnProtocol( + name = json.optString("name"), + host = json.optString("host"), + port = json.optInt("port", 0), + ) companion object { @@ -139,14 +206,21 @@ class ServerListClient( if (proto.name == "connect") { val host = proto.host.ifBlank { server.hostname } val port = if (proto.port != 0) proto.port else server.port - return host to port + if (SettingsStore.isValidEdgeHost(host) && port in 1..65_535) + return host to port } } - if (server.protocols.isEmpty()) return server.hostname to server.port + if (server.protocols.isEmpty()) + return (server.hostname to server.port).takeIf { + SettingsStore.isValidEdgeHost(server.hostname) && server.port in 1..65_535 + } return null } - fun candidatesForCountry(countries: List, countryCode: String): List { + fun candidatesForCountry( + countries: List, + countryCode: String, + ): List { val out = mutableListOf() for (country in countries) { if (!country.code.equals(countryCode, ignoreCase = true)) continue @@ -154,14 +228,15 @@ class ServerListClient( for (server in city.servers) { if (server.quarantined) continue val target = defaultConnectTarget(server) ?: continue + val countryDisplayName = country.displayName out.add( ProxyCandidate( host = target.first, port = target.second, countryCode = country.code, - countryName = country.name, + countryName = countryDisplayName, cityCode = city.code, - ), + ) ) } } @@ -169,7 +244,13 @@ class ServerListClient( return out } - fun candidatesForCity(countries: List, countryCode: String, cityCode: String): List = - candidatesForCountry(countries, countryCode).filter { it.cityCode.equals(cityCode, ignoreCase = true) } + fun candidatesForCity( + countries: List, + countryCode: String, + cityCode: String, + ): List = + candidatesForCountry(countries, countryCode).filter { + it.cityCode.equals(cityCode, ignoreCase = true) + } } } diff --git a/app/src/main/java/com/vauth/foxyvpn/data/SettingsStore.kt b/app/src/main/java/com/vauth/foxyvpn/data/SettingsStore.kt index 060c1bbe..edab21cc 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/SettingsStore.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/SettingsStore.kt @@ -1,105 +1,208 @@ package com.vauth.foxyvpn.data import android.content.Context -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import com.vauth.foxyvpn.ui.theme.ThemeMode -import com.vauth.foxyvpn.vpn.FoxyVpnService -import com.vauth.foxyvpn.widget.QuickConnectWidgetProvider class SettingsStore(context: Context) { + internal val proxySettingsLock = Any() private val appContext = context.applicationContext private val prefs = appContext.getSharedPreferences("foxyvpn_settings", Context.MODE_PRIVATE) + private fun readCredential(block: () -> T): T = + try { + block() + } catch (error: Exception) { + throw error as? SecureStorageException ?: SecureStorageException(error) + } + private val credentialsPrefs by lazy { - val masterKey = MasterKey.Builder(appContext) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - EncryptedSharedPreferences.create( - appContext, - "foxyvpn_settings_credentials", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + SecurePreferences(appContext, "foxyvpn_settings_credentials") + } + + data class AccountSnapshot( + val realIp: String?, + val country: String?, + val quotaRemaining: Long?, + val quotaMax: Long?, + val unlimited: Boolean, + ) + + private fun accountSnapshot() = + AccountSnapshot( + cachedRealIp, + cachedRealCountry, + cachedQuotaRemaining, + cachedQuotaMax, + cachedIsUnlimited, ) + + private val mutableAccount = kotlinx.coroutines.flow.MutableStateFlow(accountSnapshot()) + val account: kotlinx.coroutines.flow.StateFlow = mutableAccount + + private val accountListener = + android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if ( + key == null || + key in + setOf( + KEY_CACHED_REAL_IP, + KEY_CACHED_REAL_COUNTRY, + KEY_CACHED_QUOTA_REMAINING, + KEY_CACHED_QUOTA_MAX, + KEY_CACHED_IS_UNLIMITED, + ) + ) { + mutableAccount.value = accountSnapshot() + } + } + + init { + + if (prefs.getString(KEY_DNS_PROVIDER, null) == "ADGUARD") { + prefs.edit().putString(KEY_DNS_PROVIDER, DnsProvider.GOOGLE.name).apply() + } + prefs.registerOnSharedPreferenceChangeListener(accountListener) } + var bypassLocalNetwork: Boolean + get() = prefs.getBoolean("bypass_local_network", true) + set(value) { + prefs.edit().putBoolean("bypass_local_network", value).apply() + } + + var automaticRecovery: Boolean + get() = prefs.getBoolean("automatic_recovery", false) + set(value) { + prefs.edit().putBoolean("automatic_recovery", value).apply() + } + var themeMode: ThemeMode - get() = ThemeMode.entries.firstOrNull { - it.name == prefs.getString(KEY_THEME_MODE, null) - } ?: ThemeMode.SYSTEM + get() = + ThemeMode.entries.firstOrNull { it.name == prefs.getString(KEY_THEME_MODE, null) } + ?: ThemeMode.SYSTEM set(value) { prefs.edit().putString(KEY_THEME_MODE, value.name).apply() - QuickConnectWidgetProvider.updateAll(appContext, FoxyVpnService.state.value) } - var exitCheckEnabled: Boolean - get() = prefs.getBoolean(KEY_EXIT_CHECK, true) - set(value) { prefs.edit().putBoolean(KEY_EXIT_CHECK, value).apply() } + var latencyProbeUrl: String + get() = + prefs.getString(KEY_LATENCY_PROBE_URL, null)?.takeIf { isValidProbeUrl(it) } + ?: DEFAULT_LATENCY_PROBE_URL + set(value) { + val url = value.trim() + require(isValidProbeUrl(url)) { + "Latency endpoint must be HTTPS without credentials or fragment" + } + prefs.edit().putString(KEY_LATENCY_PROBE_URL, url).apply() + } var mtu: Int get() = prefs.getInt(KEY_CUSTOM_MTU, DEFAULT_MTU) - set(value) { prefs.edit().putInt(KEY_CUSTOM_MTU, value.coerceIn(MIN_MTU, MAX_MTU)).apply() } + set(value) { + prefs.edit().putInt(KEY_CUSTOM_MTU, value.coerceIn(MIN_MTU, MAX_MTU)).apply() + } var cachedRealIp: String? get() = prefs.getString(KEY_CACHED_REAL_IP, null) - set(value) { prefs.edit().putString(KEY_CACHED_REAL_IP, value).apply() } + set(value) { + prefs.edit().putString(KEY_CACHED_REAL_IP, value).apply() + } var cachedRealCountry: String? get() = prefs.getString(KEY_CACHED_REAL_COUNTRY, null) - set(value) { prefs.edit().putString(KEY_CACHED_REAL_COUNTRY, value).apply() } + set(value) { + prefs.edit().putString(KEY_CACHED_REAL_COUNTRY, value).apply() + } var cachedQuotaRemaining: Long? - get() = if (prefs.contains(KEY_CACHED_QUOTA_REMAINING)) prefs.getLong(KEY_CACHED_QUOTA_REMAINING, -1L).takeIf { it >= 0 } else null + get() = + if (prefs.contains(KEY_CACHED_QUOTA_REMAINING)) + prefs.getLong(KEY_CACHED_QUOTA_REMAINING, -1L).takeIf { it >= 0 } + else null set(value) { - if (value != null && value >= 0) prefs.edit().putLong(KEY_CACHED_QUOTA_REMAINING, value).apply() + if (value != null && value >= 0) + prefs.edit().putLong(KEY_CACHED_QUOTA_REMAINING, value).apply() else prefs.edit().remove(KEY_CACHED_QUOTA_REMAINING).apply() } var cachedQuotaMax: Long? - get() = if (prefs.contains(KEY_CACHED_QUOTA_MAX)) prefs.getLong(KEY_CACHED_QUOTA_MAX, -1L).takeIf { it >= 0 } else null + get() = + if (prefs.contains(KEY_CACHED_QUOTA_MAX)) + prefs.getLong(KEY_CACHED_QUOTA_MAX, -1L).takeIf { it >= 0 } + else null set(value) { - if (value != null && value >= 0) prefs.edit().putLong(KEY_CACHED_QUOTA_MAX, value).apply() + if (value != null && value >= 0) + prefs.edit().putLong(KEY_CACHED_QUOTA_MAX, value).apply() else prefs.edit().remove(KEY_CACHED_QUOTA_MAX).apply() } var cachedIsUnlimited: Boolean get() = prefs.getBoolean(KEY_CACHED_IS_UNLIMITED, false) - set(value) { prefs.edit().putBoolean(KEY_CACHED_IS_UNLIMITED, value).apply() } + set(value) { + prefs.edit().putBoolean(KEY_CACHED_IS_UNLIMITED, value).apply() + } + + fun saveDirectAddress(ip: String, country: String) { + prefs + .edit() + .putString(KEY_CACHED_REAL_IP, ip) + .putString(KEY_CACHED_REAL_COUNTRY, country) + .apply() + mutableAccount.value = accountSnapshot() + } + + fun saveAccountQuota(unlimited: Boolean, remaining: Long?, maximum: Long?) { + val editor = prefs.edit().putBoolean(KEY_CACHED_IS_UNLIMITED, unlimited) + if (remaining != null && remaining >= 0) { + editor.putLong(KEY_CACHED_QUOTA_REMAINING, remaining) + } else if (unlimited) { + editor.remove(KEY_CACHED_QUOTA_REMAINING) + } + if (maximum != null && maximum >= 0) { + editor.putLong(KEY_CACHED_QUOTA_MAX, maximum) + } else if (unlimited) { + editor.remove(KEY_CACHED_QUOTA_MAX) + } + editor.apply() + mutableAccount.value = accountSnapshot() + } fun clearAccountSession() { - prefs.edit() + prefs + .edit() .remove(KEY_CACHED_REAL_IP) .remove(KEY_CACHED_REAL_COUNTRY) .remove(KEY_CACHED_QUOTA_REMAINING) .remove(KEY_CACHED_QUOTA_MAX) .remove(KEY_CACHED_IS_UNLIMITED) .apply() + mutableAccount.value = accountSnapshot() } - var socksBindAddress: String - get() = prefs.getString(KEY_SOCKS_BIND_ADDRESS, DEFAULT_SOCKS_BIND_ADDRESS) ?: DEFAULT_SOCKS_BIND_ADDRESS - set(value) { - prefs.edit().putString(KEY_SOCKS_BIND_ADDRESS, value.ifBlank { DEFAULT_SOCKS_BIND_ADDRESS }).apply() - } - - var socksPort: Int - get() = prefs.getInt(KEY_SOCKS_PORT, DEFAULT_SOCKS_PORT) - set(value) { prefs.edit().putInt(KEY_SOCKS_PORT, value.coerceIn(1, 65_535)).apply() } - - var httpProxyEnabled: Boolean - get() = prefs.getBoolean(KEY_HTTP_PROXY_ENABLED, false) - set(value) { prefs.edit().putBoolean(KEY_HTTP_PROXY_ENABLED, value).apply() } - - var httpProxyPort: Int - get() = prefs.getInt(KEY_HTTP_PROXY_PORT, DEFAULT_HTTP_PROXY_PORT) - set(value) { prefs.edit().putInt(KEY_HTTP_PROXY_PORT, value.coerceIn(1, 65_535)).apply() } + val socksBindAddress: String + get() = DEFAULT_SOCKS_BIND_ADDRESS + + val socksPort: Int + get() = + if (localProxyAuthEnabled) + readCredential { + credentialsPrefs + .getLong( + KEY_SOCKS_PORT, + prefs.getInt(KEY_SOCKS_PORT, DEFAULT_SOCKS_PORT).toLong(), + ) + .toInt() + } + else prefs.getInt(KEY_SOCKS_PORT, DEFAULT_SOCKS_PORT) var dnsProvider: DnsProvider - get() = DnsProvider.entries.firstOrNull { - it.name == prefs.getString(KEY_DNS_PROVIDER, null) - } ?: DnsProvider.SYSTEM_DEFAULT - set(value) { prefs.edit().putString(KEY_DNS_PROVIDER, value.name).apply() } + get() { + val saved = prefs.getString(KEY_DNS_PROVIDER, null) + if (saved == "SYSTEM_DEFAULT" || saved == null) return DnsProvider.GOOGLE + return DnsProvider.entries.firstOrNull { it.name == saved } ?: DnsProvider.GOOGLE + } + set(value) { + prefs.edit().putString(KEY_DNS_PROVIDER, value.name).apply() + } var customDohUrl: String get() = prefs.getString(KEY_CUSTOM_DOH_URL, "") ?: "" @@ -109,40 +212,76 @@ class SettingsStore(context: Context) { prefs.edit().putString(KEY_CUSTOM_DOH_URL, trimmed).apply() } - val effectiveDohUrl: String? - get() = when (dnsProvider) { - DnsProvider.SYSTEM_DEFAULT -> null - DnsProvider.CUSTOM -> customDohUrl.takeIf { isValidDohUrl(it) } - else -> dnsProvider.dohUrl + fun saveDns(provider: DnsProvider, customUrl: String) { + val url = customUrl.trim() + require(provider != DnsProvider.CUSTOM || isValidDohUrl(url)) { + "Enter a valid HTTPS DoH URL" + } + val savedUrl = + url.takeIf { it.isEmpty() || isValidDohUrl(it) } + ?: customDohUrl.takeIf { isValidDohUrl(it) }.orEmpty() + prefs + .edit() + .putString(KEY_DNS_PROVIDER, provider.name) + .putString(KEY_CUSTOM_DOH_URL, savedUrl) + .apply() + } + + val effectiveDohUrl: String + get() = + when (dnsProvider) { + DnsProvider.CUSTOM -> customDohUrl + DnsProvider.DIRECT_SYSTEM -> "" + else -> dnsProvider.dohUrl + } + + var tlsFragmentMode: TlsFragmentMode + get() = + TlsFragmentMode.entries.firstOrNull { + it.name == prefs.getString(KEY_TLS_FRAGMENT_MODE, null) + } ?: TlsFragmentMode.OFF + set(value) { + prefs.edit().putString(KEY_TLS_FRAGMENT_MODE, value.name).apply() + } + + var fragmentFallbackDelay: Int + get() = prefs.getInt(KEY_FRAGMENT_FALLBACK_DELAY, DEFAULT_FRAGMENT_FALLBACK_DELAY) + set(value) { + prefs.edit().putInt(KEY_FRAGMENT_FALLBACK_DELAY, value.coerceIn(50, 2000)).apply() } - var bootstrapDnsProvider: BootstrapDnsProvider - get() = BootstrapDnsProvider.entries.firstOrNull { - it.name == prefs.getString(KEY_BOOTSTRAP_DNS_PROVIDER, null) - } ?: BootstrapDnsProvider.AUTOMATIC - set(value) { prefs.edit().putString(KEY_BOOTSTRAP_DNS_PROVIDER, value.name).apply() } + var utlsFingerprint: UtlsFingerprint + get() = + UtlsFingerprint.entries.firstOrNull { + it.name == prefs.getString(KEY_UTLS_FINGERPRINT, null) + } ?: UtlsFingerprint.FIREFOX + set(value) { + prefs.edit().putString(KEY_UTLS_FINGERPRINT, value.name).apply() + } - var customBootstrapIp: String - get() = prefs.getString(KEY_CUSTOM_BOOTSTRAP_IP, "") ?: "" + var tcpFastOpen: Boolean + get() = prefs.getBoolean(KEY_TCP_FAST_OPEN, false) set(value) { - val trimmed = value.trim() - if (trimmed.isNotEmpty() && !isValidIpAddress(trimmed)) return - prefs.edit().putString(KEY_CUSTOM_BOOTSTRAP_IP, trimmed).apply() + prefs.edit().putBoolean(KEY_TCP_FAST_OPEN, value).apply() } - val effectiveBootstrapAddresses: List - get() = when (bootstrapDnsProvider) { - BootstrapDnsProvider.CUSTOM -> { - val ip = customBootstrapIp.trim() - if (isValidIpAddress(ip)) listOf(ip) else BootstrapDnsProvider.AUTOMATIC.addresses - } - BootstrapDnsProvider.SYSTEM -> emptyList() - else -> bootstrapDnsProvider.addresses + var maxConcurrentStreams: Int + get() = prefs.getInt(KEY_MAX_CONCURRENT_STREAMS, DEFAULT_MAX_CONCURRENT_STREAMS) + set(value) { + prefs.edit().putInt(KEY_MAX_CONCURRENT_STREAMS, value.coerceIn(2, 128)).apply() + } + + var ipv6Enabled: Boolean + get() = prefs.getBoolean(KEY_IPV6_ENABLED, false) + set(value) { + prefs.edit().putBoolean(KEY_IPV6_ENABLED, value).apply() } var proxyOnlyMode: Boolean get() = prefs.getBoolean(KEY_PROXY_ONLY_MODE, false) - set(value) { prefs.edit().putBoolean(KEY_PROXY_ONLY_MODE, value).apply() } + set(value) { + prefs.edit().putBoolean(KEY_PROXY_ONLY_MODE, value).apply() + } var customEdgeAddress: String get() = prefs.getString(KEY_CUSTOM_EDGE_ADDRESS, "") ?: "" @@ -155,155 +294,260 @@ class SettingsStore(context: Context) { val effectiveCustomEdgeAddress: String? get() = customEdgeAddress.takeIf { isValidEdgeHost(it) } - var upstreamProxyEnabled: Boolean + val localProxyAuthEnabled: Boolean + get() = prefs.getBoolean("local_proxy_auth_enabled", false) + + val localProxyUsername: String + get() = readCredential { credentialsPrefs.getString("local_proxy_username", "") ?: "" } + + val localProxyPassword: String + get() = readCredential { credentialsPrefs.getString("local_proxy_password", "") ?: "" } + + val upstreamProxyEnabled: Boolean get() = prefs.getBoolean(KEY_UPSTREAM_PROXY_ENABLED, false) - set(value) { prefs.edit().putBoolean(KEY_UPSTREAM_PROXY_ENABLED, value).apply() } - var upstreamProxyType: UpstreamProxyType - get() = UpstreamProxyType.entries.firstOrNull { - it.name == prefs.getString(KEY_UPSTREAM_PROXY_TYPE, null) - } ?: UpstreamProxyType.SOCKS5 - set(value) { prefs.edit().putString(KEY_UPSTREAM_PROXY_TYPE, value.name).apply() } + val upstreamProxyType: UpstreamProxyType + get() = + UpstreamProxyType.entries.firstOrNull { + it.name == + readCredential { + credentialsPrefs.getString( + KEY_UPSTREAM_PROXY_TYPE, + prefs.getString(KEY_UPSTREAM_PROXY_TYPE, null), + ) + } + } ?: UpstreamProxyType.SOCKS5 + + val upstreamProxyHost: String + get() = readCredential { + credentialsPrefs + .getString(KEY_UPSTREAM_PROXY_HOST, prefs.getString(KEY_UPSTREAM_PROXY_HOST, "")) + .orEmpty() + .trim() + .removeSurrounding("[", "]") + } + + fun saveLocalProxy(options: LocalProxyOptions) = + synchronized(proxySettingsLock) { + require(isValidLocalPort(options.port)) { + "Choose a valid listener port, excluding controller port $CONTROLLER_PORT" + } + require( + !options.authentication || + isValidLocalProxyCredentials(options.username, options.password) + ) { + "Enter valid credentials for both HTTP and SOCKS5" + } + require( + !upstreamProxyEnabled || + !isLocalProxyLoop(upstreamProxyHost, upstreamProxyPort, options.port) + ) { + "Upstream proxy must not point to this app's listeners" + } + readCredential { + credentialsPrefs + .edit() + .putString("local_proxy_username", options.username) + .putString("local_proxy_password", options.password) + .putLong(KEY_SOCKS_PORT, options.port.toLong()) + .commit() + } + check( + prefs + .edit() + .putInt(KEY_SOCKS_PORT, options.port) + .putBoolean("local_proxy_auth_enabled", options.authentication) + .commit() + ) { + "Could not persist local proxy settings" + } + } + + val upstreamReviewRequired: Boolean + get() = readCredential { + credentialsPrefs.getLong( + "upstream_review_required", + if (prefs.getBoolean("upstream_proxy_tls", false)) 1 else 0, + ) == 1L + } - var upstreamProxyHost: String - get() = prefs.getString(KEY_UPSTREAM_PROXY_HOST, "") ?: "" - set(value) { prefs.edit().putString(KEY_UPSTREAM_PROXY_HOST, value.trim()).apply() } + fun setUpstreamProxyEnabled(enabled: Boolean) = + synchronized(proxySettingsLock) { + check(prefs.edit().putBoolean(KEY_UPSTREAM_PROXY_ENABLED, enabled).commit()) { + "Could not persist the upstream proxy switch" + } + } - var upstreamProxyPort: Int - get() = prefs.getInt(KEY_UPSTREAM_PROXY_PORT, DEFAULT_UPSTREAM_PROXY_PORT) - set(value) { prefs.edit().putInt(KEY_UPSTREAM_PROXY_PORT, value.coerceIn(1, 65_535)).apply() } + fun disableUpstreamProxy() = setUpstreamProxyEnabled(false) - var upstreamProxyUsername: String - get() = credentialsPrefs.getString(KEY_UPSTREAM_PROXY_USERNAME, "") ?: "" - set(value) { credentialsPrefs.edit().putString(KEY_UPSTREAM_PROXY_USERNAME, value).apply() } + fun saveUpstreamProxy(options: UpstreamProxyOptions) = + synchronized(proxySettingsLock) { + val host = options.host.trim().removeSurrounding("[", "]") + require((host.isEmpty() || isValidProxyHost(host)) && options.port in 1..65_535) { + "Enter a valid proxy host and port" + } + require( + !options.enabled || + isValidUpstreamProxyCredentials( + options.type, + options.username, + options.password, + ) + ) { + "Credentials are not valid for the selected proxy type" + } + require(!options.enabled || !isLocalProxyLoop(host, options.port, socksPort)) { + "Upstream proxy must not point to this app's listeners" + } + readCredential { + credentialsPrefs + .edit() + .putString(KEY_UPSTREAM_PROXY_USERNAME, options.username) + .putString(KEY_UPSTREAM_PROXY_PASSWORD, options.password) + .putString(KEY_UPSTREAM_PROXY_TYPE, options.type.name) + .putString(KEY_UPSTREAM_PROXY_HOST, host) + .putLong(KEY_UPSTREAM_PROXY_PORT, options.port.toLong()) + .also { if (options.enabled) it.putLong("upstream_review_required", 0) } + .commit() + } + setUpstreamProxyEnabled(options.enabled) + } - var upstreamProxyPassword: String - get() = credentialsPrefs.getString(KEY_UPSTREAM_PROXY_PASSWORD, "") ?: "" - set(value) { credentialsPrefs.edit().putString(KEY_UPSTREAM_PROXY_PASSWORD, value).apply() } + val upstreamProxyPort: Int + get() = readCredential { + credentialsPrefs + .getLong( + KEY_UPSTREAM_PROXY_PORT, + prefs.getInt(KEY_UPSTREAM_PROXY_PORT, DEFAULT_UPSTREAM_PROXY_PORT).toLong(), + ) + .toInt() + } + + val upstreamProxyUsername: String + get() = readCredential { credentialsPrefs.getString(KEY_UPSTREAM_PROXY_USERNAME, "") ?: "" } + + val upstreamProxyPassword: String + get() = readCredential { credentialsPrefs.getString(KEY_UPSTREAM_PROXY_PASSWORD, "") ?: "" } var excludedApps: Set get() = prefs.getStringSet(KEY_EXCLUDED_APPS, emptySet())?.toSet() ?: emptySet() set(value) { - prefs.edit().putStringSet(KEY_EXCLUDED_APPS, HashSet(value)).apply() } - var tlsSplitMode: TlsSplitMode - get() = TlsSplitMode.entries.firstOrNull { - it.name == prefs.getString(KEY_TLS_SPLIT_MODE, null) - } ?: TlsSplitMode.OFF - set(value) { prefs.edit().putString(KEY_TLS_SPLIT_MODE, value.name).apply() } - - enum class TlsSplitMode( - val label: String, - val description: String, - ) { - OFF( - label = "Disabled", - description = "Standard TLS handshake without fragmentation", - ), - FIRST_BYTE( - label = "First byte", - description = "Splits TLS record header at byte 0 (0x16)", - ), - SNI( - label = "SNI hostname", - description = "Fragments the SNI extension across packets (defeats DPI)", - ), - ; + enum class SplitTunnelMode { + EXCLUDE, + INCLUDE, + } + + var splitTunnelMode: SplitTunnelMode + get() = + SplitTunnelMode.entries.firstOrNull { + it.name == prefs.getString(KEY_SPLIT_TUNNEL_MODE, null) + } ?: SplitTunnelMode.EXCLUDE + set(value) { + prefs.edit().putString(KEY_SPLIT_TUNNEL_MODE, value.name).apply() + } + + var includedApps: Set + get() = prefs.getStringSet(KEY_INCLUDED_APPS, emptySet())?.toSet() ?: emptySet() + set(value) { + prefs.edit().putStringSet(KEY_INCLUDED_APPS, HashSet(value)).apply() + } + + fun saveSplitTunneling(mode: SplitTunnelMode, excluded: Set, included: Set) { + require(mode != SplitTunnelMode.INCLUDE || included.isNotEmpty()) { + "Select at least one app to include" + } + prefs + .edit() + .putString(KEY_SPLIT_TUNNEL_MODE, mode.name) + .putStringSet(KEY_EXCLUDED_APPS, HashSet(excluded)) + .putStringSet(KEY_INCLUDED_APPS, HashSet(included)) + .apply() + } + + enum class HttpVersion(val versionNumber: Int, val label: String, val description: String) { + HTTP_2(versionNumber = 2, label = "HTTP/2", description = "Multiplexed streams over TCP"), + HTTP_3(versionNumber = 3, label = "HTTP/3", description = "QUIC transport over UDP"), + } + + var httpVersion: HttpVersion + get() = + HttpVersion.entries.firstOrNull { it.name == prefs.getString(KEY_HTTP_VERSION, null) } + ?: HttpVersion.HTTP_2 + set(value) { + prefs.edit().putString(KEY_HTTP_VERSION, value.name).apply() + } + + enum class UpstreamProxyType { + SOCKS5, + HTTP, + } + + enum class TlsFragmentMode(val label: String, val description: String) { + OFF(label = "Disabled", description = "Standard TLS"), + RECORD(label = "Record", description = "TLS record layer"), + PACKET(label = "Packet", description = "TCP segment layer"), + COMBINED(label = "Combined", description = "Record and packet"), } - enum class UpstreamProxyType { SOCKS5, HTTP } - - enum class DnsProvider( - val label: String, - val dohUrl: String?, - ) { - SYSTEM_DEFAULT( - label = "Default", - dohUrl = null, - ), - CLOUDFLARE( - label = "Cloudflare", - dohUrl = "https://1.1.1.1/dns-query", - ), - GOOGLE( - label = "Google", - dohUrl = "https://8.8.8.8/dns-query", - ), - QUAD9( - label = "Quad9", - dohUrl = "https://9.9.9.9/dns-query", - ), - ADGUARD( - label = "AdGuard", - dohUrl = "https://dns.adguard-dns.com/dns-query", - ), - CUSTOM( - label = "Custom DoH", - dohUrl = null, - ), - ; + enum class UtlsFingerprint(val label: String, val identifier: String) { + FIREFOX("Firefox", "firefox"), + CHROME("Chrome", "chrome"), + SAFARI("Safari", "safari"), + IOS("iOS", "ios"), + EDGE("Edge", "edge"), + RANDOMIZED("Randomized", "randomized"), } - enum class BootstrapDnsProvider( - val label: String, - val description: String, - val addresses: List, - ) { - AUTOMATIC( - label = "Automatic", - description = "Races all default DoH endpoints", - addresses = listOf("9.9.9.9", "1.1.1.1", "8.8.8.8"), - ), - QUAD9( - label = "Quad9", - description = "9.9.9.9, 149.112.112.112", - addresses = listOf("9.9.9.9", "149.112.112.112"), - ), - CLOUDFLARE( - label = "Cloudflare", - description = "1.1.1.1, 1.0.0.1", - addresses = listOf("1.1.1.1", "1.0.0.1"), - ), - GOOGLE( - label = "Google", - description = "8.8.8.8, 8.8.4.4", - addresses = listOf("8.8.8.8", "8.8.4.4"), - ), - SYSTEM( - label = "System DNS", - description = "Local network DNS", - addresses = emptyList(), - ), - CUSTOM( - label = "Custom IP", - description = "Specify a custom DoH IP resolver address", - addresses = emptyList(), - ), - ; + enum class DnsProvider(val label: String, val dohUrl: String) { + DIRECT_SYSTEM(label = "System DNS", dohUrl = ""), + GOOGLE(label = "Google", dohUrl = "https://8.8.8.8/dns-query"), + CLOUDFLARE(label = "Cloudflare", dohUrl = "https://1.1.1.1/dns-query"), + QUAD9(label = "Quad9", dohUrl = "https://9.9.9.9/dns-query"), + CUSTOM(label = "Custom DoH", dohUrl = ""); + + val isDirect: Boolean + get() = this == DIRECT_SYSTEM } companion object { + const val DEFAULT_LATENCY_PROBE_URL = "https://www.gstatic.com/generate_204" + private const val KEY_LATENCY_PROBE_URL = "latency_probe_url" + + fun isValidProbeUrl(value: String): Boolean = + runCatching { + val uri = java.net.URI(value) + uri.scheme.equals("https", ignoreCase = true) && + !uri.host.isNullOrBlank() && + uri.rawUserInfo == null && + uri.rawFragment == null && + (uri.port == -1 || uri.port in 1..65_535) + } + .getOrDefault(false) + private const val KEY_THEME_MODE = "theme_mode" - private const val KEY_EXIT_CHECK = "exit_check_enabled" private const val KEY_CUSTOM_MTU = "custom_mtu" private const val KEY_CACHED_REAL_IP = "cached_real_ip" private const val KEY_CACHED_REAL_COUNTRY = "cached_real_country" private const val KEY_CACHED_QUOTA_REMAINING = "cached_quota_remaining" private const val KEY_CACHED_QUOTA_MAX = "cached_quota_max" private const val KEY_CACHED_IS_UNLIMITED = "cached_is_unlimited" - const val DEFAULT_MTU = 1400 + const val DEFAULT_MTU = 1280 const val MIN_MTU = 576 const val MAX_MTU = 9000 - private const val KEY_SOCKS_BIND_ADDRESS = "socks_bind_address" private const val KEY_SOCKS_PORT = "socks_port" - private const val KEY_HTTP_PROXY_ENABLED = "http_proxy_enabled" - private const val KEY_HTTP_PROXY_PORT = "http_proxy_port" private const val KEY_DNS_PROVIDER = "dns_provider" private const val KEY_CUSTOM_DOH_URL = "custom_doh_url" - private const val KEY_BOOTSTRAP_DNS_PROVIDER = "bootstrap_dns_provider" - private const val KEY_CUSTOM_BOOTSTRAP_IP = "custom_bootstrap_ip" + private const val KEY_TLS_FRAGMENT_MODE = "tls_fragment_mode" + private const val KEY_FRAGMENT_FALLBACK_DELAY = "fragment_fallback_delay" + private const val KEY_UTLS_FINGERPRINT = "utls_fingerprint" + private const val KEY_TCP_FAST_OPEN = "tcp_fast_open" + private const val KEY_MAX_CONCURRENT_STREAMS = "max_concurrent_streams" + private const val KEY_IPV6_ENABLED = "ipv6_enabled" + const val DEFAULT_FRAGMENT_FALLBACK_DELAY = 300 + const val DEFAULT_MAX_CONCURRENT_STREAMS = 32 private const val KEY_PROXY_ONLY_MODE = "proxy_only_mode" private const val KEY_CUSTOM_EDGE_ADDRESS = "custom_edge_address" private const val KEY_UPSTREAM_PROXY_ENABLED = "upstream_proxy_enabled" @@ -313,33 +557,91 @@ class SettingsStore(context: Context) { private const val KEY_UPSTREAM_PROXY_USERNAME = "upstream_proxy_username" private const val KEY_UPSTREAM_PROXY_PASSWORD = "upstream_proxy_password" private const val KEY_EXCLUDED_APPS = "excluded_apps" - private const val KEY_TLS_SPLIT_MODE = "tls_split_mode" + private const val KEY_INCLUDED_APPS = "included_apps" + private const val KEY_SPLIT_TUNNEL_MODE = "split_tunnel_mode" + private const val KEY_HTTP_VERSION = "http_version" + const val CONTROLLER_PORT = 9090 const val DEFAULT_SOCKS_BIND_ADDRESS = "127.0.0.1" const val DEFAULT_SOCKS_PORT = 1080 - const val DEFAULT_HTTP_PROXY_PORT = 8080 const val DEFAULT_UPSTREAM_PROXY_PORT = 1080 - val SOCKS_BIND_ADDRESS_PRESETS = listOf( - "127.0.0.1" to "Loopback only (127.0.0.1)", - "0.0.0.0" to "All interfaces (0.0.0.0)", - ) + fun isValidUpstreamProxyCredentials( + type: UpstreamProxyType, + username: String, + password: String, + ): Boolean { + if (username.isEmpty() && password.isEmpty()) return true + if ( + username.isBlank() || + username.any { it.isISOControl() } || + password.any { it.isISOControl() } + ) + return false + return when (type) { + UpstreamProxyType.SOCKS5 -> + username.toByteArray(Charsets.UTF_8).size in 1..255 && + password.toByteArray(Charsets.UTF_8).size in 1..255 + UpstreamProxyType.HTTP -> + ':' !in username && + username.toByteArray(Charsets.UTF_8).size <= 255 && + password.toByteArray(Charsets.UTF_8).size <= 255 + } + } + + fun isLocalProxyLoop(host: String, port: Int, mixedPort: Int): Boolean { + val normalized = host.trim().removeSurrounding("[", "]").removeSuffix(".").lowercase() + val loopback = + normalized == "localhost" || + normalized == "::1" || + normalized == "::ffff:127.0.0.1" || + (isValidIpAddress(normalized) && + runCatching { java.net.InetAddress.getByName(normalized).isLoopbackAddress } + .getOrDefault(false)) + return loopback && (port == mixedPort || port == CONTROLLER_PORT) + } + + fun isValidLocalProxyCredentials(username: String, password: String): Boolean = + username.isNotBlank() && + password.isNotBlank() && + ':' !in username && + username.none { it.isISOControl() } && + password.none { it.isISOControl() } && + username.toByteArray(Charsets.UTF_8).size in 1..255 && + password.toByteArray(Charsets.UTF_8).size in 1..255 fun isValidDohUrl(value: String): Boolean { val trimmed = value.trim() if (!trimmed.startsWith("https://", ignoreCase = true)) return false return runCatching { - val uri = java.net.URI.create(trimmed) - !uri.host.isNullOrBlank() - }.getOrDefault(false) + val uri = java.net.URI.create(trimmed) + !uri.host.isNullOrBlank() && + uri.rawUserInfo == null && + uri.rawFragment == null && + uri.rawQuery == null && + (uri.port == -1 || uri.port in 1..65_535) + } + .getOrDefault(false) } fun isValidIpAddress(value: String): Boolean { - return if (android.os.Build.VERSION.SDK_INT >= 29) { - android.net.InetAddresses.isNumericAddress(value) - } else { - @Suppress("DEPRECATION") - android.util.Patterns.IP_ADDRESS.matcher(value).matches() + if (value.isEmpty()) return false + if (':' !in value) { + val octets = value.split('.') + return octets.size == 4 && + octets.all { octet -> + octet.isNotEmpty() && + octet.length <= 3 && + octet.all { it in '0'..'9' } && + (octet.length == 1 || octet.first() != '0') && + octet.toInt() in 0..255 + } } + if (value.any { it !in "0123456789abcdefABCDEF:." }) return false + return runCatching { + java.net.URI("http://[$value]/").host != null && + java.net.InetAddress.getByName(value) != null + } + .getOrDefault(false) } fun isValidHostname(value: String): Boolean { @@ -361,6 +663,19 @@ class SettingsStore(context: Context) { } } + fun isValidProxyHost(value: String): Boolean { + val host = value.trim().removeSurrounding("[", "]").removeSuffix(".") + if (isValidIpAddress(host) || isValidHostname(host)) return true + + return host.length in 1..63 && + host.any { it.isLetter() } && + !host.startsWith('-') && + !host.endsWith('-') && + host.all { it == '-' || it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' } + } + + fun isValidLocalPort(port: Int): Boolean = port in 1..65_535 && port != CONTROLLER_PORT + fun isValidEdgeHost(value: String): Boolean { val host = value.trim().removeSuffix(".") return isValidHostname(host) || isValidIpAddress(host) diff --git a/app/src/main/java/com/vauth/foxyvpn/data/ThemeMode.kt b/app/src/main/java/com/vauth/foxyvpn/data/ThemeMode.kt new file mode 100644 index 00000000..789dca17 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/data/ThemeMode.kt @@ -0,0 +1,7 @@ +package com.vauth.foxyvpn.data + +enum class ThemeMode { + SYSTEM, + LIGHT, + DARK, +} diff --git a/app/src/main/java/com/vauth/foxyvpn/data/TokenStore.kt b/app/src/main/java/com/vauth/foxyvpn/data/TokenStore.kt index 8a6e442e..e3fa8ae5 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/TokenStore.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/TokenStore.kt @@ -1,42 +1,82 @@ package com.vauth.foxyvpn.data import android.content.Context -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey import com.vauth.foxyvpn.data.model.RuntimeAuth private const val TAG = "TokenStore" class TokenStore(context: Context) { - private val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - - private val prefs = EncryptedSharedPreferences.create( - context, - "foxyvpn_tokens", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - - fun saveAuth(auth: RuntimeAuth) { - prefs.edit() - .putString(KEY_ACCESS_TOKEN, auth.accessToken) - .putString(KEY_REFRESH_TOKEN, auth.refreshToken) - .putLong(KEY_EXPIRES_AT, auth.expiresAtEpochSeconds) - .apply() + private val mutableStorageError = kotlinx.coroutines.flow.MutableStateFlow(null) + val storageError: kotlinx.coroutines.flow.StateFlow = mutableStorageError + private val appContext = context.applicationContext + private val prefs by lazy { SecurePreferences(appContext, "foxyvpn_tokens", ::storageFailure) } + + private fun storageFailure(error: Exception): SecureStorageException { + val failure = error as? SecureStorageException ?: SecureStorageException(error) + mutableStorageError.value = failure.message + return failure } - fun loadAuth(): RuntimeAuth? = runCatching { - val access = prefs.getString(KEY_ACCESS_TOKEN, null)?.takeIf { it.isNotBlank() } ?: return@runCatching null - val refresh = prefs.getString(KEY_REFRESH_TOKEN, null)?.takeIf { it.isNotBlank() } - val expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L) - RuntimeAuth(access, refresh, expiresAt) - }.onFailure { - AppLogger.w(TAG, "could not read the stored session", it) - }.getOrNull() + val sessionEpoch: Long + get() = epoch + + private val invalidationListener = + java.util.concurrent.atomic.AtomicReference<(() -> Unit)?>(null) + var onSessionInvalidated: (() -> Unit)? + get() = invalidationListener.get() + set(value) { + invalidationListener.set(value) + } + + fun clearSessionInvalidationListener(expected: () -> Unit) { + invalidationListener.compareAndSet(expected, null) + } + + fun withCurrentSession(expectedEpoch: Long, block: () -> T): T = + synchronized(sessionLock) { + check(epoch == expectedEpoch && hasStoredSession()) { "Session changed" } + block() + } + + fun saveAuthIfCurrent(auth: RuntimeAuth, expectedEpoch: Long) = + synchronized(sessionLock) { + check(epoch == expectedEpoch) { + "Session changed while credentials were being refreshed" + } + saveAuth(auth) + } + + private fun saveAuth(auth: RuntimeAuth) = + synchronized(sessionLock) { + prefs + .edit() + .putString(KEY_ACCESS_TOKEN, auth.accessToken) + .putString(KEY_REFRESH_TOKEN, auth.refreshToken) + .putLong(KEY_EXPIRES_AT, auth.expiresAtEpochSeconds) + .commit() + } + + fun loadAuth(): RuntimeAuth? = + synchronized(sessionLock) { + runCatching { + val access = prefs.getString(KEY_ACCESS_TOKEN, null)?.takeIf { it.isNotBlank() } + mutableStorageError.value = null + if (access == null) return@runCatching null + val refresh = + prefs.getString(KEY_REFRESH_TOKEN, null)?.takeIf { it.isNotBlank() } + val expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L) + mutableStorageError.value = null + RuntimeAuth(access, refresh, expiresAt) + } + .onFailure { + storageFailure( + it as? Exception ?: IllegalStateException("Could not read credentials", it) + ) + AppLogger.w(TAG, "could not read the stored session", it) + } + .getOrNull() + } fun hasValidAccessToken(): Boolean { val auth = loadAuth() ?: return false @@ -47,13 +87,38 @@ class TokenStore(context: Context) { fun hasStoredSession(): Boolean = loadAuth() != null - fun hasRefreshToken(): Boolean = loadAuth()?.refreshToken != null - fun clear() { - prefs.edit().clear().apply() + clearIfCurrent(null) + } + + fun clearIfCurrent(expectedEpoch: Long?): Boolean { + val listener = + synchronized(sessionLock) { + if (expectedEpoch != null && epoch != expectedEpoch) return false + prefs.edit().clear().commit() + epoch++ + onSessionInvalidated + } + listener?.invoke() + return true + } + + fun completeLoginIfCurrent(auth: RuntimeAuth, expectedEpoch: Long) { + synchronized(sessionLock) { + check(epoch == expectedEpoch) { "Sign-in was superseded" } + prefs + .edit() + .putString(KEY_ACCESS_TOKEN, auth.accessToken) + .putString(KEY_REFRESH_TOKEN, auth.refreshToken) + .putLong(KEY_EXPIRES_AT, auth.expiresAtEpochSeconds) + .commit() + epoch++ + } } companion object { + private val sessionLock = Any() + @Volatile private var epoch = 0L private const val KEY_ACCESS_TOKEN = "access_token" private const val KEY_REFRESH_TOKEN = "refresh_token" private const val KEY_EXPIRES_AT = "expires_at" diff --git a/app/src/main/java/com/vauth/foxyvpn/data/TunnelSettings.kt b/app/src/main/java/com/vauth/foxyvpn/data/TunnelSettings.kt new file mode 100644 index 00000000..3e5da433 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/data/TunnelSettings.kt @@ -0,0 +1,76 @@ +package com.vauth.foxyvpn.data + +data class TunnelSettings( + val automaticRecovery: Boolean, + val dnsProvider: SettingsStore.DnsProvider, + val effectiveCustomEdgeAddress: String?, + val effectiveDohUrl: String, + val excludedApps: Set, + val includedApps: Set, + val splitTunnelMode: SettingsStore.SplitTunnelMode, + val fragmentFallbackDelay: Int, + val httpVersion: SettingsStore.HttpVersion, + val ipv6Enabled: Boolean, + val maxConcurrentStreams: Int, + val mtu: Int, + val localProxyAuthEnabled: Boolean, + val localProxyUsername: String, + val localProxyPassword: String, + val proxyOnlyMode: Boolean, + val socksBindAddress: String, + val socksPort: Int, + val tcpFastOpen: Boolean, + val tlsFragmentMode: SettingsStore.TlsFragmentMode, + val upstreamProxyEnabled: Boolean, + val upstreamProxyHost: String, + val upstreamProxyPassword: String, + val upstreamProxyPort: Int, + val upstreamProxyType: SettingsStore.UpstreamProxyType, + val upstreamProxyUsername: String, + val utlsFingerprint: SettingsStore.UtlsFingerprint, + val bypassLocalNetwork: Boolean = true, +) { + override fun toString() = + "TunnelSettings(proxyOnlyMode=$proxyOnlyMode, httpVersion=$httpVersion)" +} + +fun SettingsStore.tunnelSnapshot(): TunnelSettings = + synchronized(proxySettingsLock) { + require(!upstreamProxyEnabled || !upstreamReviewRequired) { + "Upstream TLS has been removed. Review the upstream proxy settings before connecting." + } + TunnelSettings( + automaticRecovery = automaticRecovery, + bypassLocalNetwork = bypassLocalNetwork, + dnsProvider = dnsProvider, + effectiveCustomEdgeAddress = effectiveCustomEdgeAddress, + effectiveDohUrl = effectiveDohUrl, + excludedApps = excludedApps.toSet(), + includedApps = includedApps.toSet(), + splitTunnelMode = splitTunnelMode, + fragmentFallbackDelay = fragmentFallbackDelay, + httpVersion = httpVersion, + ipv6Enabled = ipv6Enabled, + maxConcurrentStreams = maxConcurrentStreams, + mtu = mtu, + localProxyAuthEnabled = localProxyAuthEnabled, + localProxyUsername = if (localProxyAuthEnabled) localProxyUsername else "", + localProxyPassword = if (localProxyAuthEnabled) localProxyPassword else "", + proxyOnlyMode = proxyOnlyMode, + socksBindAddress = socksBindAddress, + socksPort = socksPort, + tcpFastOpen = tcpFastOpen, + tlsFragmentMode = tlsFragmentMode, + upstreamProxyEnabled = upstreamProxyEnabled, + upstreamProxyHost = if (upstreamProxyEnabled) upstreamProxyHost else "", + upstreamProxyPassword = if (upstreamProxyEnabled) upstreamProxyPassword else "", + upstreamProxyPort = + if (upstreamProxyEnabled) upstreamProxyPort + else SettingsStore.DEFAULT_UPSTREAM_PROXY_PORT, + upstreamProxyType = + if (upstreamProxyEnabled) upstreamProxyType + else SettingsStore.UpstreamProxyType.SOCKS5, + upstreamProxyUsername = if (upstreamProxyEnabled) upstreamProxyUsername else "", + utlsFingerprint = utlsFingerprint, + ) + } diff --git a/app/src/main/java/com/vauth/foxyvpn/data/model/Models.kt b/app/src/main/java/com/vauth/foxyvpn/data/model/Models.kt index 720e94e9..8f26a911 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/model/Models.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/model/Models.kt @@ -1,14 +1,12 @@ package com.vauth.foxyvpn.data.model -enum class ConnectionState { DISCONNECTED, CONNECTING, CONNECTED } +enum class ConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED, +} -data class VpnProtocol( - val name: String, - val host: String = "", - val port: Int = 0, - val scheme: String = "", - val templateString: String = "", -) +data class VpnProtocol(val name: String, val host: String = "", val port: Int = 0) data class VpnServerNode( val hostname: String, @@ -23,11 +21,7 @@ data class VpnCity( val servers: List = emptyList(), ) -data class VpnCountry( - val name: String, - val code: String, - val cities: List = emptyList(), -) +data class VpnCountry(val name: String, val code: String, val cities: List = emptyList()) data class ProxyCandidate( val host: String, @@ -36,12 +30,11 @@ data class ProxyCandidate( val countryName: String, val cityCode: String = "", ) { - val authority: String get() = "$host:$port" + val authority: String + get() = "${if (':' in host) "[$host]" else host}:$port" } data class Entitlement( - val subscribed: Boolean, - val uid: String, val maxBytes: Long?, val limitedBandwidth: Boolean, val quotaRemaining: Long? = null, @@ -51,10 +44,32 @@ data class RuntimeAuth( val accessToken: String, val refreshToken: String?, val expiresAtEpochSeconds: Long, -) +) { + override fun toString() = "RuntimeAuth(expiresAtEpochSeconds=$expiresAtEpochSeconds)" +} + +const val RECOMMENDED_COUNTRY_CODE = "REC" +const val RECOMMENDED_COUNTRY_NAME = "Recommended" + +fun normalizeCountryName(code: String, rawName: String): String { + return if (code.equals(RECOMMENDED_COUNTRY_CODE, ignoreCase = true) || + rawName.equals("Recomended", ignoreCase = true) || + rawName.equals(RECOMMENDED_COUNTRY_NAME, ignoreCase = true) + ) { + RECOMMENDED_COUNTRY_NAME + } else { + rawName + } +} + +val VpnCountry.displayName: String + get() = normalizeCountryName(code, name) + +val ProxyCandidate.displayName: String + get() = normalizeCountryName(countryCode, countryName) fun countryCodeToEmojiFlag(countryCode: String): String { - if (countryCode.equals("REC", ignoreCase = true) || countryCode.length != 2) return "" + if (countryCode.equals(RECOMMENDED_COUNTRY_CODE, ignoreCase = true) || countryCode.length != 2) return "" val upper = countryCode.uppercase() val first = upper[0] val second = upper[1] @@ -66,6 +81,8 @@ fun countryCodeToEmojiFlag(countryCode: String): String { return String(Character.toChars(firstCodePoint)) + String(Character.toChars(secondCodePoint)) } -val VpnCountry.emojiFlag: String get() = countryCodeToEmojiFlag(code) -val ProxyCandidate.emojiFlag: String get() = countryCodeToEmojiFlag(countryCode) +val VpnCountry.emojiFlag: String + get() = countryCodeToEmojiFlag(code) +val ProxyCandidate.emojiFlag: String + get() = countryCodeToEmojiFlag(countryCode) diff --git a/app/src/main/java/com/vauth/foxyvpn/quicksettings/QuickConnectTileService.kt b/app/src/main/java/com/vauth/foxyvpn/quicksettings/QuickConnectTileService.kt index 2e9e2f91..dcdcc40f 100644 --- a/app/src/main/java/com/vauth/foxyvpn/quicksettings/QuickConnectTileService.kt +++ b/app/src/main/java/com/vauth/foxyvpn/quicksettings/QuickConnectTileService.kt @@ -1,5 +1,6 @@ package com.vauth.foxyvpn.quicksettings +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Intent import android.graphics.drawable.Icon @@ -10,9 +11,9 @@ import android.service.quicksettings.TileService import com.vauth.foxyvpn.FoxyVpnApp import com.vauth.foxyvpn.MainActivity import com.vauth.foxyvpn.R -import com.vauth.foxyvpn.data.model.ConnectionState import com.vauth.foxyvpn.data.SettingsStore import com.vauth.foxyvpn.data.TokenStore +import com.vauth.foxyvpn.data.model.ConnectionState import com.vauth.foxyvpn.vpn.FoxyVpnService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -20,6 +21,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class QuickConnectTileService : TileService() { @@ -29,11 +31,8 @@ class QuickConnectTileService : TileService() { override fun onStartListening() { super.onStartListening() listenJob?.cancel() - listenJob = serviceScope.launch { - FoxyVpnService.state.collect { state -> - updateTileState(state) - } - } + listenJob = + serviceScope.launch { FoxyVpnService.state.collect { state -> updateTileState(state) } } } override fun onStopListening() { @@ -45,28 +44,45 @@ class QuickConnectTileService : TileService() { override fun onClick() { super.onClick() when (FoxyVpnService.state.value) { - ConnectionState.CONNECTED, ConnectionState.CONNECTING -> { + ConnectionState.CONNECTED, + ConnectionState.CONNECTING -> { FoxyVpnService.stop(this) } ConnectionState.DISCONNECTED -> { - val app = applicationContext as? FoxyVpnApp - val tokenStore = app?.tokenStore ?: TokenStore(this) - if (!tokenStore.hasStoredSession()) { - launchMainActivityAndCollapse() - return - } + serviceScope.launch { + try { + val app = applicationContext as? FoxyVpnApp + val tokenStore = app?.tokenStore ?: TokenStore(this@QuickConnectTileService) + if (!withContext(Dispatchers.IO) { tokenStore.hasStoredSession() }) { + launchMainActivityAndCollapse() + return@launch + } - val settingsStore = app?.settingsStore ?: SettingsStore(this) - if (settingsStore.proxyOnlyMode) { - FoxyVpnService.start(this) - return - } + if (FoxyVpnService.state.value != ConnectionState.DISCONNECTED) + return@launch + val settingsStore = + app?.settingsStore ?: SettingsStore(this@QuickConnectTileService) + if (settingsStore.proxyOnlyMode) { + FoxyVpnService.start(this@QuickConnectTileService) + return@launch + } - val prepareIntent = VpnService.prepare(this) - if (prepareIntent != null) { - launchMainActivityAndCollapse() - } else { - FoxyVpnService.start(this) + val prepareIntent = VpnService.prepare(this@QuickConnectTileService) + if (prepareIntent != null) { + launchMainActivityAndCollapse() + } else { + FoxyVpnService.start(this@QuickConnectTileService) + } + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (error: Exception) { + com.vauth.foxyvpn.data.AppLogger.w( + "QuickConnectTile", + "Quick connect failed", + error, + ) + launchMainActivityAndCollapse() + } } } } @@ -74,7 +90,7 @@ class QuickConnectTileService : TileService() { private fun updateTileState(state: ConnectionState) { val tile = qsTile ?: return - tile.icon = Icon.createWithResource(this, android.R.drawable.ic_lock_lock) + tile.icon = Icon.createWithResource(this, R.drawable.ic_power) tile.label = getString(R.string.quick_connect) when (state) { ConnectionState.CONNECTED -> { @@ -99,21 +115,23 @@ class QuickConnectTileService : TileService() { tile.updateTile() } + @SuppressLint("StartActivityAndCollapseDeprecated") private fun launchMainActivityAndCollapse() { - val intent = Intent(this, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - } + val intent = + Intent(this, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - val pendingIntent = PendingIntent.getActivity( - this, - 0, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) + val pendingIntent = + PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) startActivityAndCollapse(pendingIntent) } else { - @Suppress("DEPRECATION") - startActivityAndCollapse(intent) + @Suppress("DEPRECATION") startActivityAndCollapse(intent) } } diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/HomeViewModel.kt b/app/src/main/java/com/vauth/foxyvpn/ui/HomeViewModel.kt new file mode 100644 index 00000000..3e906dfc --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/ui/HomeViewModel.kt @@ -0,0 +1,154 @@ +package com.vauth.foxyvpn.ui + +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.vauth.foxyvpn.data.AppLogger +import com.vauth.foxyvpn.data.FxaAuthRepository +import com.vauth.foxyvpn.data.GUARDIAN_ENDPOINT_DEFAULT +import com.vauth.foxyvpn.data.GuardianClient +import com.vauth.foxyvpn.data.SettingsStore +import com.vauth.foxyvpn.vpn.ExitCheck +import com.vauth.foxyvpn.vpn.FoxyVpnService +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class HomeViewModel( + private val settingsStore: SettingsStore, + private val guardianClient: GuardianClient, + private val authRepository: FxaAuthRepository, + private val connectivity: ConnectivityManager?, +) : ViewModel() { + val account = settingsStore.account + private var addressJob: Job? = null + private val _isPinging = MutableStateFlow(false) + val isPinging: StateFlow = _isPinging.asStateFlow() + + private val networkCallback = + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + refreshAddress() + refreshAccount() + } + + override fun onLost(network: Network) { + refreshAddress() + } + + override fun onCapabilitiesChanged( + network: Network, + capabilities: NetworkCapabilities, + ) { + if (capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) { + refreshAddress() + refreshAccount() + } + } + } + + init { + refreshAddress() + refreshAccount() + runCatching { + connectivity?.registerNetworkCallback( + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + .build(), + networkCallback, + ) + } + .onFailure { + AppLogger.w("HomeViewModel", "Could not observe underlying-network changes") + } + } + + fun refreshAccount() { + viewModelScope.launch { + try { + val token = authRepository.currentAccessToken() ?: return@launch + val info = guardianClient.fetchUserInfo(GUARDIAN_ENDPOINT_DEFAULT, token) + val hasKnownQuota = info.quotaRemaining != null || info.maxBytes != null + val isUnlimited = !info.limitedBandwidth && !hasKnownQuota + withContext(Dispatchers.IO) { + settingsStore.saveAccountQuota( + isUnlimited, + info.quotaRemaining, + info.maxBytes, + ) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + AppLogger.w("HomeViewModel", "Account refresh failed; keeping last known quota", e) + } + } + } + + fun triggerPing() { + if (_isPinging.value) return + viewModelScope.launch { + _isPinging.value = true + try { + FoxyVpnService.pingNow() + } finally { + _isPinging.value = false + } + } + } + + private fun refreshAddress() { + addressJob?.cancel() + addressJob = + viewModelScope.launch { + delay(750L) + ExitCheck().fetchDirectIpInfo().onSuccess { info -> + runCatching { + withContext(Dispatchers.IO) { + settingsStore.saveDirectAddress( + info.ip, + info.countryCode, + ) + } + } + .onFailure { if (it is CancellationException) throw it } + } + } + } + + override fun onCleared() { + runCatching { connectivity?.unregisterNetworkCallback(networkCallback) } + super.onCleared() + } + + companion object { + fun factory( + settingsStore: SettingsStore, + guardianClient: GuardianClient, + authRepository: FxaAuthRepository, + connectivity: ConnectivityManager?, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + HomeViewModel( + settingsStore, + guardianClient, + authRepository, + connectivity, + ) + } + } + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/LoginViewModel.kt b/app/src/main/java/com/vauth/foxyvpn/ui/LoginViewModel.kt new file mode 100644 index 00000000..e567df80 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/ui/LoginViewModel.kt @@ -0,0 +1,133 @@ +package com.vauth.foxyvpn.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.vauth.foxyvpn.data.FxaAuthRepository +import com.vauth.foxyvpn.data.isUnconfirmedSession +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout + +data class LoginUiState( + val browserStartUrl: String? = null, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val signedIn: Boolean = false, +) + +class LoginViewModel(private val repository: FxaAuthRepository) : ViewModel() { + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + val browserStartUrl: String? get() = _uiState.value.browserStartUrl + val isLoading: Boolean get() = _uiState.value.isLoading + val errorMessage: String? get() = _uiState.value.errorMessage + val signedIn: Boolean get() = _uiState.value.signedIn + + private var operation: Job? = null + private var generation = 0 + + fun startBrowserLogin() { + if (_uiState.value.isLoading || _uiState.value.signedIn) return + _uiState.update { it.copy(errorMessage = null, browserStartUrl = repository.getBrowserLoginUrl()) } + } + + fun cancelVerification() { + generation++ + operation?.cancel() + operation = null + _uiState.value = LoginUiState() + } + + fun completeBrowserLoginWithTokens( + accessToken: String, + refreshToken: String?, + expiresInSeconds: Long, + ) { + if (operation?.isActive == true || _uiState.value.isLoading || _uiState.value.signedIn || _uiState.value.browserStartUrl == null) return + val requestGeneration = ++generation + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + operation = + viewModelScope.launch { + try { + val result = + withTimeout(120_000L) { + repository.completeBrowserLoginWithTokens( + accessToken, + refreshToken, + expiresInSeconds, + ) + } + if (generation != requestGeneration) return@launch + result + .onSuccess { + _uiState.update { it.copy(browserStartUrl = null, signedIn = true) } + } + .onFailure { + _uiState.update { s -> s.copy(errorMessage = it.message ?: "Browser sign-in failed. Please try again.") } + } + } catch (_: TimeoutCancellationException) { + if (generation == requestGeneration) + _uiState.update { it.copy(errorMessage = "Sign-in timed out. Try again.") } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + if (generation == requestGeneration) + _uiState.update { it.copy(errorMessage = "Sign-in failed. Try again.") } + } finally { + if (generation == requestGeneration) + _uiState.update { it.copy(isLoading = false) } + } + } + } + + fun completeBrowserLogin(sessionToken: String) { + if (operation?.isActive == true || _uiState.value.isLoading || _uiState.value.signedIn || _uiState.value.browserStartUrl == null) return + val requestGeneration = ++generation + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + operation = + viewModelScope.launch { + try { + val result = + withTimeout(120_000L) { repository.completeBrowserLogin(sessionToken) } + if (generation != requestGeneration) return@launch + result + .onSuccess { + _uiState.update { it.copy(browserStartUrl = null, signedIn = true) } + } + .onFailure { + if (!it.isUnconfirmedSession()) { + _uiState.update { s -> + s.copy(errorMessage = it.message ?: "Browser sign-in failed. Please try again.") + } + } + } + } catch (_: TimeoutCancellationException) { + if (generation == requestGeneration) + _uiState.update { it.copy(errorMessage = "Sign-in timed out. Try again.") } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + if (generation == requestGeneration) + _uiState.update { it.copy(errorMessage = "Sign-in failed. Try again.") } + } finally { + if (generation == requestGeneration) + _uiState.update { it.copy(isLoading = false) } + } + } + } + + companion object { + fun factory(repository: FxaAuthRepository) = viewModelFactory { + initializer { LoginViewModel(repository) } + } + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/LogsViewModel.kt b/app/src/main/java/com/vauth/foxyvpn/ui/LogsViewModel.kt new file mode 100644 index 00000000..87748daa --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/ui/LogsViewModel.kt @@ -0,0 +1,181 @@ +package com.vauth.foxyvpn.ui + +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.PersistableBundle +import androidx.core.content.FileProvider +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vauth.foxyvpn.R +import com.vauth.foxyvpn.data.AppLogger +import com.vauth.foxyvpn.data.LogLevel +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +enum class LogFilter(val levels: Set) { + ALL(setOf(LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR)), + INFO(setOf(LogLevel.INFO)), + PROBLEMS(setOf(LogLevel.WARN, LogLevel.ERROR)), +} + +sealed interface LogsUserMessage { + data class StringRes(val resId: Int) : LogsUserMessage + data class PlainText(val text: String) : LogsUserMessage +} + +data class LogsUiState( + val filter: LogFilter = LogFilter.ALL, + val isExporting: Boolean = false, + val userMessage: LogsUserMessage? = null, + val shareFileIntent: Intent? = null, +) + +class LogsViewModel : ViewModel() { + + private val _state = MutableStateFlow(LogsUiState()) + val state: StateFlow = _state.asStateFlow() + + fun setFilter(filter: LogFilter) { + _state.update { it.copy(filter = filter) } + } + + fun clearLogs() { + AppLogger.clear() + } + + fun exportLogs(context: Context, chooserTitle: String) { + if (_state.value.isExporting) return + _state.update { it.copy(isExporting = true) } + val appContext = context.applicationContext + + viewModelScope.launch { + try { + val file = writeLogFile(appContext) + val uri = + FileProvider.getUriForFile( + appContext, + "${appContext.packageName}.fileprovider", + file, + ) + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, file.name) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val chooser = + Intent.createChooser(shareIntent, chooserTitle).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + _state.update { it.copy(shareFileIntent = chooser, isExporting = false) } + } catch (cancelled: CancellationException) { + _state.update { it.copy(isExporting = false) } + throw cancelled + } catch (error: Exception) { + AppLogger.w("LogsViewModel", "Could not export logs", error) + _state.update { + it.copy( + isExporting = false, + userMessage = LogsUserMessage.StringRes(R.string.logs_share_failed), + ) + } + } + } + } + + fun copyLogs(context: Context) { + if (_state.value.isExporting) return + _state.update { it.copy(isExporting = true) } + val appContext = context.applicationContext + + viewModelScope.launch { + try { + val text = withContext(Dispatchers.IO) { AppLogger.exportAsText() } + val clipboard = + appContext.getSystemService(ClipboardManager::class.java) + ?: error("Clipboard unavailable") + val clip = + ClipData.newPlainText("FoxyVPN logs", text).apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + description.extras = + PersistableBundle().apply { + putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + } + } + } + clipboard.setPrimaryClip(clip) + val message = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + LogsUserMessage.StringRes(R.string.logs_copied) + } else { + null + } + _state.update { it.copy(isExporting = false, userMessage = message) } + } catch (cancelled: CancellationException) { + _state.update { it.copy(isExporting = false) } + throw cancelled + } catch (error: Exception) { + AppLogger.w("LogsViewModel", "Could not copy logs", error) + _state.update { + it.copy( + isExporting = false, + userMessage = LogsUserMessage.StringRes(R.string.logs_copy_failed), + ) + } + } + } + } + + fun consumeShareIntent() { + _state.update { it.copy(shareFileIntent = null) } + } + + fun consumeUserMessage() { + _state.update { it.copy(userMessage = null) } + } + + companion object { + private val exportMutex = Mutex() + + private suspend fun writeLogFile(context: Context): File = + withContext(Dispatchers.IO) { + exportMutex.withLock { + val dir = File(context.cacheDir, "logs").apply { mkdirs() } + val now = System.currentTimeMillis() + + dir.listFiles() + ?.filter { + it.isFile && it.name.startsWith("foxyvpn-logs-") && it.extension == "txt" + } + ?.sortedByDescending { it.lastModified() } + ?.forEachIndexed { index, old -> + if (index >= 9 || now - old.lastModified() >= 7 * 24 * 60 * 60_000L) { + old.delete() + } + } + val stamp = + SimpleDateFormat("yyyyMMdd-HHmmss-SSS", Locale.US).format(Date(now)) + val file = File(dir, "foxyvpn-logs-$stamp.txt") + file.writeText(AppLogger.exportAsText()) + file + } + } + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/ProxySettingsViewModel.kt b/app/src/main/java/com/vauth/foxyvpn/ui/ProxySettingsViewModel.kt new file mode 100644 index 00000000..cc37f31b --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/ui/ProxySettingsViewModel.kt @@ -0,0 +1,171 @@ +package com.vauth.foxyvpn.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.vauth.foxyvpn.R +import com.vauth.foxyvpn.data.LocalProxyOptions +import com.vauth.foxyvpn.data.ProxySettingsRepository +import com.vauth.foxyvpn.data.ProxySummary +import com.vauth.foxyvpn.data.SettingsStore +import com.vauth.foxyvpn.data.UpstreamProxyOptions +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +enum class ProxyEditor { + LOCAL, + UPSTREAM_TYPE, + UPSTREAM_ADDRESS, + UPSTREAM_CREDENTIALS, +} + +data class ProxySettingsUiState( + val summary: ProxySummary? = null, + val editor: ProxyEditor? = null, + val local: LocalProxyOptions? = null, + val upstream: UpstreamProxyOptions? = null, + val upstreamAddressReviewed: Boolean = false, + val busy: Boolean = false, + val error: String? = null, + val errorRes: Int? = null, +) + +class ProxySettingsViewModel(private val repository: ProxySettingsRepository) : ViewModel() { + private val mutableState = MutableStateFlow(ProxySettingsUiState()) + val state = mutableState.asStateFlow() + + init { + reload() + } + + fun reload() = perform { mutableState.value = ProxySettingsUiState(repository.summary()) } + + fun open(editor: ProxyEditor) { + if (mutableState.value.busy || mutableState.value.summary == null) return + mutableState.value = + mutableState.value.copy( + editor = editor, + local = null, + upstream = null, + upstreamAddressReviewed = false, + error = null, + errorRes = null, + ) + perform { + val localOpt = if (editor == ProxyEditor.LOCAL) repository.local() else null + val upstreamOpt = if (editor != ProxyEditor.LOCAL) repository.upstream() else null + mutableState.value = + mutableState.value.copy( + local = localOpt, + upstream = upstreamOpt, + ) + } + } + + fun close() { + if (!mutableState.value.busy) + mutableState.value = + mutableState.value.copy( + editor = null, + local = null, + upstream = null, + upstreamAddressReviewed = false, + error = null, + errorRes = null, + ) + } + + fun setUpstreamEnabled(enabled: Boolean) = perform { + repository.setUpstreamEnabled(enabled) + val refreshed = repository.summary() + mutableState.value = mutableState.value.copy(summary = refreshed) + } + + fun save(options: LocalProxyOptions) = perform { + repository.save(options) + val refreshed = repository.summary() + mutableState.value = + mutableState.value.copy( + summary = refreshed, + editor = null, + local = null, + error = null, + errorRes = null, + ) + } + + fun save(options: UpstreamProxyOptions) = perform { + if (mutableState.value.editor == ProxyEditor.UPSTREAM_ADDRESS) { + mutableState.value = mutableState.value.copy(upstreamAddressReviewed = true) + } + if ( + options.enabled && + mutableState.value.summary?.upstreamReviewRequired == true && + !mutableState.value.upstreamAddressReviewed + ) { + mutableState.value = + mutableState.value.copy( + editor = ProxyEditor.UPSTREAM_ADDRESS, + upstream = options, + error = null, + errorRes = R.string.proxy_tls_review_error, + ) + } else if ( + options.enabled && + !SettingsStore.isValidUpstreamProxyCredentials( + options.type, + options.username, + options.password, + ) + ) { + mutableState.value = + mutableState.value.copy( + editor = ProxyEditor.UPSTREAM_CREDENTIALS, + upstream = options, + error = null, + errorRes = R.string.proxy_credentials_invalid_error, + ) + } else { + repository.save(options) + val refreshed = repository.summary() + mutableState.value = + mutableState.value.copy( + summary = refreshed, + editor = null, + upstream = null, + upstreamAddressReviewed = false, + error = null, + errorRes = null, + ) + } + } + + private fun perform(action: suspend () -> Unit) { + if (mutableState.value.busy) return + mutableState.value = mutableState.value.copy(busy = true, error = null, errorRes = null) + viewModelScope.launch { + try { + action() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + mutableState.value = + mutableState.value.copy( + error = error.message ?: "Could not update proxy settings", + errorRes = null, + ) + } finally { + mutableState.value = mutableState.value.copy(busy = false) + } + } + } + + companion object { + fun factory(repository: ProxySettingsRepository) = viewModelFactory { + initializer { ProxySettingsViewModel(repository) } + } + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/ServerListViewModel.kt b/app/src/main/java/com/vauth/foxyvpn/ui/ServerListViewModel.kt new file mode 100644 index 00000000..cbb57194 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/ui/ServerListViewModel.kt @@ -0,0 +1,147 @@ +package com.vauth.foxyvpn.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.vauth.foxyvpn.data.ProxyStateStore +import com.vauth.foxyvpn.data.ServerListClient +import com.vauth.foxyvpn.data.model.ProxyCandidate +import com.vauth.foxyvpn.data.model.VpnCountry +import com.vauth.foxyvpn.vpn.PingUtil +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext + +enum class ServerPingState { + PENDING, + DONE, + FAILED, +} + +data class ServerListUiState( + val countries: List = emptyList(), + val isLoading: Boolean = true, + val errorMessage: String? = null, + val pingResults: Map = emptyMap(), + val pingStates: Map = emptyMap(), + val directoryRevision: Int = 0, +) + +class ServerListViewModel( + private val serverListClient: ServerListClient, + private val proxyStateStore: ProxyStateStore, +) : ViewModel() { + + private val _state = MutableStateFlow(ServerListUiState()) + val state: StateFlow = _state.asStateFlow() + + private val probeSlots = Semaphore(4) + + init { + loadServerList(force = false) + } + + fun loadServerList(force: Boolean = false) { + viewModelScope.launch { + if (_state.value.countries.isEmpty()) { + _state.update { it.copy(isLoading = true, errorMessage = null) } + } + if (!force) { + val cached = withContext(Dispatchers.IO) { serverListClient.loadCachedCountries() } + if (cached.isNotEmpty()) { + _state.update { it.copy(countries = cached, isLoading = false) } + } + } + try { + val fresh = withContext(Dispatchers.IO) { serverListClient.fetchCountries() } + _state.update { current -> + current.copy( + countries = fresh, + isLoading = false, + errorMessage = null, + pingResults = emptyMap(), + pingStates = emptyMap(), + directoryRevision = current.directoryRevision + 1, + ) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + _state.update { current -> + current.copy( + isLoading = false, + errorMessage = error.message ?: "Failed to load server list", + ) + } + } + } + } + + fun probeCityLatency(countryCode: String, cityCode: String, available: List) { + val pingKey = "$countryCode:$cityCode" + if (_state.value.pingStates.containsKey(pingKey)) return + + _state.update { it.copy(pingStates = it.pingStates + (pingKey to ServerPingState.PENDING)) } + + val target = available.firstOrNull()?.let { it.host to it.port } + if (target == null) { + _state.update { it.copy(pingStates = it.pingStates + (pingKey to ServerPingState.FAILED)) } + return + } + + viewModelScope.launch { + val result = + try { + probeSlots.withPermit { + withContext(Dispatchers.IO) { + PingUtil.measureTcpLatencyMs(target.first, target.second) + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + null + } + + if (result != null) { + _state.update { + it.copy( + pingResults = it.pingResults + (pingKey to result), + pingStates = it.pingStates + (pingKey to ServerPingState.DONE), + ) + } + } else { + _state.update { + it.copy(pingStates = it.pingStates + (pingKey to ServerPingState.FAILED)) + } + } + } + } + + fun selectCandidate(candidate: ProxyCandidate) { + proxyStateStore.save(candidate) + } + + fun clearError() { + _state.update { it.copy(errorMessage = null) } + } + + fun setErrorMessage(message: String) { + _state.update { it.copy(errorMessage = message) } + } + + companion object { + fun factory(serverListClient: ServerListClient, proxyStateStore: ProxyStateStore) = + viewModelFactory { + initializer { ServerListViewModel(serverListClient, proxyStateStore) } + } + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/navigation/FoxyNavGraph.kt b/app/src/main/java/com/vauth/foxyvpn/ui/navigation/FoxyNavGraph.kt index 8b8346ab..1b055f9f 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/navigation/FoxyNavGraph.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/navigation/FoxyNavGraph.kt @@ -1,11 +1,21 @@ package com.vauth.foxyvpn.ui.navigation +import android.net.ConnectivityManager 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.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.vauth.foxyvpn.FoxyVpnApp +import com.vauth.foxyvpn.AppContainer +import com.vauth.foxyvpn.ui.HomeViewModel +import com.vauth.foxyvpn.ui.ServerListViewModel import com.vauth.foxyvpn.ui.screens.HomeScreen import com.vauth.foxyvpn.ui.screens.LoginScreen import com.vauth.foxyvpn.ui.screens.LogsScreen @@ -13,6 +23,9 @@ import com.vauth.foxyvpn.ui.screens.ServerListScreen import com.vauth.foxyvpn.ui.screens.SettingsScreen import com.vauth.foxyvpn.ui.screens.SplashScreen import com.vauth.foxyvpn.ui.theme.ThemeController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext object FoxyRoutes { const val SPLASH = "splash" @@ -26,34 +39,54 @@ object FoxyRoutes { @Composable fun FoxyNavGraph( navController: NavHostController = rememberNavController(), - app: FoxyVpnApp, + container: AppContainer, themeController: ThemeController, onRequestConnect: () -> Unit, onDisconnect: () -> Unit, ) { + val context = LocalContext.current + val sessionScope = rememberCoroutineScope() + var signingOut by remember { mutableStateOf(false) } NavHost(navController = navController, startDestination = FoxyRoutes.SPLASH) { composable(FoxyRoutes.SPLASH) { SplashScreen( - authRepository = app.authRepository, + authRepository = container.authRepository, onSignedIn = { - navController.navigate(FoxyRoutes.HOME) { popUpTo(FoxyRoutes.SPLASH) { inclusive = true } } + navController.navigate(FoxyRoutes.HOME) { + popUpTo(FoxyRoutes.SPLASH) { inclusive = true } + } }, onNeedsLogin = { - navController.navigate(FoxyRoutes.LOGIN) { popUpTo(FoxyRoutes.SPLASH) { inclusive = true } } + navController.navigate(FoxyRoutes.LOGIN) { + popUpTo(FoxyRoutes.SPLASH) { inclusive = true } + } }, ) } composable(FoxyRoutes.LOGIN) { LoginScreen( - authRepository = app.authRepository, + authRepository = container.authRepository, onSignedIn = { - navController.navigate(FoxyRoutes.HOME) { popUpTo(FoxyRoutes.LOGIN) { inclusive = true } } + navController.navigate(FoxyRoutes.HOME) { + popUpTo(FoxyRoutes.LOGIN) { inclusive = true } + } }, ) } composable(FoxyRoutes.HOME) { + val homeModel: HomeViewModel = + viewModel( + factory = + HomeViewModel.factory( + settingsStore = container.settingsStore, + guardianClient = container.guardianClient, + authRepository = container.authRepository, + connectivity = context.getSystemService(ConnectivityManager::class.java), + ) + ) HomeScreen( - app = app, + proxyStateStore = container.proxyStateStore, + homeModel = homeModel, themeController = themeController, onRequestConnect = onRequestConnect, onDisconnect = onDisconnect, @@ -62,29 +95,73 @@ fun FoxyNavGraph( ) } composable(FoxyRoutes.SERVERS) { + val serverListModel: ServerListViewModel = + viewModel( + factory = + ServerListViewModel.factory( + serverListClient = container.serverListClient, + proxyStateStore = container.proxyStateStore, + ) + ) ServerListScreen( - serverListClient = app.serverListClient, - proxyStateStore = app.proxyStateStore, + viewModel = serverListModel, + proxyStateStore = container.proxyStateStore, onServerSelected = { navController.popBackStack() }, onBack = { navController.popBackStack() }, ) } composable(FoxyRoutes.SETTINGS) { + val app = context.applicationContext as? com.vauth.foxyvpn.FoxyVpnApp + val updater = app?.appUpdater + var autoUpdates by remember { mutableStateOf(updater?.automaticChecks ?: false) } SettingsScreen( - settingsStore = app.settingsStore, + settingsStore = container.settingsStore, onOpenLogs = { navController.navigate(FoxyRoutes.LOGS) }, - onSignOut = { + signOutInProgress = signingOut, + onCheckForUpdates = { + updater?.showPrompt() + updater?.check() + }, + automaticUpdates = autoUpdates, + onAutomaticUpdatesChange = { enabled -> + updater?.automaticChecks = enabled + autoUpdates = enabled + }, + onSignOut = signOut@{ + if (signingOut) return@signOut + signingOut = true + val epoch = container.tokenStore.sessionEpoch onDisconnect() - app.tokenStore.clear() - app.settingsStore.clearAccountSession() - app.proxyStateStore.clear() - navController.navigate(FoxyRoutes.LOGIN) { popUpTo(0) { inclusive = true } } + sessionScope.launch { + try { + withContext(Dispatchers.IO) { + check(container.tokenStore.clearIfCurrent(epoch)) { + "Session changed. Try signing out again." + } + com.vauth.foxyvpn.data.ControlPlaneHttp.cookieJar.clear() + container.settingsStore.clearAccountSession() + container.proxyStateStore.clear() + } + android.webkit.CookieManager.getInstance().removeAllCookies(null) + navController.navigate(FoxyRoutes.LOGIN) { + popUpTo(0) { inclusive = true } + } + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (error: Exception) { + android.widget.Toast.makeText( + context, + error.message ?: "Could not clear the session", + android.widget.Toast.LENGTH_LONG, + ).show() + } finally { + signingOut = false + } + } }, onBack = { navController.popBackStack() }, ) } - composable(FoxyRoutes.LOGS) { - LogsScreen(onBack = { navController.popBackStack() }) - } + composable(FoxyRoutes.LOGS) { LogsScreen(onBack = { navController.popBackStack() }) } } } diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt index e9a6381c..c8e72c42 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt @@ -3,6 +3,7 @@ package com.vauth.foxyvpn.ui.screens import android.annotation.SuppressLint import android.content.Intent import android.net.Uri +import android.os.Build import android.webkit.CookieManager import android.webkit.JavascriptInterface import android.webkit.WebChromeClient @@ -35,9 +36,13 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import com.vauth.foxyvpn.R import com.vauth.foxyvpn.data.AppLogger +import com.vauth.foxyvpn.data.ControlPlaneHttp +import com.vauth.foxyvpn.data.FxaAuthRepository import java.lang.ref.WeakReference import org.json.JSONObject @@ -50,8 +55,8 @@ private const val CMD_LOGIN = "fxaccounts:login" private val SESSION_TOKEN_HEX = Regex("^[0-9a-f]{64}$") -private const val FIREFOX_ANDROID_UA = - "Mozilla/5.0 (Android 14; Mobile; rv:132.0) Gecko/132.0 Firefox/132.0" +private val FIREFOX_ANDROID_UA = + "Mozilla/5.0 (Android ${Build.VERSION.RELEASE.ifBlank { "14" }}; Mobile; rv:135.0) Gecko/135.0 Firefox/135.0" private fun jsString(value: String): String = JSONObject.quote(value) @@ -93,13 +98,14 @@ private val WEBCHANNEL_HOOK_JS = """ }; var checkVerifyUrl = function(url) { var u = getUrlString(url).toLowerCase(); - return u.indexOf('/verify') !== -1 || u.indexOf('/totp') !== -1 || u.indexOf('/session/verify') !== -1; + return u.indexOf('/verify') !== -1 || u.indexOf('/totp') !== -1 || u.indexOf('/session/verify') !== -1 || + u.indexOf('enter_code') !== -1 || u.indexOf('confirm_signin') !== -1 || u.indexOf('confirm_email') !== -1; }; var checkSuccessUrl = function(url) { var u = getUrlString(url).toLowerCase(); return u.indexOf('confirmed') !== -1 || u.indexOf('connect_another_device') !== -1 || u.indexOf('/pair') !== -1 || u.indexOf('/settings') !== -1 || u.indexOf('/complete') !== -1 || - u.indexOf('success') !== -1; + u.indexOf('/verified') !== -1 || u.indexOf('success') !== -1; }; if (window.fetch) { var origFetch = window.fetch; @@ -163,12 +169,16 @@ private val WEBCHANNEL_HOOK_JS = """ internal class WebChannelBridge( webView: WebView, + private val authRepository: FxaAuthRepository, + private val onTokens: (String, String?, Long) -> Unit, private val onSessionToken: (String) -> Unit, ) { private val webViewRef = WeakReference(webView) @Volatile var pendingSessionToken: String? = null private set + @Volatile + private var exchangeInProgress = false @JavascriptInterface fun onMessage(payload: String) { @@ -178,9 +188,63 @@ internal class WebChannelBridge( @JavascriptInterface fun onSessionVerified() { val token = pendingSessionToken ?: return + exchangeInProgress = false notifySessionToken(token) } + @JavascriptInterface + fun onOAuthResult(status: Int, responseText: String) { + val token = pendingSessionToken + if (status in 200..299) { + val parsed = runCatching { + val json = JSONObject(responseText) + val accessToken = json.getString("access_token") + val refreshToken = json.optString("refresh_token").ifBlank { null } + val expiresIn = json.optLong("expires_in", 0L) + Triple(accessToken, refreshToken, expiresIn) + }.getOrNull() + if (parsed != null) { + val view = webViewRef.get() + if (view != null) { + view.post { onTokens(parsed.first, parsed.second, parsed.third) } + } else { + onTokens(parsed.first, parsed.second, parsed.third) + } + return + } + } + val isUnconfirmed = status == 400 && + (responseText.contains("104") || + responseText.contains("Unconfirmed session", ignoreCase = true) || + responseText.contains("unverified", ignoreCase = true)) + if (isUnconfirmed) { + exchangeInProgress = false + return + } + if (token != null) { + fallbackSession(token) + } + } + + @JavascriptInterface + fun onOAuthError(error: String) { + AppLogger.w(TAG, "in-browser oauth exchange failed: $error") + val token = pendingSessionToken + if (token != null) { + fallbackSession(token) + } + } + + private fun fallbackSession(token: String) { + exchangeInProgress = false + val view = webViewRef.get() + if (view != null) { + view.post { onSessionToken(token) } + } else { + onSessionToken(token) + } + } + @JavascriptInterface fun onMessage(payload: String, originUrl: String) { val host = runCatching { Uri.parse(originUrl).host?.lowercase() }.getOrNull() @@ -210,8 +274,14 @@ internal class WebChannelBridge( .ifBlank { data.optString("token", "").trim() } if (SESSION_TOKEN_HEX.matches(token)) { pendingSessionToken = token - val isVerified = if (data.has("verified")) data.optBoolean("verified", true) else true - if (isVerified) { + val hasVerificationReason = data.has("verificationReason") && + data.optString("verificationReason").isNotBlank() + val isVerified = if (data.has("verified")) { + data.optBoolean("verified", true) + } else { + !hasVerificationReason + } + if (isVerified && !hasVerificationReason) { notifySessionToken(token) } } @@ -226,6 +296,7 @@ internal class WebChannelBridge( .ifBlank { pendingSessionToken ?: "" } if (SESSION_TOKEN_HEX.matches(token)) { pendingSessionToken = token + exchangeInProgress = false notifySessionToken(token) } } @@ -238,11 +309,43 @@ internal class WebChannelBridge( fun notifySessionToken(token: String) { if (!SESSION_TOKEN_HEX.matches(token)) return + if (exchangeInProgress) return + exchangeInProgress = true + pendingSessionToken = token + runCatching { + CookieManager.getInstance().flush() + ControlPlaneHttp.cookieJar.syncFromWebView() + } + val hawkInfo = runCatching { authRepository.buildOAuthHawkHeader(token) }.getOrNull() val view = webViewRef.get() - if (view != null) { - view.post { onSessionToken(token) } - } else { - onSessionToken(token) + if (hawkInfo == null || view == null) { + fallbackSession(token) + return + } + val (hawkHeader, bodyJson) = hawkInfo + val script = """ + (function() { + fetch('https://api.accounts.firefox.com/v1/oauth/token', { + method: 'POST', + headers: { + 'Authorization': ${jsString(hawkHeader)}, + 'Content-Type': 'application/json' + }, + body: ${jsString(bodyJson)} + }).then(function(res) { + return res.text().then(function(text) { + foxyWebChannel.onOAuthResult(res.status, text); + }); + }).catch(function(err) { + foxyWebChannel.onOAuthError(err ? (err.message || ('' + err)) : 'fetch_failed'); + }); + })(); + """.trimIndent() + view.post { + runCatching { view.evaluateJavascript(script, null) } + .onFailure { + fallbackSession(token) + } } } @@ -260,11 +363,12 @@ internal class WebChannelBridge( private fun isSuccessUrl(url: String?): Boolean { if (url.isNullOrBlank()) return false val u = url.lowercase() - return u.contains("/signin/confirmed") || + return u.contains("confirmed") || u.contains("/connect_another_device") || u.contains("/pair") || u.contains("/settings") || u.contains("/complete") || + u.contains("/verified") || u.contains("success") } @@ -273,7 +377,9 @@ private fun isSuccessUrl(url: String?): Boolean { fun BrowserLoginScreen( startUrl: String, busy: Boolean, + authRepository: FxaAuthRepository, errorMessage: String? = null, + onTokensCaptured: (String, String?, Long) -> Unit, onSessionCaptured: (String) -> Unit, onCanceled: () -> Unit, ) { @@ -282,6 +388,7 @@ fun BrowserLoginScreen( var pageTitle by remember { mutableStateOf("") } var pageProgress by remember { mutableIntStateOf(0) } var pageLoaded by remember { mutableStateOf(false) } + val currentOnTokens by rememberUpdatedState(onTokensCaptured) val currentOnSession by rememberUpdatedState(onSessionCaptured) val currentOnCanceled by rememberUpdatedState(onCanceled) @@ -293,6 +400,11 @@ fun BrowserLoginScreen( } } + fun handleTokens(accessToken: String, refreshToken: String?, expiresIn: Long) { + if (busy) return + currentOnTokens(accessToken, refreshToken, expiresIn) + } + fun handleSession(token: String) { if (busy) return currentOnSession(token) @@ -321,16 +433,19 @@ fun BrowserLoginScreen( }, enabled = !busy, ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back), + ) } Column(Modifier.weight(1f)) { Text( - pageTitle.ifBlank { "Sign in with Firefox" }, + pageTitle.ifBlank { stringResource(R.string.login_title) }, style = MaterialTheme.typography.titleMedium, maxLines = 1, ) Text( - "You will return to FoxyVPN automatically after signing in", + stringResource(R.string.browser_login_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -367,7 +482,7 @@ fun BrowserLoginScreen( val cookieManager = CookieManager.getInstance() cookieManager.setAcceptCookie(true) cookieManager.setAcceptThirdPartyCookies(this, true) - val bridge = WebChannelBridge(this, ::handleSession) + val bridge = WebChannelBridge(this, authRepository, ::handleTokens, ::handleSession) bridgeInstance = bridge addJavascriptInterface(bridge, "foxyWebChannel") webViewClient = object : WebViewClient() { @@ -375,8 +490,16 @@ fun BrowserLoginScreen( view: WebView, request: WebResourceRequest, ): Boolean { + val scheme = request.url.scheme?.lowercase().orEmpty() + if (scheme != "http" && scheme != "https") { + return false + } val host = request.url.host?.lowercase().orEmpty() - if (host == "accounts.firefox.com" || host.endsWith(".firefox.com")) { + if (host == "accounts.firefox.com" || + host.endsWith(".firefox.com") || + host == "mozilla.org" || + host.endsWith(".mozilla.org") + ) { return false } runCatching { @@ -416,6 +539,10 @@ fun BrowserLoginScreen( super.onPageFinished(view, url) pageLoaded = true pageTitle = view.title.orEmpty() + runCatching { + CookieManager.getInstance().flush() + ControlPlaneHttp.cookieJar.syncFromWebView() + } if (isSuccessUrl(url)) { bridge.onSessionVerified() } diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/HomeScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/HomeScreen.kt index 838665cb..66021a6c 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/HomeScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -43,105 +44,105 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf -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.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.vauth.foxyvpn.FoxyVpnApp -import com.vauth.foxyvpn.data.GUARDIAN_ENDPOINT_DEFAULT -import com.vauth.foxyvpn.data.RECOMMENDED_COUNTRY_CODE -import com.vauth.foxyvpn.data.ServerListClient +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vauth.foxyvpn.R +import com.vauth.foxyvpn.data.ProxyStateStore +import com.vauth.foxyvpn.data.ThemeMode import com.vauth.foxyvpn.data.formatBytes import com.vauth.foxyvpn.data.model.ConnectionState +import com.vauth.foxyvpn.data.model.displayName import com.vauth.foxyvpn.data.model.emojiFlag +import com.vauth.foxyvpn.ui.HomeViewModel import com.vauth.foxyvpn.ui.theme.LocalFoxyStatusColors import com.vauth.foxyvpn.ui.theme.ThemeController -import com.vauth.foxyvpn.ui.theme.ThemeMode -import com.vauth.foxyvpn.vpn.ExitCheck import com.vauth.foxyvpn.vpn.FoxyVpnService import com.vauth.foxyvpn.vpn.IpLocationInfo -import com.vauth.foxyvpn.vpn.PingUtil +import java.util.Locale import kotlinx.coroutines.delay import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import java.util.Locale @OptIn(ExperimentalFoundationApi::class) @Composable fun HomeScreen( - app: FoxyVpnApp, + proxyStateStore: ProxyStateStore, + homeModel: HomeViewModel, themeController: ThemeController, onRequestConnect: () -> Unit, onDisconnect: () -> Unit, onOpenServers: () -> Unit, onOpenSettings: () -> Unit, ) { - val state by FoxyVpnService.state.collectAsState() - val lastError by FoxyVpnService.lastError.collectAsState() - val liveStats by FoxyVpnService.vpnLiveStats.collectAsState() - val connectedAtEpochMs by FoxyVpnService.connectedAtEpochMs.collectAsState() - val pingLatency by FoxyVpnService.pingLatencyMs.collectAsState() - val vpnEdgeIp by FoxyVpnService.vpnEdgeIp.collectAsState() - - val selectedProxy by app.proxyStateStore.selectedProxyFlow.collectAsState() + val state by FoxyVpnService.state.collectAsStateWithLifecycle() + val proxyOnlyActive by FoxyVpnService.proxyOnlyActive.collectAsStateWithLifecycle() + val lastError by FoxyVpnService.lastError.collectAsStateWithLifecycle() + val liveStats by FoxyVpnService.vpnLiveStats.collectAsStateWithLifecycle() + val connectedAtElapsedMs by FoxyVpnService.connectedAtElapsedMs.collectAsStateWithLifecycle() + val pingLatency by FoxyVpnService.pingLatencyMs.collectAsStateWithLifecycle() + val vpnEdgeIp by FoxyVpnService.vpnEdgeIp.collectAsStateWithLifecycle() + val isPinging by homeModel.isPinging.collectAsStateWithLifecycle() + + val selectedProxy by proxyStateStore.selectedProxyFlow.collectAsStateWithLifecycle() + val activeProxy by FoxyVpnService.activeCandidate.collectAsStateWithLifecycle() + val displayProxy = + if (state == ConnectionState.DISCONNECTED) selectedProxy else activeProxy ?: selectedProxy val statusColors = LocalFoxyStatusColors.current - val targetRingColor = when (state) { - ConnectionState.CONNECTED -> statusColors.connected - ConnectionState.CONNECTING -> statusColors.connecting - ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.outline - } + val targetRingColor = + when (state) { + ConnectionState.CONNECTED -> statusColors.connected + ConnectionState.CONNECTING -> statusColors.connecting + ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.outline + } - val ringColor by animateColorAsState(targetValue = targetRingColor, label = "connect-ring-color") - val scale by animateFloatAsState( - targetValue = if (state == ConnectionState.CONNECTED) 1f else 0.92f, - animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), - label = "connect-scale", - ) + val ringColor by + animateColorAsState(targetValue = targetRingColor, label = "connect-ring-color") + val scale by + animateFloatAsState( + targetValue = if (state == ConnectionState.CONNECTED) 1f else 0.92f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), + label = "connect-scale", + ) val systemInDarkTheme = isSystemInDarkTheme() val haptics = LocalHapticFeedback.current - val scope = rememberCoroutineScope() var elapsedSeconds by remember { mutableLongStateOf(0L) } - var realIp by remember { - mutableStateOf( - app.settingsStore.cachedRealIp?.let { - IpLocationInfo(it, app.settingsStore.cachedRealCountry.orEmpty()) - }, - ) + val account by homeModel.account.collectAsStateWithLifecycle() + val realIp = account.realIp?.let { IpLocationInfo(it, account.country.orEmpty()) } + val quotaRemaining = account.quotaRemaining + val quotaMax = account.quotaMax + val isUnlimited = account.unlimited + + LaunchedEffect(Unit) { + homeModel.refreshAccount() } - var isPinging by remember { mutableStateOf(false) } - var disconnectedPingLatency by remember { mutableStateOf(null) } - var quotaRemaining by remember { mutableStateOf(app.settingsStore.cachedQuotaRemaining) } - var quotaMax by remember { mutableStateOf(app.settingsStore.cachedQuotaMax) } - var isUnlimited by remember { mutableStateOf(app.settingsStore.cachedIsUnlimited) } LaunchedEffect(state) { if (state == ConnectionState.CONNECTED) { - quotaRemaining = app.settingsStore.cachedQuotaRemaining - quotaMax = app.settingsStore.cachedQuotaMax - isUnlimited = app.settingsStore.cachedIsUnlimited + homeModel.refreshAccount() } } - LaunchedEffect(state, connectedAtEpochMs) { - if (state == ConnectionState.CONNECTED && connectedAtEpochMs != null) { + LaunchedEffect(state, connectedAtElapsedMs) { + val startEpoch = connectedAtElapsedMs + if (state == ConnectionState.CONNECTED && startEpoch != null) { while (isActive) { - val now = System.currentTimeMillis() - elapsedSeconds = ((now - connectedAtEpochMs!!) / 1000L).coerceAtLeast(0L) + val now = android.os.SystemClock.elapsedRealtime() + elapsedSeconds = ((now - startEpoch) / 1000L).coerceAtLeast(0L) delay(1000L) } } else { @@ -149,86 +150,63 @@ fun HomeScreen( } } - LaunchedEffect(Unit) { - launch { - ExitCheck().fetchDirectIpInfo().onSuccess { info -> - realIp = info - app.settingsStore.cachedRealIp = info.ip - app.settingsStore.cachedRealCountry = info.countryCode - } - } - launch { - val token = app.authRepository.currentAccessToken() - if (token != null) { - runCatching { app.guardianClient.fetchUserInfo(GUARDIAN_ENDPOINT_DEFAULT, token) } - .onSuccess { entitlement -> - val unlimited = !entitlement.limitedBandwidth - isUnlimited = unlimited - app.settingsStore.cachedIsUnlimited = unlimited - entitlement.quotaRemaining?.let { - quotaRemaining = it - app.settingsStore.cachedQuotaRemaining = it - } - entitlement.maxBytes?.let { - quotaMax = it - app.settingsStore.cachedQuotaMax = it - } - } - } - } - } - Scaffold( topBar = { TopAppBar( - title = { Text("FoxyVPN") }, + title = { Text(stringResource(R.string.app_name)) }, actions = { val mode = themeController.mode val showingDark = themeController.resolveDark(systemInDarkTheme) Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .combinedClickable( - role = Role.Button, - onClick = { themeController.toggle(systemInDarkTheme) }, - onLongClick = { - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - themeController.set(ThemeMode.SYSTEM) - }, - ), + modifier = + Modifier.size(48.dp) + .clip(CircleShape) + .combinedClickable( + role = Role.Button, + onClick = { themeController.toggle(systemInDarkTheme) }, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + themeController.set(ThemeMode.SYSTEM) + }, + ), contentAlignment = Alignment.Center, ) { Icon( - imageVector = when (mode) { - ThemeMode.SYSTEM -> Icons.Filled.BrightnessAuto - ThemeMode.LIGHT -> Icons.Filled.LightMode - ThemeMode.DARK -> Icons.Filled.DarkMode - }, - contentDescription = when (mode) { - ThemeMode.SYSTEM -> - "Theme: follow system. Tap to switch to ${if (showingDark) "light" else "dark"} mode" - ThemeMode.LIGHT -> "Theme: light. Tap for dark mode, long press to follow the system" - ThemeMode.DARK -> "Theme: dark. Tap for light mode, long press to follow the system" - }, + imageVector = + when (mode) { + ThemeMode.SYSTEM -> Icons.Filled.BrightnessAuto + ThemeMode.LIGHT -> Icons.Filled.LightMode + ThemeMode.DARK -> Icons.Filled.DarkMode + }, + contentDescription = + when (mode) { + ThemeMode.SYSTEM -> + if (showingDark) { + stringResource(R.string.theme_desc_system_light) + } else { + stringResource(R.string.theme_desc_system_dark) + } + ThemeMode.LIGHT -> + stringResource(R.string.theme_desc_light) + ThemeMode.DARK -> + stringResource(R.string.theme_desc_dark) + }, tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } IconButton(onClick = onOpenSettings) { Icon( imageVector = Icons.Filled.Settings, - contentDescription = "Settings", + contentDescription = stringResource(R.string.settings_title), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, ) - }, + } ) { padding -> BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .padding(padding), + modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.TopCenter, ) { val scrollState = rememberScrollState() @@ -238,35 +216,38 @@ fun HomeScreen( val dynamicSpace = if (availableExtra > 0.dp) (availableExtra / 2) else 16.dp Column( - modifier = Modifier - .widthIn(max = 520.dp) - .fillMaxWidth() - .verticalScroll(scrollState, enabled = scrollState.maxValue > 0) - .padding(horizontal = 20.dp), + modifier = + Modifier.widthIn(max = 520.dp) + .fillMaxWidth() + .verticalScroll(scrollState, enabled = scrollState.maxValue > 0) + .padding(horizontal = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.height(dynamicSpace)) Box( - modifier = Modifier - .size(180.dp) - .clip(CircleShape) - .background(ringColor.copy(alpha = 0.15f)) - .clickable { - if (state == ConnectionState.DISCONNECTED) onRequestConnect() else onDisconnect() - }, + modifier = + Modifier.size(180.dp) + .clip(CircleShape) + .background(ringColor.copy(alpha = 0.15f)) + .clickable(role = Role.Button) { + if (state == ConnectionState.DISCONNECTED) onRequestConnect() + else onDisconnect() + }, contentAlignment = Alignment.Center, ) { Box( - modifier = Modifier - .size((140 * scale).dp) - .clip(CircleShape) - .background(ringColor.copy(alpha = 0.25f)), + modifier = + Modifier.size((140 * scale).dp) + .clip(CircleShape) + .background(ringColor.copy(alpha = 0.25f)), contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Filled.Power, - contentDescription = if (state == ConnectionState.DISCONNECTED) "Connect" else "Disconnect", + contentDescription = + if (state == ConnectionState.DISCONNECTED) "Connect" + else "Disconnect", tint = ringColor, modifier = Modifier.size(56.dp), ) @@ -276,26 +257,31 @@ fun HomeScreen( Spacer(Modifier.height(heroGap)) Text( - text = when (state) { - ConnectionState.CONNECTED -> "Connected" - ConnectionState.CONNECTING -> "Connecting\u2026" - ConnectionState.DISCONNECTED -> "Disconnected" - }, - style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + text = + when (state) { + ConnectionState.CONNECTED -> "Connected" + ConnectionState.CONNECTING -> "Connecting\u2026" + ConnectionState.DISCONNECTED -> "Disconnected" + }, + style = + MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), ) Spacer(Modifier.height(4.dp)) if (state == ConnectionState.CONNECTING) { Text( - "Tap to cancel", + if (lastError != null) "Retrying connection\u2026 Tap to cancel" else "Tap to cancel", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { Text( - text = if (state == ConnectionState.CONNECTED) formatDuration(elapsedSeconds) else "00:00:00", - style = MaterialTheme.typography.bodyMedium.copy(fontFeatureSettings = "tnum"), + text = + if (state == ConnectionState.CONNECTED) formatDuration(elapsedSeconds) + else "00:00:00", + style = + MaterialTheme.typography.bodyMedium.copy(fontFeatureSettings = "tnum"), color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -308,9 +294,7 @@ fun HomeScreen( textAlign = TextAlign.Center, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), ) } @@ -320,7 +304,10 @@ fun HomeScreen( onClick = onOpenServers, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), ) { Column(Modifier.padding(16.dp)) { Text( @@ -330,18 +317,35 @@ fun HomeScreen( ) Spacer(Modifier.padding(top = 4.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - val flag = selectedProxy?.emojiFlag + val flag = displayProxy?.emojiFlag if (!flag.isNullOrBlank()) { Text(flag, style = MaterialTheme.typography.titleMedium) } else { Icon(imageVector = Icons.Filled.Public, contentDescription = null) } Spacer(Modifier.padding(start = 8.dp)) + val displayName = + displayProxy?.displayName?.ifBlank { displayProxy.countryCode } + ?: stringResource(R.string.home_recommended) Text( - selectedProxy?.let { it.countryName.ifBlank { it.countryCode } } ?: "Recommended", + displayName, style = MaterialTheme.typography.bodyLarge, ) } + if ( + state != ConnectionState.DISCONNECTED && + activeProxy != null && + selectedProxy != null && + activeProxy != selectedProxy + ) { + val nextDisplayName = + selectedProxy?.displayName?.ifBlank { selectedProxy?.countryCode.orEmpty() }.orEmpty() + Text( + stringResource(R.string.home_next_connection, nextDisplayName), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } @@ -350,22 +354,25 @@ fun HomeScreen( Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), ) { Column(Modifier.padding(16.dp)) { val isConnected = state == ConnectionState.CONNECTED - val ipDisplay = if (isConnected) { - vpnEdgeIp?.let { - if (it.countryCode.isNotEmpty()) "${it.ip} • ${it.countryCode}" else it.ip - } ?: "Checking\u2026" - } else { - realIp?.let { - if (it.countryCode.isNotEmpty()) "${it.ip} • ${it.countryCode}" else it.ip - } ?: app.settingsStore.cachedRealIp?.let { cachedIp -> - val cachedCountry = app.settingsStore.cachedRealCountry - if (!cachedCountry.isNullOrEmpty()) "$cachedIp • $cachedCountry" else cachedIp - } ?: "Checking\u2026" - } + val ipDisplay = + if (isConnected) { + vpnEdgeIp?.let { + if (it.countryCode.isNotEmpty()) "${it.ip} • ${it.countryCode}" + else it.ip + } ?: "\u2014" + } else { + realIp?.let { + if (it.countryCode.isNotEmpty()) "${it.ip} • ${it.countryCode}" + else it.ip + } ?: "\u2014" + } Row( modifier = Modifier.fillMaxWidth(), @@ -380,22 +387,32 @@ fun HomeScreen( Spacer(Modifier.height(2.dp)) Text( ipDisplay, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), + style = + MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Bold + ), color = MaterialTheme.colorScheme.onSurface, ) } Spacer(Modifier.width(8.dp)) - val badgeColor = if (isConnected) statusColors.connected else MaterialTheme.colorScheme.error + val badgeColor = + if (isConnected) statusColors.connected + else MaterialTheme.colorScheme.error val badgeBg = badgeColor.copy(alpha = 0.15f) Box( - modifier = Modifier - .clip(RoundedCornerShape(6.dp)) - .background(badgeBg) - .padding(horizontal = 10.dp, vertical = 4.dp), + modifier = + Modifier.clip(RoundedCornerShape(6.dp)) + .background(badgeBg) + .padding(horizontal = 10.dp, vertical = 4.dp) ) { Text( - if (isConnected) "Protected" else "Exposed", - style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold), + if (isConnected) { + if (proxyOnlyActive) "Proxy active" else "Protected" + } else "Exposed", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold + ), color = badgeColor, ) } @@ -408,7 +425,10 @@ fun HomeScreen( Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), ) { Column(Modifier.padding(16.dp)) { val isConnected = state == ConnectionState.CONNECTED @@ -427,54 +447,51 @@ fun HomeScreen( ) Spacer(Modifier.height(2.dp)) Text( - if (isConnected) formatBytes(liveStats.sessionBytes) else "0.0 MB", - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + if (isConnected) formatBytes(liveStats.sessionBytes) + else "0.0 MB", + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold + ), color = MaterialTheme.colorScheme.onSurface, ) } Column( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(8.dp)) - .clickable(role = Role.Button) { - if (!isPinging) { - scope.launch { - isPinging = true - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - if (state == ConnectionState.CONNECTED) { - FoxyVpnService.pingNow() - } else { - val target = selectedProxy ?: app.proxyStateStore.load() ?: run { - val cached = app.serverListClient.loadCachedCountries() - ServerListClient.candidatesForCountry(cached, RECOMMENDED_COUNTRY_CODE).firstOrNull() - ?: cached.firstOrNull { it.code.isNotBlank() }?.let { country -> - ServerListClient.candidatesForCountry(cached, country.code).firstOrNull() - } - } - if (target != null) { - disconnectedPingLatency = PingUtil.measureTcpLatencyMs(target.host, target.port) - } - } - isPinging = false - } + modifier = + Modifier.weight(1f) + .heightIn(min = 48.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable( + enabled = isConnected && !isPinging, + role = Role.Button, + ) { + haptics.performHapticFeedback( + HapticFeedbackType.LongPress + ) + homeModel.triggerPing() } - }, ) { Text( - "Latency", + stringResource(R.string.home_latency), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(2.dp)) - val currentPing = if (isConnected) pingLatency else disconnectedPingLatency + val currentPing = if (isConnected) pingLatency else null Text( - text = when { - isPinging -> "Pinging\u2026" - currentPing != null -> "$currentPing ms" - else -> "\u2014 ms" - }, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = if (currentPing != null) statusColors.connected else MaterialTheme.colorScheme.onSurface, + text = + when { + isPinging -> stringResource(R.string.home_pinging) + currentPing != null -> "$currentPing ms" + else -> "\u2014 ms" + }, + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold + ), + color = + if (currentPing != null) statusColors.connected + else MaterialTheme.colorScheme.onSurface, ) } } @@ -492,36 +509,45 @@ fun HomeScreen( modifier = Modifier.weight(1f), ) Spacer(Modifier.width(8.dp)) - val quotaText = when { - isUnlimited -> "Unlimited" - remaining != null && max != null -> "${formatBytes(remaining)} / ${formatBytes(max)}" - remaining != null -> formatBytes(remaining) - max != null -> "Limited to ${formatBytes(max)}" - else -> "\u2014" - } + val quotaText = + when { + remaining != null && max != null -> + "${formatBytes(remaining)} / ${formatBytes(max)}" + remaining != null -> formatBytes(remaining) + isUnlimited -> "Unlimited" + max != null -> "Limited to ${formatBytes(max)}" + else -> "\u2014" + } Text( quotaText, - style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.weight(1f), + textAlign = TextAlign.End, + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold + ), color = MaterialTheme.colorScheme.onSurface, ) } Spacer(Modifier.height(8.dp)) - val quotaFraction = when { - isUnlimited -> 1f - max != null && max > 0L && remaining != null -> - (remaining.toFloat() / max.toFloat()).coerceIn(0f, 1f) - else -> 0f - } + val quotaFraction = + when { + max != null && max > 0L && remaining != null -> + (remaining.toFloat() / max.toFloat()).coerceIn(0f, 1f) + isUnlimited -> 1f + remaining != null && remaining > 0L -> 1f + else -> 0f + } LinearProgressIndicator( progress = { quotaFraction }, - modifier = Modifier - .fillMaxWidth() - .height(6.dp) - .clip(RoundedCornerShape(3.dp)), - color = if (isConnected) statusColors.connected else MaterialTheme.colorScheme.outlineVariant, + modifier = + Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)), + color = + if (isConnected) statusColors.connected + else MaterialTheme.colorScheme.primary, trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, ) } diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt index 76d801a2..b4712e3a 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt @@ -19,102 +19,80 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vauth.foxyvpn.R import com.vauth.foxyvpn.data.FxaAuthRepository -import com.vauth.foxyvpn.data.isUnconfirmedSession -import kotlinx.coroutines.launch +import com.vauth.foxyvpn.ui.LoginViewModel @Composable -fun LoginScreen( - authRepository: FxaAuthRepository, - onSignedIn: () -> Unit, -) { - var browserStartUrl by rememberSaveable { mutableStateOf(null) } - var completingBrowserLogin by rememberSaveable { mutableStateOf(false) } - var errorMessage by rememberSaveable { mutableStateOf(null) } - val scope = rememberCoroutineScope() - - fun startBrowserLogin() { - if (completingBrowserLogin) return - errorMessage = null - browserStartUrl = authRepository.getBrowserLoginUrl() - } - - fun handleBrowserSession(sessionToken: String) { - if (completingBrowserLogin) return - completingBrowserLogin = true - errorMessage = null - scope.launch { - val result = authRepository.completeBrowserLogin(sessionToken) - completingBrowserLogin = false - result.onSuccess { - browserStartUrl = null - errorMessage = null - onSignedIn() - }.onFailure { - if (!it.isUnconfirmedSession()) { - errorMessage = it.message ?: "Browser sign-in failed. Please try again." - } - } - } - } +fun LoginScreen(authRepository: FxaAuthRepository, onSignedIn: () -> Unit) { + val model: LoginViewModel = + androidx.lifecycle.viewmodel.compose.viewModel( + factory = remember(authRepository) { LoginViewModel.factory(authRepository) } + ) + val uiState by model.uiState.collectAsStateWithLifecycle() + val browserStartUrl = uiState.browserStartUrl + val completingBrowserLogin = uiState.isLoading + val errorMessage = uiState.errorMessage + val storageError by authRepository.storageError.collectAsStateWithLifecycle() + androidx.compose.runtime.LaunchedEffect(uiState.signedIn) { if (uiState.signedIn) onSignedIn() } if (browserStartUrl != null) { BrowserLoginScreen( - startUrl = browserStartUrl!!, + startUrl = browserStartUrl, busy = completingBrowserLogin, + authRepository = authRepository, errorMessage = errorMessage, - onSessionCaptured = ::handleBrowserSession, - onCanceled = { - browserStartUrl = null - completingBrowserLogin = false - errorMessage = null - }, + onTokensCaptured = model::completeBrowserLoginWithTokens, + onSessionCaptured = model::completeBrowserLogin, + onCanceled = model::cancelVerification, ) return } Box( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding() - .navigationBarsPadding() - .imePadding(), + modifier = Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding().imePadding(), contentAlignment = Alignment.Center, ) { Column( - modifier = Modifier - .widthIn(max = 520.dp) - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(24.dp), + modifier = + Modifier.widthIn(max = 520.dp) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(24.dp), verticalArrangement = Arrangement.Center, ) { - Text("Sign in with Firefox", style = MaterialTheme.typography.headlineMedium) + Text( + stringResource(R.string.login_title), + style = MaterialTheme.typography.headlineMedium, + ) Spacer(Modifier.height(8.dp)) Text( - "FoxyVPN runs on the free 50 GB of monthly VPN traffic Mozilla includes with a Firefox account. No subscription is required.", + stringResource(R.string.login_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + storageError?.let { + Spacer(Modifier.height(12.dp)) + Text(it, color = MaterialTheme.colorScheme.error) + } Spacer(Modifier.height(24.dp)) Button( - onClick = { startBrowserLogin() }, + onClick = model::startBrowserLogin, enabled = !completingBrowserLogin, modifier = Modifier.fillMaxWidth(), ) { - Text("Sign in with Firefox") + Text(stringResource(R.string.login_btn_sign_in)) } Text( - "Opens the official Mozilla sign-in page in an in-app browser and returns automatically once you are signed in.", + stringResource(R.string.login_help), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp), @@ -126,9 +104,7 @@ fun LoginScreen( text = it, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), ) } } diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LogsScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LogsScreen.kt index 286b5580..1c2bd0c9 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LogsScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LogsScreen.kt @@ -1,9 +1,5 @@ package com.vauth.foxyvpn.ui.screens -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent import android.widget.Toast import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -13,8 +9,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ContentCopy @@ -28,141 +24,158 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +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.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.core.content.FileProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vauth.foxyvpn.R import com.vauth.foxyvpn.data.AppLogger import com.vauth.foxyvpn.data.LogEntry import com.vauth.foxyvpn.data.LogLevel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File +import com.vauth.foxyvpn.ui.LogFilter +import com.vauth.foxyvpn.ui.LogsUserMessage +import com.vauth.foxyvpn.ui.LogsViewModel import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -private enum class LogFilter(val label: String, val levels: Set) { - ALL("All", setOf(LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR)), - INFO("Info", setOf(LogLevel.INFO)), - PROBLEMS("Problems", setOf(LogLevel.WARN, LogLevel.ERROR)), -} - -private suspend fun writeLogFile(context: Context): File = withContext(Dispatchers.IO) { - val dir = File(context.cacheDir, "logs").apply { mkdirs() } - val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) - val file = File(dir, "foxyvpn-logs-$stamp.txt") - file.writeText(AppLogger.exportAsText()) - file -} +@Composable +private fun LogFilter.label(): String = + when (this) { + LogFilter.ALL -> stringResource(R.string.logs_filter_all) + LogFilter.INFO -> stringResource(R.string.logs_filter_info) + LogFilter.PROBLEMS -> stringResource(R.string.logs_filter_problems) + } @Composable -fun LogsScreen(onBack: () -> Unit) { - val entries by AppLogger.entries.collectAsState() +fun LogsScreen( + onBack: () -> Unit, + viewModel: LogsViewModel = viewModel(), +) { + val entries by AppLogger.entries.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current - val scope = rememberCoroutineScope() val timeFormat = remember { SimpleDateFormat("HH:mm:ss", Locale.US) } - var filter by remember { mutableStateOf(LogFilter.ALL) } + val chooserTitle = stringResource(R.string.logs_share_chooser_title) - val countsByFilter: Map = remember(entries) { - LogFilter.entries.associateWith { option -> entries.count { it.level in option.levels } } + LaunchedEffect(state.shareFileIntent) { + val intent = state.shareFileIntent ?: return@LaunchedEffect + context.startActivity(intent) + viewModel.consumeShareIntent() } - val visible: List = remember(entries, filter) { - if (filter == LogFilter.ALL) entries else entries.filter { it.level in filter.levels } + + LaunchedEffect(state.userMessage) { + val msg = state.userMessage ?: return@LaunchedEffect + val text = + when (msg) { + is LogsUserMessage.StringRes -> context.getString(msg.resId) + is LogsUserMessage.PlainText -> msg.text + } + Toast.makeText(context, text, Toast.LENGTH_SHORT).show() + viewModel.consumeUserMessage() } + val countsByFilter: Map = + remember(entries) { + LogFilter.entries.associateWith { option -> + entries.count { it.level in option.levels } + } + } + val visible: List = + remember(entries, state.filter) { + if (state.filter == LogFilter.ALL) { + entries + } else { + entries.filter { it.level in state.filter.levels } + } + } + Scaffold( topBar = { TopAppBar( title = { Text( - if (visible.size == entries.size) "Logs (${entries.size})" - else "Logs (${visible.size}/${entries.size})", + if (visible.size == entries.size) { + stringResource(R.string.logs_title_all, entries.size) + } else { + stringResource(R.string.logs_title_filtered, visible.size, entries.size) + } ) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back), + ) } }, actions = { - IconButton(onClick = { - if (entries.isEmpty()) { - Toast.makeText(context, "No log entries yet", Toast.LENGTH_SHORT).show() - return@IconButton - } - scope.launch { - val file = writeLogFile(context) - val uri = FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", - file, - ) - val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_STREAM, uri) - putExtra(Intent.EXTRA_SUBJECT, file.name) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity( - Intent.createChooser(shareIntent, "Save or send FoxyVPN logs").apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - }, - ) - } - }) { - Icon(Icons.Filled.Share, contentDescription = "Save/share logs as a file") + IconButton( + enabled = !state.isExporting && entries.isNotEmpty(), + onClick = { viewModel.exportLogs(context, chooserTitle) }, + ) { + Icon( + Icons.Filled.Share, + contentDescription = stringResource(R.string.logs_action_share), + ) } - IconButton(onClick = { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("FoxyVPN logs", AppLogger.exportAsText())) - }) { - Icon(Icons.Filled.ContentCopy, contentDescription = "Copy logs") + IconButton( + enabled = !state.isExporting && entries.isNotEmpty(), + onClick = { viewModel.copyLogs(context) }, + ) { + Icon( + Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.logs_action_copy), + ) } - IconButton(onClick = { AppLogger.clear() }) { - Icon(Icons.Filled.DeleteSweep, contentDescription = "Clear logs") + IconButton( + enabled = entries.isNotEmpty(), + onClick = { viewModel.clearLogs() }, + ) { + Icon( + Icons.Filled.DeleteSweep, + contentDescription = stringResource(R.string.logs_action_clear), + ) } }, ) - }, + } ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 4.dp), + modifier = + Modifier.fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { for (option in LogFilter.entries) { val count = countsByFilter[option] ?: 0 FilterChip( - selected = filter == option, - onClick = { filter = option }, - - label = { Text("${option.label} ($count)") }, + selected = state.filter == option, + onClick = { viewModel.setFilter(option) }, + label = { Text("${option.label()} ($count)") }, ) } } if (visible.isEmpty()) { - Column( - Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - ) { - Text( + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) { + val emptyText = when { - entries.isEmpty() -> "No log entries yet." - filter == LogFilter.PROBLEMS -> "No warnings or errors. Nothing has gone wrong." - else -> "Nothing matches this filter." - }, + entries.isEmpty() -> stringResource(R.string.logs_empty_none) + state.filter == LogFilter.PROBLEMS -> + stringResource(R.string.logs_empty_problems) + else -> stringResource(R.string.logs_empty_filter) + } + Text( + text = emptyText, modifier = Modifier.padding(24.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -170,12 +183,15 @@ fun LogsScreen(onBack: () -> Unit) { } else { LazyColumn(Modifier.fillMaxSize()) { items(visible.asReversed(), key = { it.id }) { entry -> - val color = when (entry.level) { - LogLevel.ERROR -> MaterialTheme.colorScheme.error - LogLevel.WARN -> MaterialTheme.colorScheme.tertiary - LogLevel.INFO -> MaterialTheme.colorScheme.onSurface - } - Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { + val color = + when (entry.level) { + LogLevel.ERROR -> MaterialTheme.colorScheme.error + LogLevel.WARN -> MaterialTheme.colorScheme.tertiary + LogLevel.INFO -> MaterialTheme.colorScheme.onSurface + } + Column( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp) + ) { Text( "${timeFormat.format(Date(entry.timestampMillis))} ${entry.level.name} [${entry.tag}]", style = MaterialTheme.typography.labelSmall, diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/ServerListScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/ServerListScreen.kt index 93996712..0be5cf7f 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/ServerListScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/ServerListScreen.kt @@ -1,12 +1,13 @@ package com.vauth.foxyvpn.ui.screens -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle @@ -17,98 +18,114 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState 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.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vauth.foxyvpn.R import com.vauth.foxyvpn.data.ProxyStateStore import com.vauth.foxyvpn.data.RECOMMENDED_COUNTRY_CODE import com.vauth.foxyvpn.data.ServerListClient -import com.vauth.foxyvpn.data.model.ProxyCandidate import com.vauth.foxyvpn.data.model.VpnCountry import com.vauth.foxyvpn.data.model.emojiFlag -import com.vauth.foxyvpn.vpn.PingUtil -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import com.vauth.foxyvpn.ui.ServerListViewModel +import com.vauth.foxyvpn.ui.ServerPingState private sealed class LocationRow { data class RecommendedRow(val country: VpnCountry) : LocationRow() + data class CountryHeader(val country: VpnCountry) : LocationRow() + data class CityRow(val country: VpnCountry, val cityIndex: Int) : LocationRow() } -private enum class PingState { PENDING, DONE, FAILED } - @Composable fun ServerListScreen( serverListClient: ServerListClient, proxyStateStore: ProxyStateStore, onServerSelected: () -> Unit, onBack: () -> Unit, + viewModel: ServerListViewModel = + viewModel(factory = ServerListViewModel.factory(serverListClient, proxyStateStore)), ) { - var countries by remember { mutableStateOf>(emptyList()) } - var isLoading by remember { mutableStateOf(true) } - var errorMessage by remember { mutableStateOf(null) } - - val selectedState by proxyStateStore.selectedProxyFlow.collectAsState() + ServerListScreen( + viewModel = viewModel, + proxyStateStore = proxyStateStore, + onServerSelected = onServerSelected, + onBack = onBack, + ) +} - val pingResults = remember { mutableStateMapOf() } - val pingStates = remember { mutableStateMapOf() } +@Composable +fun ServerListScreen( + viewModel: ServerListViewModel, + proxyStateStore: ProxyStateStore, + onServerSelected: () -> Unit, + onBack: () -> Unit, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val countries = state.countries + val isLoading = state.isLoading + val errorMessage = state.errorMessage + val selectedState by proxyStateStore.selectedProxyFlow.collectAsStateWithLifecycle() + val snackbarHost = remember { SnackbarHostState() } + val context = LocalContext.current - LaunchedEffect(Unit) { - val cached = withContext(Dispatchers.IO) { serverListClient.loadCachedCountries() } - if (cached.isNotEmpty()) { - countries = cached - isLoading = false + LaunchedEffect(errorMessage) { + val message = errorMessage + if (message != null && countries.isNotEmpty()) { + snackbarHost.showSnackbar(message) + viewModel.clearError() } - runCatching { serverListClient.fetchCountries() } - .onSuccess { fresh -> - countries = fresh - errorMessage = null - } - .onFailure { - if (countries.isEmpty()) { - errorMessage = it.message ?: "Failed to load server list" - } - } - isLoading = false } - val ordered = remember(countries) { - countries.sortedWith(compareBy({ it.code != RECOMMENDED_COUNTRY_CODE }, { it.name })) - } + val ordered = + remember(countries) { + countries.sortedWith(compareBy({ it.code != RECOMMENDED_COUNTRY_CODE }, { it.name })) + } - val rows = remember(ordered) { - buildList { - for (country in ordered) { - if (country.code == RECOMMENDED_COUNTRY_CODE) { - add(LocationRow.RecommendedRow(country)) - continue + val rows = + remember(ordered) { + buildList { + for (country in ordered) { + if (country.code == RECOMMENDED_COUNTRY_CODE) { + add(LocationRow.RecommendedRow(country)) + continue + } + add(LocationRow.CountryHeader(country)) + country.cities.indices.forEach { index -> + add(LocationRow.CityRow(country, index)) + } } - add(LocationRow.CountryHeader(country)) - country.cities.indices.forEach { index -> add(LocationRow.CityRow(country, index)) } } } - } Scaffold( + snackbarHost = { SnackbarHost(snackbarHost) }, topBar = { TopAppBar( - title = { Text("Choose location") }, + title = { Text(stringResource(R.string.server_list_title)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back), + ) } }, ) @@ -117,130 +134,201 @@ fun ServerListScreen( Box(Modifier.fillMaxSize().padding(padding)) { when { isLoading -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - errorMessage != null && countries.isEmpty() -> Text( - text = errorMessage.orEmpty(), - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.error, - modifier = Modifier - .align(Alignment.Center) - .fillMaxWidth() - .padding(24.dp), - ) - else -> LazyColumn { - items( - rows, - key = { row -> - when (row) { - is LocationRow.RecommendedRow -> "recommended:${row.country.code}" - is LocationRow.CountryHeader -> "country:${row.country.code}" - is LocationRow.CityRow -> - "city:${row.country.code}:${row.country.cities[row.cityIndex].code}" + errorMessage != null && countries.isEmpty() -> + Column( + modifier = Modifier.align(Alignment.Center).padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + errorMessage, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + ) + TextButton( + onClick = { + viewModel.loadServerList(force = true) } - }, - ) { row -> - - val selected = selectedState - when (row) { - is LocationRow.RecommendedRow -> ListItem( - leadingContent = { - Icon(Icons.Filled.Public, contentDescription = null) - }, - headlineContent = { Text("Recommended", fontWeight = FontWeight.SemiBold) }, - supportingContent = { Text("Automatically chosen for the best performance") }, - trailingContent = { - if (row.country.code == selected?.countryCode) { - Icon(Icons.Filled.CheckCircle, contentDescription = "Selected") - } - }, - modifier = Modifier - .padding(horizontal = 8.dp) - .clickable { - val candidates = ServerListClient.candidatesForCountry(countries, row.country.code) - val chosen = candidates.randomOrNull() - if (chosen != null) { - proxyStateStore.save(chosen) - onServerSelected() - } else { - errorMessage = "No available servers for ${row.country.name}" - } - }, - ) - is LocationRow.CountryHeader -> ListItem( - leadingContent = { - Text( - text = row.country.emojiFlag, - style = MaterialTheme.typography.titleLarge, + ) { + Text(stringResource(R.string.retry)) + } + } + else -> + LazyColumn(Modifier.selectableGroup()) { + items( + rows, + key = { row -> + when (row) { + is LocationRow.RecommendedRow -> + "recommended:${row.country.code}" + is LocationRow.CountryHeader -> "country:${row.country.code}" + is LocationRow.CityRow -> + "city:${row.country.code}:${row.country.cities[row.cityIndex].code}" + } + }, + ) { row -> + val selected = selectedState + when (row) { + is LocationRow.RecommendedRow -> + ListItem( + leadingContent = { + Icon(Icons.Filled.Public, contentDescription = null) + }, + headlineContent = { + Text( + stringResource(R.string.server_list_recommended_title), + fontWeight = FontWeight.SemiBold, + ) + }, + supportingContent = { + Text(stringResource(R.string.server_list_recommended_desc)) + }, + trailingContent = { + if (row.country.code == selected?.countryCode) { + Icon( + Icons.Filled.CheckCircle, + contentDescription = null, + ) + } + }, + modifier = + Modifier.padding(horizontal = 8.dp) + .selectable( + selected = + row.country.code == selected?.countryCode, + role = Role.RadioButton, + onClick = { + val candidates = + ServerListClient.candidatesForCountry( + countries, + row.country.code, + ) + val chosen = candidates.randomOrNull() + if (chosen != null) { + viewModel.selectCandidate(chosen) + onServerSelected() + } else { + viewModel.setErrorMessage( + context.getString( + R.string.server_list_no_servers_country, + row.country.name, + ) + ) + } + }, + ), + ) + is LocationRow.CountryHeader -> + ListItem( + leadingContent = { + Text( + text = row.country.emojiFlag, + style = MaterialTheme.typography.titleLarge, + ) + }, + headlineContent = { + Text(row.country.name, fontWeight = FontWeight.SemiBold) + }, + modifier = Modifier.padding(horizontal = 8.dp), ) - }, - headlineContent = { Text(row.country.name, fontWeight = FontWeight.SemiBold) }, - modifier = Modifier.padding(horizontal = 8.dp), - ) - is LocationRow.CityRow -> { - val city = row.country.cities[row.cityIndex] - val serverCount = city.servers.size + is LocationRow.CityRow -> { + val city = row.country.cities[row.cityIndex] + val available = + remember(countries, row.country.code, city.code) { + ServerListClient.candidatesForCity( + countries, + row.country.code, + city.code, + ) + } + val serverCount = available.size - val pingKey = "${row.country.code}:${city.code}" - val pingMs = pingResults[pingKey] - val pingState = pingStates[pingKey] ?: PingState.PENDING + val pingKey = "${row.country.code}:${city.code}" + val pingMs = state.pingResults[pingKey] + val pingState = + state.pingStates[pingKey] ?: ServerPingState.PENDING - LaunchedEffect(pingKey) { - if (pingStates.containsKey(pingKey)) return@LaunchedEffect - val target = city.servers.firstOrNull { !it.quarantined } - ?.let { ServerListClient.defaultConnectTarget(it) } - if (target == null) { - pingStates[pingKey] = PingState.FAILED - return@LaunchedEffect - } - val result = PingUtil.measureTcpLatencyMs(target.first, target.second) - if (result != null) { - pingResults[pingKey] = result - pingStates[pingKey] = PingState.DONE - } else { - pingStates[pingKey] = PingState.FAILED + LaunchedEffect(pingKey, available, state.directoryRevision) { + viewModel.probeCityLatency( + row.country.code, + city.code, + available, + ) } - } - ListItem( - - headlineContent = { Text(city.code) }, - supportingContent = { - val primaryHostname = city.servers.firstOrNull()?.hostname - val serverLabel = when { - primaryHostname == null -> "No servers available" - serverCount > 1 -> "$primaryHostname (+${serverCount - 1} more)" - else -> primaryHostname - } - val pingLabel = when { - pingMs != null -> "$pingMs ms" - pingState == PingState.FAILED -> "ping unavailable" - else -> "pinging\u2026" - } - Text("$serverLabel \u2022 $pingLabel") - }, - trailingContent = { - if (row.country.code == selected?.countryCode && - city.code == selected?.cityCode - ) { - Icon(Icons.Filled.CheckCircle, contentDescription = "Selected") - } - }, - modifier = Modifier - .padding(start = 24.dp, end = 8.dp) - .clickable { - val candidates = ServerListClient.candidatesForCity(countries, row.country.code, city.code) - val chosen = candidates.randomOrNull() - if (chosen != null) { - proxyStateStore.save(chosen) - onServerSelected() - } else { - errorMessage = "No available servers in ${city.code}" + ListItem( + headlineContent = { Text(city.code) }, + supportingContent = { + val primaryHostname = available.firstOrNull()?.host + val serverLabel = + when { + primaryHostname == null -> + stringResource(R.string.server_list_no_servers_available) + serverCount > 1 -> + stringResource( + R.string.server_list_more_servers, + primaryHostname, + serverCount - 1, + ) + else -> primaryHostname + } + val pingLabel = + when { + pingMs != null -> + stringResource( + R.string.server_list_tcp_ms, + pingMs, + ) + pingState == ServerPingState.FAILED -> + stringResource(R.string.server_list_tcp_unavailable) + else -> + stringResource(R.string.server_list_checking_tcp) + } + Text("$serverLabel \u2022 $pingLabel") + }, + trailingContent = { + if ( + row.country.code == selected?.countryCode && + city.code == selected?.cityCode + ) { + Icon( + Icons.Filled.CheckCircle, + contentDescription = null, + ) } }, - ) + modifier = + Modifier.padding(start = 24.dp, end = 8.dp) + .selectable( + selected = + row.country.code == selected?.countryCode && + city.code == selected?.cityCode, + role = Role.RadioButton, + onClick = { + val candidates = + ServerListClient.candidatesForCity( + countries, + row.country.code, + city.code, + ) + val chosen = candidates.randomOrNull() + if (chosen != null) { + viewModel.selectCandidate(chosen) + onServerSelected() + } else { + viewModel.setErrorMessage( + context.getString( + R.string.server_list_no_servers_city, + city.code, + ) + ) + } + }, + ), + ) + } } } } - } } } } diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/SettingsScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/SettingsScreen.kt index e449086b..1902a644 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/SettingsScreen.kt @@ -1,42 +1,30 @@ package com.vauth.foxyvpn.ui.screens import android.content.Intent -import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager import android.net.Uri import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.selection.toggleable 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.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.Code -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -44,22 +32,52 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.vauth.foxyvpn.BuildConfig +import com.vauth.foxyvpn.R +import com.vauth.foxyvpn.data.ProxySettingsRepository import com.vauth.foxyvpn.data.SettingsStore -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -private const val GITHUB_URL = "https://github.com/vauth/foxyvpn" +import com.vauth.foxyvpn.ui.ProxyEditor +import com.vauth.foxyvpn.ui.ProxySettingsViewModel +import com.vauth.foxyvpn.ui.settings.ConcurrencyPickerDialog +import com.vauth.foxyvpn.ui.settings.CustomEdgeAddressDialog +import com.vauth.foxyvpn.ui.settings.DnsProviderPickerDialog +import com.vauth.foxyvpn.ui.settings.HttpVersionPickerDialog +import com.vauth.foxyvpn.ui.settings.LatencyProbeDialog +import com.vauth.foxyvpn.ui.settings.MtuPickerDialog +import com.vauth.foxyvpn.ui.settings.ProxySettingsDialog +import com.vauth.foxyvpn.ui.settings.SplitTunnelDialog +import com.vauth.foxyvpn.ui.settings.TlsFragmentPickerDialog +import com.vauth.foxyvpn.ui.settings.UtlsFingerprintPickerDialog +import com.vauth.foxyvpn.vpn.FoxyVpnService +import com.vauth.foxyvpn.vpn.TunnelHealth + +private const val GITHUB_URL = com.vauth.foxyvpn.data.AppLinks.SOURCE + +private enum class SettingsDialog { + NONE, + LATENCY_PROBE, + TLS_FRAGMENT, + UTLS_FINGERPRINT, + CONCURRENCY, + MTU, + DNS_PROVIDER, + EDGE_ADDRESS, + SPLIT_TUNNEL, + HTTP_VERSION, +} @Composable fun SettingsScreen( @@ -67,183 +85,140 @@ fun SettingsScreen( onOpenLogs: () -> Unit, onSignOut: () -> Unit, onBack: () -> Unit, + signOutInProgress: Boolean = false, + onCheckForUpdates: (() -> Unit)? = null, + automaticUpdates: Boolean = false, + onAutomaticUpdatesChange: ((Boolean) -> Unit)? = null, ) { val context = LocalContext.current - var exitCheckEnabled by remember { mutableStateOf(settingsStore.exitCheckEnabled) } + var automaticRecovery by remember { mutableStateOf(settingsStore.automaticRecovery) } + val health by FoxyVpnService.tunnelHealth.collectAsStateWithLifecycle() var dnsProvider by remember { mutableStateOf(settingsStore.dnsProvider) } var customDohUrl by remember { mutableStateOf(settingsStore.customDohUrl) } - var bootstrapDnsProvider by remember { mutableStateOf(settingsStore.bootstrapDnsProvider) } - var customBootstrapIp by remember { mutableStateOf(settingsStore.customBootstrapIp) } - var socksBindAddress by remember { mutableStateOf(settingsStore.socksBindAddress) } - var socksPort by remember { mutableStateOf(settingsStore.socksPort) } - var httpProxyEnabled by remember { mutableStateOf(settingsStore.httpProxyEnabled) } - var httpProxyPort by remember { mutableStateOf(settingsStore.httpProxyPort) } var proxyOnlyMode by remember { mutableStateOf(settingsStore.proxyOnlyMode) } var customEdgeAddress by remember { mutableStateOf(settingsStore.customEdgeAddress) } - var upstreamProxyEnabled by remember { mutableStateOf(settingsStore.upstreamProxyEnabled) } - var upstreamProxyType by remember { mutableStateOf(settingsStore.upstreamProxyType) } - var upstreamProxyHost by remember { mutableStateOf(settingsStore.upstreamProxyHost) } - var upstreamProxyPort by remember { mutableStateOf(settingsStore.upstreamProxyPort) } - var upstreamProxyUsername by remember { mutableStateOf(settingsStore.upstreamProxyUsername) } - var upstreamProxyPassword by remember { mutableStateOf(settingsStore.upstreamProxyPassword) } var excludedApps by remember { mutableStateOf(settingsStore.excludedApps) } - var mtu by remember { mutableStateOf(settingsStore.mtu) } - var showDnsProviderDialog by remember { mutableStateOf(false) } - var showBootstrapDnsDialog by remember { mutableStateOf(false) } - var showMtuDialog by remember { mutableStateOf(false) } - var showSocksBindDialog by remember { mutableStateOf(false) } - var showSocksPortDialog by remember { mutableStateOf(false) } - var showHttpPortDialog by remember { mutableStateOf(false) } - var showEdgeAddressDialog by remember { mutableStateOf(false) } - var showUpstreamProxyTypeDialog by remember { mutableStateOf(false) } - var showUpstreamProxyAddressDialog by remember { mutableStateOf(false) } - var showUpstreamProxyCredentialsDialog by remember { mutableStateOf(false) } - var showSplitTunnelDialog by remember { mutableStateOf(false) } - var tlsSplitMode by remember { mutableStateOf(settingsStore.tlsSplitMode) } - var showTlsSplitDialog by remember { mutableStateOf(false) } + var includedApps by remember { mutableStateOf(settingsStore.includedApps) } + var bypassLocalNetwork by remember { mutableStateOf(settingsStore.bypassLocalNetwork) } + var splitTunnelMode by remember { mutableStateOf(settingsStore.splitTunnelMode) } + var mtu by remember { mutableIntStateOf(settingsStore.mtu) } + var latencyProbeUrl by remember { mutableStateOf(settingsStore.latencyProbeUrl) } + var httpVersion by remember { mutableStateOf(settingsStore.httpVersion) } + var tlsFragmentMode by remember { mutableStateOf(settingsStore.tlsFragmentMode) } + var fragmentFallbackDelay by remember { mutableIntStateOf(settingsStore.fragmentFallbackDelay) } + var utlsFingerprint by remember { mutableStateOf(settingsStore.utlsFingerprint) } + var tcpFastOpen by remember { mutableStateOf(settingsStore.tcpFastOpen) } + var maxConcurrentStreams by remember { mutableIntStateOf(settingsStore.maxConcurrentStreams) } + var ipv6Enabled by remember { mutableStateOf(settingsStore.ipv6Enabled) } + var activeDialog by rememberSaveable { mutableStateOf(SettingsDialog.NONE) } + + val proxyViewModel: ProxySettingsViewModel = + viewModel( + factory = + remember(settingsStore) { + ProxySettingsViewModel.factory(ProxySettingsRepository(settingsStore)) + } + ) + val proxyState by proxyViewModel.state.collectAsStateWithLifecycle() + ProxySettingsDialog( + proxyState, + proxyViewModel::close, + proxyViewModel::open, + proxyViewModel::save, + proxyViewModel::save, + ) - if (showMtuDialog) { - MtuPickerDialog( - current = mtu, - onDismiss = { showMtuDialog = false }, + when (activeDialog) { + SettingsDialog.NONE -> {} + SettingsDialog.LATENCY_PROBE -> LatencyProbeDialog( + current = latencyProbeUrl, + onDismiss = { activeDialog = SettingsDialog.NONE }, onConfirm = { - mtu = it - settingsStore.mtu = it - showMtuDialog = false + settingsStore.latencyProbeUrl = it + latencyProbeUrl = it + activeDialog = SettingsDialog.NONE }, ) - } - - if (showDnsProviderDialog) { - DnsProviderPickerDialog( - currentProvider = dnsProvider, - currentCustomUrl = customDohUrl, - onDismiss = { showDnsProviderDialog = false }, - onConfirm = { provider, url -> - dnsProvider = provider - settingsStore.dnsProvider = provider - customDohUrl = url - settingsStore.customDohUrl = url - showDnsProviderDialog = false + SettingsDialog.TLS_FRAGMENT -> TlsFragmentPickerDialog( + currentMode = tlsFragmentMode, + currentDelay = fragmentFallbackDelay, + onDismiss = { activeDialog = SettingsDialog.NONE }, + onConfirm = { mode, delay -> + tlsFragmentMode = mode + settingsStore.tlsFragmentMode = mode + fragmentFallbackDelay = delay + settingsStore.fragmentFallbackDelay = delay + activeDialog = SettingsDialog.NONE }, ) - } - if (showBootstrapDnsDialog) { - BootstrapDnsPickerDialog( - currentProvider = bootstrapDnsProvider, - currentCustomIp = customBootstrapIp, - onDismiss = { showBootstrapDnsDialog = false }, - onConfirm = { provider, ip -> - bootstrapDnsProvider = provider - settingsStore.bootstrapDnsProvider = provider - customBootstrapIp = ip - settingsStore.customBootstrapIp = ip - showBootstrapDnsDialog = false + SettingsDialog.UTLS_FINGERPRINT -> UtlsFingerprintPickerDialog( + current = utlsFingerprint, + onDismiss = { activeDialog = SettingsDialog.NONE }, + onConfirm = { + utlsFingerprint = it + settingsStore.utlsFingerprint = it + activeDialog = SettingsDialog.NONE }, ) - } - if (showSocksBindDialog) { - SocksBindAddressPickerDialog( - current = socksBindAddress, - onDismiss = { showSocksBindDialog = false }, + SettingsDialog.CONCURRENCY -> ConcurrencyPickerDialog( + current = maxConcurrentStreams, + onDismiss = { activeDialog = SettingsDialog.NONE }, onConfirm = { - socksBindAddress = it - settingsStore.socksBindAddress = it - showSocksBindDialog = false + maxConcurrentStreams = it + settingsStore.maxConcurrentStreams = it + activeDialog = SettingsDialog.NONE }, ) - } - if (showSocksPortDialog) { - SocksPortPickerDialog( - current = socksPort, - onDismiss = { showSocksPortDialog = false }, + SettingsDialog.MTU -> MtuPickerDialog( + current = mtu, + minimum = if (ipv6Enabled) 1280 else SettingsStore.MIN_MTU, + onDismiss = { activeDialog = SettingsDialog.NONE }, onConfirm = { - socksPort = it - settingsStore.socksPort = it - showSocksPortDialog = false + mtu = it + settingsStore.mtu = it + activeDialog = SettingsDialog.NONE }, ) - } - if (showHttpPortDialog) { - HttpPortPickerDialog( - current = httpProxyPort, - onDismiss = { showHttpPortDialog = false }, - onConfirm = { - httpProxyPort = it - settingsStore.httpProxyPort = it - showHttpPortDialog = false + SettingsDialog.DNS_PROVIDER -> DnsProviderPickerDialog( + currentProvider = dnsProvider, + currentCustomUrl = customDohUrl, + onDismiss = { activeDialog = SettingsDialog.NONE }, + onConfirm = { provider, url -> + settingsStore.saveDns(provider, url) + dnsProvider = provider + customDohUrl = settingsStore.customDohUrl + activeDialog = SettingsDialog.NONE }, ) - } - if (showEdgeAddressDialog) { - CustomEdgeAddressDialog( + SettingsDialog.EDGE_ADDRESS -> CustomEdgeAddressDialog( current = customEdgeAddress, - onDismiss = { showEdgeAddressDialog = false }, + onDismiss = { activeDialog = SettingsDialog.NONE }, onConfirm = { customEdgeAddress = it settingsStore.customEdgeAddress = it - showEdgeAddressDialog = false + activeDialog = SettingsDialog.NONE }, ) - } - if (showUpstreamProxyTypeDialog) { - UpstreamProxyTypePickerDialog( - current = upstreamProxyType, - onDismiss = { showUpstreamProxyTypeDialog = false }, - onConfirm = { - upstreamProxyType = it - settingsStore.upstreamProxyType = it - showUpstreamProxyTypeDialog = false + SettingsDialog.SPLIT_TUNNEL -> SplitTunnelDialog( + currentMode = splitTunnelMode, + currentExcluded = excludedApps, + currentIncluded = includedApps, + onDismiss = { activeDialog = SettingsDialog.NONE }, + onConfirm = { mode, excluded, included -> + settingsStore.saveSplitTunneling(mode, excluded, included) + splitTunnelMode = mode + excludedApps = excluded + includedApps = included + activeDialog = SettingsDialog.NONE }, ) - } - if (showUpstreamProxyAddressDialog) { - UpstreamProxyAddressDialog( - currentHost = upstreamProxyHost, - currentPort = upstreamProxyPort, - onDismiss = { showUpstreamProxyAddressDialog = false }, - onConfirm = { host, port -> - upstreamProxyHost = host - upstreamProxyPort = port - settingsStore.upstreamProxyHost = host - settingsStore.upstreamProxyPort = port - showUpstreamProxyAddressDialog = false - }, - ) - } - if (showUpstreamProxyCredentialsDialog) { - UpstreamProxyCredentialsDialog( - currentUsername = upstreamProxyUsername, - currentPassword = upstreamProxyPassword, - onDismiss = { showUpstreamProxyCredentialsDialog = false }, - onConfirm = { username, password -> - upstreamProxyUsername = username - upstreamProxyPassword = password - settingsStore.upstreamProxyUsername = username - settingsStore.upstreamProxyPassword = password - showUpstreamProxyCredentialsDialog = false - }, - ) - } - if (showSplitTunnelDialog) { - SplitTunnelDialog( - current = excludedApps, - onDismiss = { showSplitTunnelDialog = false }, + SettingsDialog.HTTP_VERSION -> HttpVersionPickerDialog( + current = httpVersion, + onDismiss = { activeDialog = SettingsDialog.NONE }, onConfirm = { - excludedApps = it - settingsStore.excludedApps = it - showSplitTunnelDialog = false - }, - ) - } - if (showTlsSplitDialog) { - TlsSplitModePickerDialog( - current = tlsSplitMode, - onDismiss = { showTlsSplitDialog = false }, - onConfirm = { - tlsSplitMode = it - settingsStore.tlsSplitMode = it - showTlsSplitDialog = false + httpVersion = it + settingsStore.httpVersion = it + activeDialog = SettingsDialog.NONE }, ) } @@ -251,805 +226,427 @@ fun SettingsScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Settings") }, + title = { Text(stringResource(R.string.settings_title)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back), + ) } }, ) - }, + } ) { padding -> - Column( - Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()), - ) { - SectionLabel("Connection") + Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())) { + SectionLabel(stringResource(R.string.settings_section_connection)) ListItem( - headlineContent = { Text("Verify exit location") }, - supportingContent = { Text("Check that the tunnel exits in the selected country") }, + headlineContent = { Text(stringResource(R.string.settings_always_on_vpn)) }, + supportingContent = { Text(stringResource(R.string.settings_always_on_help)) }, trailingContent = { - Switch( - checked = exitCheckEnabled, - onCheckedChange = { - exitCheckEnabled = it - settingsStore.exitCheckEnabled = it - }, - ) + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, + modifier = + Modifier.clickable { + runCatching { + context.startActivity( + Intent(android.provider.Settings.ACTION_VPN_SETTINGS) + ) + } + .onFailure { + android.widget.Toast.makeText( + context, + "VPN settings are unavailable on this device", + android.widget.Toast.LENGTH_SHORT, + ) + .show() + } + }, ) ListItem( - headlineContent = { Text("Tunnel MTU") }, + headlineContent = { Text(stringResource(R.string.settings_latency_test_url)) }, + supportingContent = { Text(latencyProbeUrl) }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, + modifier = Modifier.clickable { activeDialog = SettingsDialog.LATENCY_PROBE }, + ) + ListItem( + headlineContent = { Text(stringResource(R.string.settings_tunnel_mtu)) }, supportingContent = { Text(mtu.toString()) }, - trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, - modifier = Modifier.clickable { showMtuDialog = true }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, + modifier = Modifier.clickable { activeDialog = SettingsDialog.MTU }, ) HorizontalDivider(Modifier.padding(vertical = 8.dp)) - SectionLabel("DNS") + SectionLabel(stringResource(R.string.settings_section_dns)) ListItem( - headlineContent = { Text("Tunnel DNS") }, + headlineContent = { Text(stringResource(R.string.settings_dns_provider)) }, supportingContent = { - val subtitle = when (dnsProvider) { - SettingsStore.DnsProvider.SYSTEM_DEFAULT -> "Default" - SettingsStore.DnsProvider.CUSTOM -> customDohUrl.ifBlank { "Custom DoH (Not configured)" } - else -> dnsProvider.label - } + val subtitle = + when (dnsProvider) { + SettingsStore.DnsProvider.CUSTOM -> + customDohUrl.ifBlank { "Custom DoH" } + else -> dnsProvider.label + } Text(subtitle) }, - trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, - modifier = Modifier.clickable { showDnsProviderDialog = true }, - ) - ListItem( - headlineContent = { Text("Resolver DNS") }, - supportingContent = { - val subtitle = when (bootstrapDnsProvider) { - SettingsStore.BootstrapDnsProvider.CUSTOM -> customBootstrapIp.ifBlank { "Custom IP (Not configured)" } - SettingsStore.BootstrapDnsProvider.SYSTEM -> "System DNS" - else -> bootstrapDnsProvider.label - } - Text(subtitle) + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, - trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, - modifier = Modifier.clickable { showBootstrapDnsDialog = true }, + modifier = Modifier.clickable { activeDialog = SettingsDialog.DNS_PROVIDER }, ) HorizontalDivider(Modifier.padding(vertical = 8.dp)) - SectionLabel("Local proxy") - ListItem( - headlineContent = { Text("Proxy-only mode") }, - supportingContent = { Text("Run local proxy servers (SOCKS5 & HTTP), without a VPN interface") }, - trailingContent = { - Switch( - checked = proxyOnlyMode, - onCheckedChange = { - proxyOnlyMode = it - settingsStore.proxyOnlyMode = it - }, - ) + SectionLabel(stringResource(R.string.proxy)) + SettingsToggle( + title = stringResource(R.string.settings_proxy_only_mode), + summary = stringResource(R.string.settings_proxy_only_help), + checked = proxyOnlyMode, + onCheckedChange = { + settingsStore.proxyOnlyMode = it + proxyOnlyMode = it }, ) + if (proxyState.editor == null && proxyState.error != null) { + Text( + proxyState.error.orEmpty(), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp), + ) + TextButton(onClick = proxyViewModel::reload) { + Text(stringResource(R.string.retry)) + } + } ListItem( - headlineContent = { Text("Local address") }, - supportingContent = { Text(socksBindAddress) }, - trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, - modifier = Modifier.clickable { showSocksBindDialog = true }, - ) - ListItem( - headlineContent = { Text("SOCKS5 port") }, - supportingContent = { Text(socksPort.toString()) }, - trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, - modifier = Modifier.clickable { showSocksPortDialog = true }, - ) - ListItem( - headlineContent = { Text("Enable HTTP proxy") }, - supportingContent = { Text("Run local HTTP proxy alongside SOCKS5") }, - trailingContent = { - Switch( - checked = httpProxyEnabled, - onCheckedChange = { - httpProxyEnabled = it - settingsStore.httpProxyEnabled = it - }, + headlineContent = { Text(stringResource(R.string.local_proxy)) }, + supportingContent = { + val summary = proxyState.summary + Text( + if (summary == null) stringResource(R.string.loading) + else + stringResource( + R.string.local_proxy_summary, + SettingsStore.DEFAULT_SOCKS_BIND_ADDRESS, + summary.localPort, + ) ) }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, + modifier = + Modifier.clickable(enabled = !proxyState.busy && proxyState.summary != null) { + proxyViewModel.open(ProxyEditor.LOCAL) + }, ) - if (httpProxyEnabled) { - ListItem( - headlineContent = { Text("HTTP port") }, - supportingContent = { Text(httpProxyPort.toString()) }, - trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, - modifier = Modifier.clickable { showHttpPortDialog = true }, - ) - } - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - SectionLabel("Split tunneling") + SectionLabel(stringResource(R.string.settings_section_split_tunnel)) + SettingsToggle( + title = stringResource(R.string.settings_bypass_local_network), + summary = stringResource(R.string.settings_bypass_local_network_help), + checked = bypassLocalNetwork, + enabled = !proxyOnlyMode, + onCheckedChange = { + settingsStore.bypassLocalNetwork = it + bypassLocalNetwork = it + }, + ) ListItem( - headlineContent = { Text("Excluded apps") }, + headlineContent = { + Text( + if (splitTunnelMode == SettingsStore.SplitTunnelMode.INCLUDE) + stringResource(R.string.settings_included_apps) + else stringResource(R.string.settings_excluded_apps) + ) + }, supportingContent = { Text( - if (excludedApps.isEmpty()) { - "None" - } else { - "${excludedApps.size} app(s) bypass the VPN" - }, + (if (splitTunnelMode == SettingsStore.SplitTunnelMode.INCLUDE) includedApps + else excludedApps) + .let { + if (it.isEmpty()) stringResource(R.string.settings_none) + else pluralStringResource(R.plurals.settings_apps_count, it.size, it.size) + } ) }, - trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, - modifier = Modifier.clickable { showSplitTunnelDialog = true }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, + modifier = Modifier.clickable { activeDialog = SettingsDialog.SPLIT_TUNNEL }, ) HorizontalDivider(Modifier.padding(vertical = 8.dp)) - SectionLabel("Advanced") + SectionLabel(stringResource(R.string.settings_section_advanced)) ListItem( - headlineContent = { Text("Custom edge address") }, - supportingContent = { Text(customEdgeAddress.ifBlank { "Not set" }) }, - trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, - modifier = Modifier.clickable { showEdgeAddressDialog = true }, - ) - ListItem( - headlineContent = { Text("TLS fragmentation") }, - supportingContent = { Text(tlsSplitMode.label) }, - trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, - modifier = Modifier.clickable { showTlsSplitDialog = true }, + headlineContent = { Text(stringResource(R.string.settings_custom_edge_address)) }, + supportingContent = { Text(customEdgeAddress.ifBlank { stringResource(R.string.not_set) }) }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, + modifier = Modifier.clickable { activeDialog = SettingsDialog.EDGE_ADDRESS }, ) ListItem( - headlineContent = { Text("Chain through upstream proxy") }, - supportingContent = { Text("Connect to the VPN server through another proxy first") }, + headlineContent = { Text(stringResource(R.string.settings_transport_protocol)) }, + supportingContent = { Text(httpVersion.label) }, trailingContent = { - Switch( - checked = upstreamProxyEnabled, - onCheckedChange = { - upstreamProxyEnabled = it - settingsStore.upstreamProxyEnabled = it - }, - ) + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, + modifier = Modifier.clickable { activeDialog = SettingsDialog.HTTP_VERSION }, + ) + SettingsToggle( + title = stringResource(R.string.upstream_proxy), + summary = stringResource(R.string.upstream_proxy_help), + checked = proxyState.summary?.upstreamEnabled ?: settingsStore.upstreamProxyEnabled, + enabled = + !proxyState.busy && + (proxyState.summary != null || settingsStore.upstreamProxyEnabled), + onCheckedChange = proxyViewModel::setUpstreamEnabled, ) - if (upstreamProxyEnabled) { + if (proxyState.summary?.upstreamEnabled == true) { + if (proxyState.summary?.upstreamReviewRequired == true) { + Text( + stringResource(R.string.upstream_review_required), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } ListItem( - headlineContent = { Text("Proxy type") }, - supportingContent = { Text(upstreamProxyType.name) }, - trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, - modifier = Modifier.clickable { showUpstreamProxyTypeDialog = true }, + headlineContent = { Text(stringResource(R.string.proxy_type)) }, + supportingContent = { Text(proxyState.summary?.upstreamType?.name.orEmpty()) }, + trailingContent = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + ) + }, + modifier = + Modifier.clickable(enabled = !proxyState.busy) { + proxyViewModel.open(ProxyEditor.UPSTREAM_TYPE) + }, ) ListItem( - headlineContent = { Text("Proxy address") }, + headlineContent = { Text(stringResource(R.string.proxy_address)) }, supportingContent = { + val summary = proxyState.summary + val host = summary?.upstreamHost.orEmpty() Text( - if (upstreamProxyHost.isBlank()) { - "Not set" - } else { - "$upstreamProxyHost:$upstreamProxyPort" - }, + if (host.isBlank()) stringResource(R.string.not_set) + else "${if (':' in host) "[$host]" else host}:${summary?.upstreamPort}" + ) + }, + trailingContent = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, ) }, - trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, - modifier = Modifier.clickable { showUpstreamProxyAddressDialog = true }, + modifier = + Modifier.clickable(enabled = !proxyState.busy) { + proxyViewModel.open(ProxyEditor.UPSTREAM_ADDRESS) + }, ) ListItem( - headlineContent = { Text("Proxy credentials") }, - supportingContent = { - Text(if (upstreamProxyUsername.isBlank()) "None" else upstreamProxyUsername) + headlineContent = { Text(stringResource(R.string.proxy_credentials)) }, + supportingContent = { Text(stringResource(R.string.proxy_credentials_help)) }, + trailingContent = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + ) }, - trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, - modifier = Modifier.clickable { showUpstreamProxyCredentialsDialog = true }, + modifier = + Modifier.clickable(enabled = !proxyState.busy) { + proxyViewModel.open(ProxyEditor.UPSTREAM_CREDENTIALS) + }, ) } HorizontalDivider(Modifier.padding(vertical = 8.dp)) - SectionLabel("About") + SectionLabel(stringResource(R.string.settings_section_anticensorship)) + ListItem( + headlineContent = { Text(stringResource(R.string.settings_tls_fragmentation)) }, + supportingContent = { + Text( + if (tlsFragmentMode == SettingsStore.TlsFragmentMode.OFF) { + stringResource(R.string.settings_disabled) + } else if (tlsFragmentMode == SettingsStore.TlsFragmentMode.RECORD) { + tlsFragmentMode.label + } else { + "${tlsFragmentMode.label}, ${fragmentFallbackDelay}ms" + } + ) + }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, + modifier = Modifier.clickable { activeDialog = SettingsDialog.TLS_FRAGMENT }, + ) + ListItem( + headlineContent = { + Text(stringResource(R.string.settings_browser_tls_fingerprint)) + }, + supportingContent = { Text(utlsFingerprint.label) }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, + modifier = Modifier.clickable { activeDialog = SettingsDialog.UTLS_FINGERPRINT }, + ) + ListItem( + headlineContent = { Text(stringResource(R.string.settings_multiplex_concurrency)) }, + supportingContent = { + Text(pluralStringResource(R.plurals.stream_count, maxConcurrentStreams, maxConcurrentStreams)) + }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, + modifier = Modifier.clickable { activeDialog = SettingsDialog.CONCURRENCY }, + ) + SettingsToggle( + title = stringResource(R.string.settings_tcp_fast_open), + summary = stringResource(R.string.settings_tcp_fast_open_help), + checked = tcpFastOpen, + onCheckedChange = { + settingsStore.tcpFastOpen = it + tcpFastOpen = it + }, + ) + SettingsToggle( + title = stringResource(R.string.settings_ipv6_tunnel), + summary = + if (ipv6Enabled) stringResource(R.string.settings_dual_stack) + else stringResource(R.string.settings_ipv4_only), + checked = ipv6Enabled, + onCheckedChange = { + if (it && mtu < 1280) { + settingsStore.mtu = 1280 + mtu = 1280 + } + settingsStore.ipv6Enabled = it + ipv6Enabled = it + }, + ) + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + SectionLabel(stringResource(R.string.settings_section_health)) + ListItem( + headlineContent = { Text(stringResource(R.string.settings_tunnel_health)) }, + supportingContent = { + Text( + when (health) { + TunnelHealth.UNKNOWN -> stringResource(R.string.settings_health_not_connected) + TunnelHealth.CHECKING -> stringResource(R.string.settings_health_checking) + TunnelHealth.HEALTHY -> stringResource(R.string.settings_health_healthy) + TunnelHealth.NO_NETWORK -> stringResource(R.string.settings_health_no_network) + TunnelHealth.UNREACHABLE -> + stringResource(R.string.settings_health_unreachable) + } + ) + }, + ) + SettingsToggle( + title = stringResource(R.string.settings_automatic_connection_recovery), + summary = stringResource(R.string.settings_recovery_help), + checked = automaticRecovery, + onCheckedChange = { + settingsStore.automaticRecovery = it + automaticRecovery = it + }, + ) + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + SectionLabel(stringResource(R.string.settings_section_about)) ListItem( - headlineContent = { Text("View logs") }, - supportingContent = { Text("Recent connection activity") }, - trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, + headlineContent = { Text(stringResource(R.string.settings_view_logs)) }, + supportingContent = { Text(stringResource(R.string.settings_logs_help)) }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, modifier = Modifier.clickable(onClick = onOpenLogs), ) ListItem( - headlineContent = { Text("Version") }, + headlineContent = { Text(stringResource(R.string.settings_version)) }, supportingContent = { Text(BuildConfig.VERSION_NAME) }, ) ListItem( - headlineContent = { Text("Source code") }, + headlineContent = { Text(stringResource(R.string.settings_check_for_updates)) }, + supportingContent = { Text(stringResource(R.string.settings_update_source)) }, + modifier = + Modifier.clickable(enabled = onCheckForUpdates != null) { + onCheckForUpdates?.invoke() + }, + ) + SettingsToggle( + title = stringResource(R.string.settings_automatic_update_checks), + summary = stringResource(R.string.settings_automatic_updates_help), + checked = automaticUpdates, + enabled = onAutomaticUpdatesChange != null, + onCheckedChange = { + onAutomaticUpdatesChange?.invoke(it) + }, + ) + ListItem( + headlineContent = { Text(stringResource(R.string.settings_source_code)) }, supportingContent = { Text(GITHUB_URL) }, leadingContent = { Icon(Icons.Filled.Code, contentDescription = null) }, - modifier = Modifier.clickable { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(GITHUB_URL))) - }, + modifier = + Modifier.clickable { + runCatching { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(GITHUB_URL)) + ) + } + .onFailure { + android.widget.Toast.makeText( + context, + context.getString(R.string.browser_unavailable), + android.widget.Toast.LENGTH_SHORT, + ) + .show() + } + }, ) HorizontalDivider(Modifier.padding(vertical = 8.dp)) Button( onClick = onSignOut, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + enabled = !signOutInProgress, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), ) { Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null) Spacer(Modifier.width(8.dp)) - Text("Sign out") + Text(stringResource(R.string.settings_sign_out)) } } } } @Composable -private fun DnsProviderPickerDialog( - currentProvider: SettingsStore.DnsProvider, - currentCustomUrl: String, - onDismiss: () -> Unit, - onConfirm: (SettingsStore.DnsProvider, String) -> Unit, +private fun SettingsToggle( + title: String, + summary: String, + checked: Boolean, + enabled: Boolean = true, + onCheckedChange: (Boolean) -> Unit, ) { - var selectedProvider by remember { mutableStateOf(currentProvider) } - var urlText by remember { mutableStateOf(currentCustomUrl) } - val isCustom = selectedProvider == SettingsStore.DnsProvider.CUSTOM - val isValidCustomUrl = SettingsStore.isValidDohUrl(urlText) - val canSave = !isCustom || isValidCustomUrl - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Tunnel DNS") }, - text = { - Column(Modifier.verticalScroll(rememberScrollState())) { - Text( - "DNS used inside the VPN tunnel for all app and browsing traffic.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp), + ListItem( + headlineContent = { Text(title) }, + supportingContent = { Text(summary) }, + trailingContent = { Switch(checked = checked, onCheckedChange = null, enabled = enabled) }, + modifier = + Modifier.toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, ) - SettingsStore.DnsProvider.entries.forEach { provider -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = selectedProvider == provider, - onClick = { selectedProvider = provider }, - ) - .padding(vertical = 4.dp), - ) { - RadioButton( - selected = selectedProvider == provider, - onClick = { selectedProvider = provider }, - ) - Column(Modifier.padding(start = 8.dp)) { - Text(provider.label) - val detail = when (provider) { - SettingsStore.DnsProvider.SYSTEM_DEFAULT -> "Resolved at the VPN edge" - SettingsStore.DnsProvider.CUSTOM -> "Specify an HTTPS DoH endpoint URL" - else -> provider.dohUrl ?: "" - } - if (detail.isNotEmpty()) { - Text( - detail, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - if (isCustom) { - OutlinedTextField( - value = urlText, - onValueChange = { urlText = it.trim() }, - label = { Text("DoH URL (https://...)") }, - placeholder = { Text("https://example.com/dns-query") }, - singleLine = true, - isError = urlText.isNotBlank() && !isValidCustomUrl, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - ) - } - } - }, - confirmButton = { - TextButton( - onClick = { onConfirm(selectedProvider, urlText) }, - enabled = canSave, - ) { - Text("Save") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) -} - -@Composable -private fun BootstrapDnsPickerDialog( - currentProvider: SettingsStore.BootstrapDnsProvider, - currentCustomIp: String, - onDismiss: () -> Unit, - onConfirm: (SettingsStore.BootstrapDnsProvider, String) -> Unit, -) { - var selectedProvider by remember { mutableStateOf(currentProvider) } - var ipText by remember { mutableStateOf(currentCustomIp) } - val isCustom = selectedProvider == SettingsStore.BootstrapDnsProvider.CUSTOM - val isValidCustomIp = SettingsStore.isValidIpAddress(ipText.trim()) - val canSave = !isCustom || isValidCustomIp - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Resolver DNS") }, - text = { - Column(Modifier.verticalScroll(rememberScrollState())) { - Text( - "DNS used to resolve the VPN server address before the tunnel connects.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp), - ) - SettingsStore.BootstrapDnsProvider.entries.forEach { provider -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = selectedProvider == provider, - onClick = { selectedProvider = provider }, - ) - .padding(vertical = 4.dp), - ) { - RadioButton( - selected = selectedProvider == provider, - onClick = { selectedProvider = provider }, - ) - Column(Modifier.padding(start = 8.dp)) { - Text(provider.label) - Text( - provider.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - if (isCustom) { - OutlinedTextField( - value = ipText, - onValueChange = { ipText = it.trim() }, - label = { Text("Resolver IP address") }, - placeholder = { Text("e.g. 76.76.2.2") }, - singleLine = true, - isError = ipText.isNotBlank() && !isValidCustomIp, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - ) - } - } - }, - confirmButton = { - TextButton( - onClick = { onConfirm(selectedProvider, ipText.trim()) }, - enabled = canSave, - ) { - Text("Save") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) -} - -@Composable -private fun CustomEdgeAddressDialog( - current: String, - onDismiss: () -> Unit, - onConfirm: (String) -> Unit, -) { - var value by remember { mutableStateOf(current) } - val trimmed = value.trim() - val isValid = trimmed.isEmpty() || SettingsStore.isValidEdgeHost(trimmed) - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Custom edge address") }, - text = { - Column { - OutlinedTextField( - value = value, - onValueChange = { value = it }, - label = { Text("Host or IP") }, - singleLine = true, - isError = !isValid, - ) - Text( - "Leave empty to resolve the server hostname normally.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), - ) - } - }, - confirmButton = { - TextButton(onClick = { onConfirm(trimmed) }, enabled = isValid) { Text("Save") } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) -} - -@Composable -private fun SocksBindAddressPickerDialog( - current: String, - onDismiss: () -> Unit, - onConfirm: (String) -> Unit, -) { - var selected by remember { mutableStateOf(current) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Local address") }, - text = { - Column { - SettingsStore.SOCKS_BIND_ADDRESS_PRESETS.forEach { (address, label) -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .selectable(selected = selected == address, onClick = { selected = address }) - .padding(vertical = 4.dp), - ) { - RadioButton(selected = selected == address, onClick = { selected = address }) - Text(label, modifier = Modifier.padding(start = 8.dp)) - } - } - } - }, - confirmButton = { - TextButton(onClick = { onConfirm(selected) }) { Text("Save") } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) -} - -@Composable -private fun SocksPortPickerDialog( - current: Int, - onDismiss: () -> Unit, - onConfirm: (Int) -> Unit, -) { - var portText by remember { mutableStateOf(current.toString()) } - val parsedPort = portText.toIntOrNull() - val isValid = parsedPort != null && parsedPort in 1..65_535 - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Local port") }, - text = { - OutlinedTextField( - value = portText, - onValueChange = { portText = it.filter(Char::isDigit).take(5) }, - label = { Text("Port (1\u201365535)") }, - singleLine = true, - isError = portText.isNotBlank() && !isValid, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - ) - }, - confirmButton = { - TextButton(onClick = { parsedPort?.let { onConfirm(it) } }, enabled = isValid) { Text("Save") } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) -} - -@Composable -private fun HttpPortPickerDialog( - current: Int, - onDismiss: () -> Unit, - onConfirm: (Int) -> Unit, -) { - var portText by remember { mutableStateOf(current.toString()) } - val parsedPort = portText.toIntOrNull() - val isValid = parsedPort != null && parsedPort in 1..65_535 - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("HTTP port") }, - text = { - OutlinedTextField( - value = portText, - onValueChange = { portText = it.filter(Char::isDigit).take(5) }, - label = { Text("Port (1\u201365535)") }, - singleLine = true, - isError = portText.isNotBlank() && !isValid, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - ) - }, - confirmButton = { - TextButton(onClick = { parsedPort?.let { onConfirm(it) } }, enabled = isValid) { Text("Save") } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) -} - -@Composable -private fun UpstreamProxyTypePickerDialog( - current: SettingsStore.UpstreamProxyType, - onDismiss: () -> Unit, - onConfirm: (SettingsStore.UpstreamProxyType) -> Unit, -) { - var selected by remember { mutableStateOf(current) } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Proxy type") }, - text = { - Column { - SettingsStore.UpstreamProxyType.entries.forEach { type -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .selectable(selected = selected == type, onClick = { selected = type }) - .padding(vertical = 4.dp), - ) { - RadioButton(selected = selected == type, onClick = { selected = type }) - Text(type.name, modifier = Modifier.padding(start = 8.dp)) - } - } - } - }, - confirmButton = { - TextButton(onClick = { onConfirm(selected) }) { Text("Save") } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) -} - -@Composable -private fun TlsSplitModePickerDialog( - current: SettingsStore.TlsSplitMode, - onDismiss: () -> Unit, - onConfirm: (SettingsStore.TlsSplitMode) -> Unit, -) { - var selected by remember { mutableStateOf(current) } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("TLS fragmentation") }, - text = { - Column { - Text( - "Fragments the TLS ClientHello into multiple TCP packets to bypass SNI-based filtering on censored networks.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 12.dp), - ) - SettingsStore.TlsSplitMode.entries.forEach { mode -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .selectable(selected = selected == mode, onClick = { selected = mode }) - .padding(vertical = 6.dp), - ) { - RadioButton(selected = selected == mode, onClick = { selected = mode }) - Column(Modifier.padding(start = 8.dp)) { - Text(mode.label, style = MaterialTheme.typography.bodyLarge) - Text( - mode.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - }, - confirmButton = { - TextButton(onClick = { onConfirm(selected) }) { Text("Save") } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) -} - -@Composable -private fun UpstreamProxyAddressDialog( - currentHost: String, - currentPort: Int, - onDismiss: () -> Unit, - onConfirm: (String, Int) -> Unit, -) { - var host by remember { mutableStateOf(currentHost) } - var portText by remember { mutableStateOf(currentPort.toString()) } - val parsedPort = portText.toIntOrNull() - val isValid = host.isNotBlank() && parsedPort != null && parsedPort in 1..65_535 - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Proxy address") }, - text = { - Column { - OutlinedTextField( - value = host, - onValueChange = { host = it.trim() }, - label = { Text("Host or IP") }, - singleLine = true, - ) - OutlinedTextField( - value = portText, - onValueChange = { portText = it.filter(Char::isDigit).take(5) }, - label = { Text("Port (1\u201365535)") }, - singleLine = true, - isError = portText.isNotBlank() && (parsedPort == null || parsedPort !in 1..65_535), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.padding(top = 8.dp), - ) - } - }, - confirmButton = { - TextButton(onClick = { parsedPort?.let { onConfirm(host, it) } }, enabled = isValid) { Text("Save") } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) -} - -@Composable -private fun UpstreamProxyCredentialsDialog( - currentUsername: String, - currentPassword: String, - onDismiss: () -> Unit, - onConfirm: (String, String) -> Unit, -) { - var username by remember { mutableStateOf(currentUsername) } - var password by remember { mutableStateOf(currentPassword) } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Proxy credentials") }, - text = { - Column { - OutlinedTextField( - value = username, - onValueChange = { username = it }, - label = { Text("Username (optional)") }, - singleLine = true, - ) - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("Password (optional)") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.padding(top = 8.dp), - ) - } - }, - confirmButton = { - TextButton(onClick = { onConfirm(username, password) }) { Text("Save") } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) -} - -private data class InstalledAppEntry(val packageName: String, val label: String) - -@Composable -private fun SplitTunnelDialog( - current: Set, - onDismiss: () -> Unit, - onConfirm: (Set) -> Unit, -) { - val context = LocalContext.current - var selected by remember { mutableStateOf(current) } - var searchQuery by remember { mutableStateOf("") } - - val installedApps by produceState>(initialValue = emptyList()) { - value = withContext(Dispatchers.IO) { - val packageManager = context.packageManager - packageManager.getInstalledApplications(PackageManager.GET_META_DATA) - .asSequence() - .filter { it.packageName != context.packageName } - .filter { packageManager.getLaunchIntentForPackage(it.packageName) != null } - .map { info: ApplicationInfo -> - InstalledAppEntry(info.packageName, info.loadLabel(packageManager).toString()) - } - .sortedBy { it.label.lowercase() } - .toList() - } - } - val filteredApps = remember(installedApps, searchQuery) { - if (searchQuery.isBlank()) { - installedApps - } else { - installedApps.filter { it.label.contains(searchQuery, ignoreCase = true) } - } - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Split tunneling") }, - text = { - Column { - Text( - "Selected apps bypass the VPN and use your normal connection.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp), - ) - OutlinedTextField( - value = searchQuery, - onValueChange = { searchQuery = it }, - label = { Text("Search apps") }, - leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - ) - if (installedApps.isEmpty()) { - CircularProgressIndicator( - modifier = Modifier.padding(24.dp).align(Alignment.CenterHorizontally) - ) - } else if (filteredApps.isEmpty()) { - Text( - "No apps match \"$searchQuery\".", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 12.dp), - ) - } else { - LazyColumn(modifier = Modifier.height(360.dp)) { - items(filteredApps, key = { it.packageName }) { app -> - val isChecked = selected.contains(app.packageName) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { - selected = if (isChecked) { - selected - app.packageName - } else { - selected + app.packageName - } - } - .padding(vertical = 2.dp), - ) { - Checkbox( - checked = isChecked, - onCheckedChange = { - selected = if (it) selected + app.packageName else selected - app.packageName - }, - ) - Text(app.label, modifier = Modifier.padding(start = 8.dp)) - } - } - } - } - } - }, - confirmButton = { - TextButton(onClick = { onConfirm(selected) }) { Text("Save") } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, + .heightIn(min = 48.dp), ) } @@ -1062,50 +659,3 @@ private fun SectionLabel(text: String) { modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) } - -@Composable -private fun MtuPickerDialog( - current: Int, - onDismiss: () -> Unit, - onConfirm: (Int) -> Unit, -) { - var mtuText by remember { mutableStateOf(current.toString()) } - val parsedMtu = mtuText.toIntOrNull() - val isValid = parsedMtu != null && parsedMtu in SettingsStore.MIN_MTU..SettingsStore.MAX_MTU - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Tunnel MTU") }, - text = { - Column { - Text( - "Maximum Transmission Unit for the VPN tunnel. Default is ${SettingsStore.DEFAULT_MTU}. Lower values (e.g. 1280\u20131400) prevent packet fragmentation on mobile networks.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 12.dp), - ) - OutlinedTextField( - value = mtuText, - onValueChange = { mtuText = it.filter(Char::isDigit).take(4) }, - label = { Text("MTU (${SettingsStore.MIN_MTU}\u2013${SettingsStore.MAX_MTU})") }, - singleLine = true, - isError = mtuText.isNotBlank() && !isValid, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - ) - TextButton( - onClick = { mtuText = SettingsStore.DEFAULT_MTU.toString() }, - modifier = Modifier.align(Alignment.End), - ) { - Text("Reset to default (${SettingsStore.DEFAULT_MTU})") - } - } - }, - confirmButton = { - TextButton(onClick = { parsedMtu?.let { onConfirm(it) } }, enabled = isValid) { Text("Save") } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) -} diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/SplashScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/SplashScreen.kt index 8efc9523..a8245160 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/SplashScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/SplashScreen.kt @@ -17,8 +17,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vauth.foxyvpn.R import com.vauth.foxyvpn.data.FxaAuthRepository import com.vauth.foxyvpn.data.SessionStatus import kotlinx.coroutines.delay @@ -34,9 +36,9 @@ fun SplashScreen( ) { LaunchedEffect(Unit) { delay(500) - val status = withTimeoutOrNull(SESSION_RESTORE_TIMEOUT_MS) { - authRepository.restoreSession() - } ?: SessionStatus.UNREACHABLE + val status = + withTimeoutOrNull(SESSION_RESTORE_TIMEOUT_MS) { authRepository.restoreSession() } + ?: SessionStatus.UNREACHABLE when (status) { SessionStatus.ACTIVE -> onSignedIn() @@ -54,11 +56,13 @@ fun SplashScreen( tint = MaterialTheme.colorScheme.primary, ) Spacer(Modifier.height(16.dp)) - Text("FoxyVPN", fontSize = 28.sp, color = MaterialTheme.colorScheme.onSurface) + Text( + stringResource(R.string.app_name), + fontSize = 28.sp, + color = MaterialTheme.colorScheme.onSurface, + ) CircularProgressIndicator( - modifier = Modifier - .padding(top = 24.dp) - .size(24.dp), + modifier = Modifier.padding(top = 24.dp).size(24.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary, ) diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/settings/ConnectionDialogs.kt b/app/src/main/java/com/vauth/foxyvpn/ui/settings/ConnectionDialogs.kt new file mode 100644 index 00000000..dd3d49f9 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/ui/settings/ConnectionDialogs.kt @@ -0,0 +1,512 @@ +package com.vauth.foxyvpn.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.vauth.foxyvpn.R +import com.vauth.foxyvpn.data.SettingsStore + +private const val MAX_SAVED_NETWORK_DRAFT_CHARS = 4096 + +private val NetworkDraftSaver = + Saver( + save = { it.takeIf { value -> value.length <= MAX_SAVED_NETWORK_DRAFT_CHARS } }, + restore = { it }, + ) + +@Composable +internal fun DnsProviderPickerDialog( + currentProvider: SettingsStore.DnsProvider, + currentCustomUrl: String, + onDismiss: () -> Unit, + onConfirm: (SettingsStore.DnsProvider, String) -> Unit, +) { + var selectedProvider by rememberSaveable { mutableStateOf(currentProvider) } + var urlText by + rememberSaveable(stateSaver = NetworkDraftSaver) { mutableStateOf(currentCustomUrl) } + val isCustom = selectedProvider == SettingsStore.DnsProvider.CUSTOM + val isValidCustomUrl = SettingsStore.isValidDohUrl(urlText) + val canSave = !isCustom || isValidCustomUrl + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_dns_provider)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()).selectableGroup()) { + Text( + stringResource(R.string.dialog_dns_tunnel_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + SettingsStore.DnsProvider.entries.forEach { provider -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .selectable( + selected = selectedProvider == provider, + role = Role.RadioButton, + onClick = { selectedProvider = provider }, + ) + .heightIn(min = 48.dp) + .padding(vertical = 4.dp), + ) { + RadioButton(selected = selectedProvider == provider, onClick = null) + Column(Modifier.padding(start = 8.dp)) { + Text(provider.label) + val detail = + when (provider) { + SettingsStore.DnsProvider.DIRECT_SYSTEM -> + stringResource(R.string.dialog_dns_direct_system) + SettingsStore.DnsProvider.CUSTOM -> + stringResource(R.string.dialog_dns_custom_doh) + else -> provider.dohUrl + } + if (detail.isNotEmpty()) { + Text( + detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + if (isCustom) { + OutlinedTextField( + value = urlText, + onValueChange = { urlText = it.trim() }, + label = { Text(stringResource(R.string.settings_doh_url)) }, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Uri, + autoCorrectEnabled = false, + ), + placeholder = { Text(stringResource(R.string.settings_doh_placeholder)) }, + supportingText = { Text(stringResource(R.string.settings_doh_help)) }, + singleLine = true, + isError = urlText.isNotBlank() && !isValidCustomUrl, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(selectedProvider, urlText) }, enabled = canSave) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + }, + ) +} + +@Composable +internal fun CustomEdgeAddressDialog( + current: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var value by rememberSaveable(stateSaver = NetworkDraftSaver) { mutableStateOf(current) } + val trimmed = value.trim() + val isValid = trimmed.isEmpty() || SettingsStore.isValidEdgeHost(trimmed) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_custom_edge_address)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { Text(stringResource(R.string.settings_host_or_ip)) }, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Ascii, + autoCorrectEnabled = false, + ), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = !isValid, + ) + Text( + stringResource(R.string.dialog_custom_edge_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(trimmed) }, enabled = isValid) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + }, + ) +} + +@Composable +internal fun HttpVersionPickerDialog( + current: SettingsStore.HttpVersion, + onDismiss: () -> Unit, + onConfirm: (SettingsStore.HttpVersion) -> Unit, +) { + var selected by rememberSaveable { mutableStateOf(current) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_transport_protocol)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()).selectableGroup()) { + Text( + stringResource(R.string.dialog_http_version_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp), + ) + SettingsStore.HttpVersion.entries.forEach { version -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .selectable( + selected = selected == version, + role = Role.RadioButton, + onClick = { selected = version }, + ) + .heightIn(min = 48.dp) + .padding(vertical = 4.dp), + ) { + RadioButton(selected = selected == version, onClick = null) + Column(Modifier.padding(start = 8.dp)) { + Text(version.label) + Text( + version.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(selected) }) { Text(stringResource(R.string.save)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + }, + ) +} + +@Composable +internal fun MtuPickerDialog( + current: Int, + minimum: Int, + onDismiss: () -> Unit, + onConfirm: (Int) -> Unit, +) { + var mtuText by rememberSaveable { mutableStateOf(current.toString()) } + val parsedMtu = mtuText.toIntOrNull() + val isValid = parsedMtu != null && parsedMtu in minimum..SettingsStore.MAX_MTU + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_tunnel_mtu)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + Text( + stringResource(R.string.dialog_mtu_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp), + ) + OutlinedTextField( + value = mtuText, + onValueChange = { mtuText = it.filter(Char::isDigit).take(4) }, + label = { Text(stringResource(R.string.settings_mtu)) }, + singleLine = true, + isError = mtuText.isNotBlank() && !isValid, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + TextButton( + onClick = { mtuText = SettingsStore.DEFAULT_MTU.toString() }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringResource(R.string.settings_reset_default)) + } + } + }, + confirmButton = { + TextButton(onClick = { parsedMtu?.let { onConfirm(it) } }, enabled = isValid) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + }, + ) +} + +@Composable +internal fun TlsFragmentPickerDialog( + currentMode: SettingsStore.TlsFragmentMode, + currentDelay: Int, + onDismiss: () -> Unit, + onConfirm: (SettingsStore.TlsFragmentMode, Int) -> Unit, +) { + var selectedMode by rememberSaveable { mutableStateOf(currentMode) } + var delayText by rememberSaveable { mutableStateOf(currentDelay.toString()) } + val parsedDelay = delayText.toIntOrNull() + val isValidDelay = parsedDelay != null && parsedDelay in 50..2000 + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_tls_fragmentation)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()).selectableGroup()) { + Text( + stringResource(R.string.dialog_fragment_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp), + ) + SettingsStore.TlsFragmentMode.entries.forEach { mode -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .selectable( + selected = selectedMode == mode, + role = Role.RadioButton, + onClick = { selectedMode = mode }, + ) + .heightIn(min = 48.dp) + .padding(vertical = 4.dp), + ) { + RadioButton(selected = selectedMode == mode, onClick = null) + Column(Modifier.padding(start = 8.dp)) { + Text(mode.label) + Text( + mode.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + if ( + selectedMode == SettingsStore.TlsFragmentMode.PACKET || + selectedMode == SettingsStore.TlsFragmentMode.COMBINED + ) { + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = delayText, + onValueChange = { delayText = it.filter(Char::isDigit).take(4) }, + label = { Text(stringResource(R.string.settings_fallback_delay_ms)) }, + singleLine = true, + isError = delayText.isNotBlank() && !isValidDelay, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + onConfirm( + selectedMode, + parsedDelay ?: SettingsStore.DEFAULT_FRAGMENT_FALLBACK_DELAY, + ) + }, + enabled = + selectedMode == SettingsStore.TlsFragmentMode.OFF || + selectedMode == SettingsStore.TlsFragmentMode.RECORD || + isValidDelay, + ) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + }, + ) +} + +@Composable +internal fun UtlsFingerprintPickerDialog( + current: SettingsStore.UtlsFingerprint, + onDismiss: () -> Unit, + onConfirm: (SettingsStore.UtlsFingerprint) -> Unit, +) { + var selected by rememberSaveable { mutableStateOf(current) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_browser_tls_fingerprint)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()).selectableGroup()) { + Text( + stringResource(R.string.dialog_utls_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp), + ) + SettingsStore.UtlsFingerprint.entries.forEach { fp -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .selectable( + selected = selected == fp, + role = Role.RadioButton, + onClick = { selected = fp }, + ) + .heightIn(min = 48.dp) + .padding(vertical = 4.dp), + ) { + RadioButton(selected = selected == fp, onClick = null) + Text(fp.label, modifier = Modifier.padding(start = 8.dp)) + } + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(selected) }) { Text(stringResource(R.string.save)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + }, + ) +} + +@Composable +internal fun ConcurrencyPickerDialog( + current: Int, + onDismiss: () -> Unit, + onConfirm: (Int) -> Unit, +) { + val options = listOf(8, 16, 32, 64) + var selected by rememberSaveable { mutableIntStateOf(current) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_multiplex_concurrency)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()).selectableGroup()) { + Text( + stringResource(R.string.dialog_concurrency_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp), + ) + options.forEach { count -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .selectable( + selected = selected == count, + role = Role.RadioButton, + onClick = { selected = count }, + ) + .heightIn(min = 48.dp) + .padding(vertical = 4.dp), + ) { + RadioButton(selected = selected == count, onClick = null) + val desc = pluralStringResource(R.plurals.stream_count, count, count) + Text(desc, modifier = Modifier.padding(start = 8.dp)) + } + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(selected) }) { Text(stringResource(R.string.save)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + }, + ) +} + +@Composable +internal fun LatencyProbeDialog( + current: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var url by rememberSaveable(stateSaver = NetworkDraftSaver) { mutableStateOf(current) } + val valid = SettingsStore.isValidProbeUrl(url.trim()) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_latency_test_url)) }, + text = { + Column( + Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(stringResource(R.string.settings_latency_help)) + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text(stringResource(R.string.settings_https_url)) }, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Uri, + autoCorrectEnabled = false, + ), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = !valid, + ) + TextButton(onClick = { url = SettingsStore.DEFAULT_LATENCY_PROBE_URL }) { + Text(stringResource(R.string.settings_reset)) + } + } + }, + confirmButton = { + TextButton(enabled = valid, onClick = { onConfirm(url.trim()) }) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + }, + ) +} diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/settings/ProxySettingsDialogs.kt b/app/src/main/java/com/vauth/foxyvpn/ui/settings/ProxySettingsDialogs.kt new file mode 100644 index 00000000..52b184d9 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/ui/settings/ProxySettingsDialogs.kt @@ -0,0 +1,371 @@ +package com.vauth.foxyvpn.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.vauth.foxyvpn.R +import com.vauth.foxyvpn.data.LocalProxyOptions +import com.vauth.foxyvpn.data.SettingsStore +import com.vauth.foxyvpn.data.UpstreamProxyOptions +import com.vauth.foxyvpn.ui.ProxyEditor +import com.vauth.foxyvpn.ui.ProxySettingsUiState + +@Composable +internal fun ProxySettingsDialog( + state: ProxySettingsUiState, + onDismiss: () -> Unit, + onRetry: (ProxyEditor) -> Unit, + onSaveLocal: (LocalProxyOptions) -> Unit, + onSaveUpstream: (UpstreamProxyOptions) -> Unit, +) { + val editor = state.editor ?: return + val errorMessage = state.errorRes?.let { stringResource(it) } ?: state.error + when { + editor == ProxyEditor.LOCAL && state.local != null -> + LocalProxyDialog(state.local, state.busy, errorMessage, onDismiss, onSaveLocal) + editor != ProxyEditor.LOCAL && state.upstream != null -> + UpstreamProxyDialog( + checkNotNull(state.editor), + state.upstream, + state.busy, + errorMessage, + onDismiss, + onSaveUpstream, + ) + else -> + AlertDialog( + onDismissRequest = { if (!state.busy) onDismiss() }, + title = { + Text( + stringResource( + if (editor == ProxyEditor.LOCAL) R.string.local_proxy + else R.string.upstream_proxy + ) + ) + }, + text = { + if (state.busy) CircularProgressIndicator() + else Text(errorMessage.orEmpty(), color = MaterialTheme.colorScheme.error) + }, + confirmButton = { + TextButton(enabled = !state.busy, onClick = { onRetry(editor) }) { + Text(stringResource(R.string.retry)) + } + }, + dismissButton = { + TextButton(enabled = !state.busy, onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } +} + +@Composable +private fun LocalProxyDialog( + initial: LocalProxyOptions, + busy: Boolean, + error: String?, + onDismiss: () -> Unit, + onSave: (LocalProxyOptions) -> Unit, +) { + var port by remember(initial) { mutableStateOf(initial.port.toString()) } + + var authentication by remember(initial) { mutableStateOf(initial.authentication) } + var username by remember(initial) { mutableStateOf(initial.username) } + var password by remember(initial) { mutableStateOf(initial.password) } + val validPort = SettingsStore.isValidLocalPort(port.toIntOrNull() ?: 0) + val validCredentials = + !authentication || SettingsStore.isValidLocalProxyCredentials(username, password) + ProxyEditorDialog( + stringResource(R.string.local_proxy), + busy, + error, + validPort && validCredentials, + onDismiss, + { onSave(LocalProxyOptions(port.toInt(), authentication, username, password)) }, + ) { + Text(stringResource(R.string.local_address_value, SettingsStore.DEFAULT_SOCKS_BIND_ADDRESS)) + PortField(port, { port = it }, stringResource(R.string.mixed_port), !busy, !validPort) + Text( + stringResource(R.string.local_port_help, SettingsStore.CONTROLLER_PORT), + style = MaterialTheme.typography.bodySmall, + ) + ProxyToggle(stringResource(R.string.proxy_authentication), authentication, !busy) { + authentication = it + } + if (authentication) { + Text( + stringResource(R.string.local_auth_help), + style = MaterialTheme.typography.bodySmall, + ) + CredentialFields( + username, + { username = it }, + password, + { password = it }, + !busy, + !validCredentials, + ) + } + } +} + +@Composable +private fun UpstreamProxyDialog( + editor: ProxyEditor, + initial: UpstreamProxyOptions, + busy: Boolean, + error: String?, + onDismiss: () -> Unit, + onSave: (UpstreamProxyOptions) -> Unit, +) { + var type by remember(initial) { mutableStateOf(initial.type) } + var host by remember(initial) { mutableStateOf(initial.host) } + var port by remember(initial) { mutableStateOf(initial.port.toString()) } + var username by remember(initial) { mutableStateOf(initial.username) } + var password by remember(initial) { mutableStateOf(initial.password) } + val validHost = SettingsStore.isValidProxyHost(host.trim().removeSurrounding("[", "]")) + val validPort = port.toIntOrNull() in 1..65_535 + val validCredentials = SettingsStore.isValidUpstreamProxyCredentials(type, username, password) + val valid = + when (editor) { + ProxyEditor.UPSTREAM_TYPE -> true + ProxyEditor.UPSTREAM_ADDRESS -> validHost && validPort + else -> validCredentials + } + val title = + when (editor) { + ProxyEditor.UPSTREAM_TYPE -> R.string.proxy_type + ProxyEditor.UPSTREAM_ADDRESS -> R.string.proxy_address + else -> R.string.proxy_credentials + } + ProxyEditorDialog( + stringResource(title), + busy, + error, + valid, + onDismiss, + { + onSave( + UpstreamProxyOptions( + initial.enabled, + type, + host, + port.toIntOrNull() ?: initial.port, + username, + password, + ) + ) + }, + ) { + when (editor) { + ProxyEditor.UPSTREAM_TYPE -> + Column(Modifier.selectableGroup()) { + SettingsStore.UpstreamProxyType.entries.forEach { option -> + Row( + Modifier.fillMaxWidth() + .selectable( + selected = type == option, + enabled = !busy, + role = Role.RadioButton, + onClick = { type = option }, + ) + .heightIn(min = 48.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = type == option, onClick = null, enabled = !busy) + Text(option.name, modifier = Modifier.padding(start = 12.dp)) + } + } + } + ProxyEditor.UPSTREAM_ADDRESS -> { + OutlinedTextField( + value = host, + onValueChange = { host = it }, + label = { Text(stringResource(R.string.proxy_host)) }, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Ascii, + autoCorrectEnabled = false, + ), + singleLine = true, + enabled = !busy, + isError = !validHost, + modifier = Modifier.fillMaxWidth(), + ) + PortField( + port, + { port = it }, + stringResource(R.string.proxy_port), + !busy, + !validPort, + ) + Text( + stringResource( + if (initial.type == SettingsStore.UpstreamProxyType.HTTP) + R.string.upstream_http_help + else R.string.upstream_socks_help + ), + style = MaterialTheme.typography.bodySmall, + ) + } + else -> { + Text( + stringResource(R.string.upstream_auth_help), + style = MaterialTheme.typography.bodySmall, + ) + CredentialFields( + username, + { username = it }, + password, + { password = it }, + !busy, + !validCredentials, + ) + } + } + } +} + +@Composable +private fun ProxyEditorDialog( + title: String, + busy: Boolean, + error: String?, + valid: Boolean, + onDismiss: () -> Unit, + onSave: () -> Unit, + content: @Composable () -> Unit, +) { + AlertDialog( + onDismissRequest = { if (!busy) onDismiss() }, + title = { Text(title) }, + text = { + Column( + Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + content() + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + if (busy) CircularProgressIndicator() + } + }, + confirmButton = { + TextButton(enabled = valid && !busy, onClick = onSave) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(enabled = !busy, onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} + +@Composable +private fun PortField( + value: String, + onChange: (String) -> Unit, + label: String, + enabled: Boolean, + invalid: Boolean, +) { + OutlinedTextField( + value = value, + onValueChange = onChange, + label = { Text(label) }, + singleLine = true, + enabled = enabled, + isError = invalid, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +private fun CredentialFields( + username: String, + onUsername: (String) -> Unit, + password: String, + onPassword: (String) -> Unit, + enabled: Boolean, + invalid: Boolean, +) { + OutlinedTextField( + value = username, + onValueChange = onUsername, + label = { Text(stringResource(R.string.username)) }, + singleLine = true, + enabled = enabled, + isError = invalid, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Ascii, autoCorrectEnabled = false), + ) + OutlinedTextField( + value = password, + onValueChange = onPassword, + label = { Text(stringResource(R.string.password)) }, + singleLine = true, + enabled = enabled, + isError = invalid, + modifier = Modifier.fillMaxWidth(), + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) +} + +@Composable +internal fun ProxyToggle( + label: String, + checked: Boolean, + enabled: Boolean = true, + onChange: (Boolean) -> Unit, +) { + Row( + Modifier.fillMaxWidth() + .toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onChange, + ) + .heightIn(min = 48.dp) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, modifier = Modifier.weight(1f).padding(end = 12.dp)) + Switch(checked = checked, onCheckedChange = null, enabled = enabled) + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/settings/SplitTunnelDialog.kt b/app/src/main/java/com/vauth/foxyvpn/ui/settings/SplitTunnelDialog.kt new file mode 100644 index 00000000..4746fce8 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/ui/settings/SplitTunnelDialog.kt @@ -0,0 +1,267 @@ +package com.vauth.foxyvpn.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +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.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vauth.foxyvpn.R +import com.vauth.foxyvpn.data.AppLogger +import com.vauth.foxyvpn.data.InstalledApp +import com.vauth.foxyvpn.data.SettingsStore +import com.vauth.foxyvpn.data.filterInstalledApps +import com.vauth.foxyvpn.data.loadInstalledApps + +private sealed interface AppListState { + data object Loading : AppListState + + data class Ready(val apps: List) : AppListState + + data object Failed : AppListState +} + +private val PackageSelectionSaver = + listSaver, String>(save = { it.toList() }, restore = { it.toSet() }) + +@Composable +internal fun SplitTunnelDialog( + currentMode: SettingsStore.SplitTunnelMode, + currentExcluded: Set, + currentIncluded: Set, + onDismiss: () -> Unit, + onConfirm: (SettingsStore.SplitTunnelMode, Set, Set) -> Unit, +) { + val context = LocalContext.current.applicationContext + var mode by rememberSaveable { mutableStateOf(currentMode) } + var excluded by + rememberSaveable(stateSaver = PackageSelectionSaver) { mutableStateOf(currentExcluded) } + var included by + rememberSaveable(stateSaver = PackageSelectionSaver) { mutableStateOf(currentIncluded) } + var searchQuery by rememberSaveable { mutableStateOf("") } + var showSystemApps by rememberSaveable { mutableStateOf(false) } + var loadAttempt by remember { mutableIntStateOf(0) } + val selected = if (mode == SettingsStore.SplitTunnelMode.INCLUDE) included else excluded + fun toggle(pkg: String) { + val next = if (pkg in selected) selected - pkg else selected + pkg + if (mode == SettingsStore.SplitTunnelMode.INCLUDE) included = next else excluded = next + } + val appList by + produceState(AppListState.Loading, context, loadAttempt) { + value = AppListState.Loading + value = + try { + AppListState.Ready(loadInstalledApps(context)) + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (error: Exception) { + AppLogger.w("SplitTunnelDialog", "Could not load installed apps", error) + AppListState.Failed + } + } + val installedApps = (appList as? AppListState.Ready)?.apps.orEmpty() + val filteredApps = + remember(installedApps, searchQuery, showSystemApps, selected) { + filterInstalledApps(installedApps, searchQuery, showSystemApps, selected) + } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_split_tunneling)) }, + text = { + LazyColumn(Modifier.heightIn(max = 400.dp)) { + item(key = "split_header") { + Column(Modifier.selectableGroup()) { + SettingsStore.SplitTunnelMode.entries.forEach { option -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .selectable( + selected = mode == option, + role = Role.RadioButton, + onClick = { mode = option }, + ) + .heightIn(min = 48.dp), + ) { + RadioButton(selected = mode == option, onClick = null) + Text( + stringResource( + if (option == SettingsStore.SplitTunnelMode.INCLUDE) + R.string.settings_include_selected_apps + else R.string.settings_exclude_selected_apps + ) + ) + } + } + } + Text( + stringResource( + if (mode == SettingsStore.SplitTunnelMode.INCLUDE) + R.string.settings_include_apps_help + else R.string.settings_exclude_apps_help + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + pluralStringResource( + R.plurals.settings_selected_apps_count, + selected.size, + selected.size, + ), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelMedium, + ) + TextButton( + onClick = { + if (mode == SettingsStore.SplitTunnelMode.INCLUDE) + included = emptySet() + else excluded = emptySet() + }, + enabled = selected.isNotEmpty(), + ) { + Text(stringResource(R.string.settings_clear_selection)) + } + } + Row( + Modifier.fillMaxWidth() + .toggleable( + value = showSystemApps, + role = Role.Switch, + onValueChange = { showSystemApps = it }, + ) + .heightIn(min = 48.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.settings_show_system_apps), + modifier = Modifier.weight(1f).padding(end = 8.dp), + ) + Switch(checked = showSystemApps, onCheckedChange = null) + } + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + label = { Text(stringResource(R.string.settings_search_apps)) }, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, + singleLine = true, + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false), + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + ) + } + item(key = "split_status") { + when { + appList == AppListState.Loading -> + CircularProgressIndicator(Modifier.padding(24.dp)) + appList == AppListState.Failed -> { + Text( + stringResource(R.string.settings_apps_load_failed), + color = MaterialTheme.colorScheme.error, + ) + TextButton(onClick = { loadAttempt++ }) { + Text(stringResource(R.string.retry)) + } + } + installedApps.isEmpty() -> Text(stringResource(R.string.settings_no_apps)) + filteredApps.isEmpty() -> + Text(stringResource(R.string.settings_no_app_matches)) + } + } + if (appList is AppListState.Ready) { + items(filteredApps, key = { it.packageName }) { app -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .toggleable( + value = app.packageName in selected, + role = Role.Checkbox, + onValueChange = { toggle(app.packageName) }, + ) + .heightIn(min = 48.dp) + .padding(vertical = 2.dp), + ) { + Checkbox(checked = app.packageName in selected, onCheckedChange = null) + Column(Modifier.weight(1f).padding(start = 8.dp)) { + Text(app.label, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text( + app.packageName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + item(key = "split_footer") { + if (mode == SettingsStore.SplitTunnelMode.INCLUDE && included.isEmpty()) { + Text( + stringResource(R.string.settings_include_app_required), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + stringResource(R.string.settings_lockdown_help), + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + confirmButton = { + TextButton( + enabled = + appList is AppListState.Ready && + (mode != SettingsStore.SplitTunnelMode.INCLUDE || included.isNotEmpty()), + onClick = { onConfirm(mode, excluded, included) }, + ) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + }, + ) +} diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/theme/Color.kt b/app/src/main/java/com/vauth/foxyvpn/ui/theme/Color.kt index 4f984e8c..1cf9a6e6 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/theme/Color.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/theme/Color.kt @@ -3,8 +3,6 @@ package com.vauth.foxyvpn.ui.theme import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color -val FoxSeed = Color(0xFFFF7139) - val md_theme_light_primary = Color(0xFFA03A00) val md_theme_light_onPrimary = Color(0xFFFFFFFF) val md_theme_light_primaryContainer = Color(0xFFFFDBCB) @@ -91,22 +89,12 @@ val md_theme_dark_inverseSurface = Color(0xFFF1DED8) val md_theme_dark_inverseOnSurface = Color(0xFF382E2A) val md_theme_dark_scrim = Color(0xFF000000) -data class FoxyStatusColors( - val connected: Color, - val connecting: Color, - val disconnected: Color, -) - -val LightStatusColors = FoxyStatusColors( - connected = Color(0xFF1B6E2F), - connecting = Color(0xFF8A5A00), - disconnected = md_theme_light_onSurfaceVariant, -) - -val DarkStatusColors = FoxyStatusColors( - connected = Color(0xFF7BDF92), - connecting = Color(0xFFFFC46B), - disconnected = md_theme_dark_onSurfaceVariant, -) +data class FoxyStatusColors(val connected: Color, val connecting: Color) + +val LightStatusColors = + FoxyStatusColors(connected = Color(0xFF1B6E2F), connecting = Color(0xFF8A5A00)) + +val DarkStatusColors = + FoxyStatusColors(connected = Color(0xFF7BDF92), connecting = Color(0xFFFFC46B)) val LocalFoxyStatusColors = staticCompositionLocalOf { LightStatusColors } diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/theme/Theme.kt b/app/src/main/java/com/vauth/foxyvpn/ui/theme/Theme.kt index d4a17834..31147179 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/theme/Theme.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/theme/Theme.kt @@ -17,84 +17,87 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.LayoutDirection import androidx.core.view.WindowCompat +import com.vauth.foxyvpn.data.ThemeMode -private val LightColors = lightColorScheme( - primary = md_theme_light_primary, - onPrimary = md_theme_light_onPrimary, - primaryContainer = md_theme_light_primaryContainer, - onPrimaryContainer = md_theme_light_onPrimaryContainer, - inversePrimary = md_theme_light_inversePrimary, - secondary = md_theme_light_secondary, - onSecondary = md_theme_light_onSecondary, - secondaryContainer = md_theme_light_secondaryContainer, - onSecondaryContainer = md_theme_light_onSecondaryContainer, - tertiary = md_theme_light_tertiary, - onTertiary = md_theme_light_onTertiary, - tertiaryContainer = md_theme_light_tertiaryContainer, - onTertiaryContainer = md_theme_light_onTertiaryContainer, - error = md_theme_light_error, - onError = md_theme_light_onError, - errorContainer = md_theme_light_errorContainer, - onErrorContainer = md_theme_light_onErrorContainer, - background = md_theme_light_background, - onBackground = md_theme_light_onBackground, - surface = md_theme_light_surface, - onSurface = md_theme_light_onSurface, - surfaceVariant = md_theme_light_surfaceVariant, - onSurfaceVariant = md_theme_light_onSurfaceVariant, - surfaceTint = md_theme_light_surfaceTint, - surfaceBright = md_theme_light_surfaceBright, - surfaceDim = md_theme_light_surfaceDim, - surfaceContainer = md_theme_light_surfaceContainer, - surfaceContainerHigh = md_theme_light_surfaceContainerHigh, - surfaceContainerHighest = md_theme_light_surfaceContainerHighest, - surfaceContainerLow = md_theme_light_surfaceContainerLow, - surfaceContainerLowest = md_theme_light_surfaceContainerLowest, - outline = md_theme_light_outline, - outlineVariant = md_theme_light_outlineVariant, - inverseSurface = md_theme_light_inverseSurface, - inverseOnSurface = md_theme_light_inverseOnSurface, - scrim = md_theme_light_scrim, -) +private val LightColors = + lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + inversePrimary = md_theme_light_inversePrimary, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + onError = md_theme_light_onError, + errorContainer = md_theme_light_errorContainer, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + surfaceTint = md_theme_light_surfaceTint, + surfaceBright = md_theme_light_surfaceBright, + surfaceDim = md_theme_light_surfaceDim, + surfaceContainer = md_theme_light_surfaceContainer, + surfaceContainerHigh = md_theme_light_surfaceContainerHigh, + surfaceContainerHighest = md_theme_light_surfaceContainerHighest, + surfaceContainerLow = md_theme_light_surfaceContainerLow, + surfaceContainerLowest = md_theme_light_surfaceContainerLowest, + outline = md_theme_light_outline, + outlineVariant = md_theme_light_outlineVariant, + inverseSurface = md_theme_light_inverseSurface, + inverseOnSurface = md_theme_light_inverseOnSurface, + scrim = md_theme_light_scrim, + ) -private val DarkColors = darkColorScheme( - primary = md_theme_dark_primary, - onPrimary = md_theme_dark_onPrimary, - primaryContainer = md_theme_dark_primaryContainer, - onPrimaryContainer = md_theme_dark_onPrimaryContainer, - inversePrimary = md_theme_dark_inversePrimary, - secondary = md_theme_dark_secondary, - onSecondary = md_theme_dark_onSecondary, - secondaryContainer = md_theme_dark_secondaryContainer, - onSecondaryContainer = md_theme_dark_onSecondaryContainer, - tertiary = md_theme_dark_tertiary, - onTertiary = md_theme_dark_onTertiary, - tertiaryContainer = md_theme_dark_tertiaryContainer, - onTertiaryContainer = md_theme_dark_onTertiaryContainer, - error = md_theme_dark_error, - onError = md_theme_dark_onError, - errorContainer = md_theme_dark_errorContainer, - onErrorContainer = md_theme_dark_onErrorContainer, - background = md_theme_dark_background, - onBackground = md_theme_dark_onBackground, - surface = md_theme_dark_surface, - onSurface = md_theme_dark_onSurface, - surfaceVariant = md_theme_dark_surfaceVariant, - onSurfaceVariant = md_theme_dark_onSurfaceVariant, - surfaceTint = md_theme_dark_surfaceTint, - surfaceBright = md_theme_dark_surfaceBright, - surfaceDim = md_theme_dark_surfaceDim, - surfaceContainer = md_theme_dark_surfaceContainer, - surfaceContainerHigh = md_theme_dark_surfaceContainerHigh, - surfaceContainerHighest = md_theme_dark_surfaceContainerHighest, - surfaceContainerLow = md_theme_dark_surfaceContainerLow, - surfaceContainerLowest = md_theme_dark_surfaceContainerLowest, - outline = md_theme_dark_outline, - outlineVariant = md_theme_dark_outlineVariant, - inverseSurface = md_theme_dark_inverseSurface, - inverseOnSurface = md_theme_dark_inverseOnSurface, - scrim = md_theme_dark_scrim, -) +private val DarkColors = + darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + inversePrimary = md_theme_dark_inversePrimary, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + onError = md_theme_dark_onError, + errorContainer = md_theme_dark_errorContainer, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + surfaceTint = md_theme_dark_surfaceTint, + surfaceBright = md_theme_dark_surfaceBright, + surfaceDim = md_theme_dark_surfaceDim, + surfaceContainer = md_theme_dark_surfaceContainer, + surfaceContainerHigh = md_theme_dark_surfaceContainerHigh, + surfaceContainerHighest = md_theme_dark_surfaceContainerHighest, + surfaceContainerLow = md_theme_dark_surfaceContainerLow, + surfaceContainerLowest = md_theme_dark_surfaceContainerLowest, + outline = md_theme_dark_outline, + outlineVariant = md_theme_dark_outlineVariant, + inverseSurface = md_theme_dark_inverseSurface, + inverseOnSurface = md_theme_dark_inverseOnSurface, + scrim = md_theme_dark_scrim, + ) @Composable fun FoxyVpnTheme( @@ -103,19 +106,21 @@ fun FoxyVpnTheme( content: @Composable () -> Unit, ) { val systemInDarkTheme = isSystemInDarkTheme() - val darkTheme = when (themeMode) { - ThemeMode.SYSTEM -> systemInDarkTheme - ThemeMode.LIGHT -> false - ThemeMode.DARK -> true - } + val darkTheme = + when (themeMode) { + ThemeMode.SYSTEM -> systemInDarkTheme + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + } val context = LocalContext.current - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + val colorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColors + else -> LightColors } - darkTheme -> DarkColors - else -> LightColors - } val view = LocalView.current if (!view.isInEditMode) { diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/theme/ThemeController.kt b/app/src/main/java/com/vauth/foxyvpn/ui/theme/ThemeController.kt new file mode 100644 index 00000000..8e430e26 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/ui/theme/ThemeController.kt @@ -0,0 +1,49 @@ +package com.vauth.foxyvpn.ui.theme + +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 com.vauth.foxyvpn.data.SettingsStore +import com.vauth.foxyvpn.data.ThemeMode + +class ThemeController( + private val settingsStore: SettingsStore, + private val onChanged: () -> Unit = {}, +) { + + var mode: ThemeMode by mutableStateOf(settingsStore.themeMode) + private set + + fun resolveDark(systemInDarkTheme: Boolean): Boolean = + when (mode) { + ThemeMode.SYSTEM -> systemInDarkTheme + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + } + + fun set(next: ThemeMode) { + if (next == mode) return + mode = next + settingsStore.themeMode = next + onChanged() + } + + fun toggle(systemInDarkTheme: Boolean) { + set(if (resolveDark(systemInDarkTheme)) ThemeMode.LIGHT else ThemeMode.DARK) + } +} + +@Composable +fun rememberThemeController(settingsStore: SettingsStore): ThemeController = + androidx.compose.ui.platform.LocalContext.current.applicationContext.let { context -> + remember(settingsStore, context) { + ThemeController(settingsStore) { + com.vauth.foxyvpn.widget.QuickConnectWidgetProvider.updateAll( + context, + com.vauth.foxyvpn.vpn.FoxyVpnService.state.value, + ) + } + } + } diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/theme/ThemeMode.kt b/app/src/main/java/com/vauth/foxyvpn/ui/theme/ThemeMode.kt deleted file mode 100644 index 21679e95..00000000 --- a/app/src/main/java/com/vauth/foxyvpn/ui/theme/ThemeMode.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.vauth.foxyvpn.ui.theme - -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 com.vauth.foxyvpn.data.SettingsStore - -enum class ThemeMode { - SYSTEM, - LIGHT, - DARK, -} - -class ThemeController(private val settingsStore: SettingsStore) { - - var mode: ThemeMode by mutableStateOf(settingsStore.themeMode) - private set - - fun resolveDark(systemInDarkTheme: Boolean): Boolean = when (mode) { - ThemeMode.SYSTEM -> systemInDarkTheme - ThemeMode.LIGHT -> false - ThemeMode.DARK -> true - } - - fun set(next: ThemeMode) { - if (next == mode) return - mode = next - settingsStore.themeMode = next - } - - fun toggle(systemInDarkTheme: Boolean) { - set(if (resolveDark(systemInDarkTheme)) ThemeMode.LIGHT else ThemeMode.DARK) - } -} - -@Composable -fun rememberThemeController(settingsStore: SettingsStore): ThemeController = - remember(settingsStore) { ThemeController(settingsStore) } diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/theme/Type.kt b/app/src/main/java/com/vauth/foxyvpn/ui/theme/Type.kt index c06cabb6..22df6b5d 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/theme/Type.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/theme/Type.kt @@ -5,13 +5,20 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -val FoxyTypography = Typography( - displayLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 57.sp, lineHeight = 64.sp), - headlineLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 40.sp), - headlineMedium = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 28.sp, lineHeight = 36.sp), - titleLarge = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp), - titleMedium = TextStyle(fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 24.sp), - bodyLarge = TextStyle(fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp), - bodyMedium = TextStyle(fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp), - labelLarge = TextStyle(fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp), -) +val FoxyTypography = + Typography( + displayLarge = + TextStyle(fontWeight = FontWeight.Bold, fontSize = 57.sp, lineHeight = 64.sp), + headlineLarge = + TextStyle(fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 40.sp), + headlineMedium = + TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 28.sp, lineHeight = 36.sp), + titleLarge = + TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp), + titleMedium = + TextStyle(fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 24.sp), + bodyLarge = TextStyle(fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp), + bodyMedium = + TextStyle(fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp), + labelLarge = TextStyle(fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp), + ) diff --git a/app/src/main/java/com/vauth/foxyvpn/update/AppUpdater.kt b/app/src/main/java/com/vauth/foxyvpn/update/AppUpdater.kt new file mode 100644 index 00000000..1d6faadc --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/update/AppUpdater.kt @@ -0,0 +1,397 @@ +package com.vauth.foxyvpn.update + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.content.FileProvider +import com.vauth.foxyvpn.BuildConfig +import com.vauth.foxyvpn.data.awaitResponse +import com.vauth.foxyvpn.data.readBoundedUtf8 +import java.io.File +import java.io.IOException +import java.security.MessageDigest +import java.util.UUID +import java.util.concurrent.TimeUnit +import java.util.zip.ZipFile +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.json.JSONObject + +private const val RELEASE_API = com.vauth.foxyvpn.data.AppLinks.RELEASE_API +private const val MAX_APK_BYTES = 512L * 1024 * 1024 +private const val FAILED_CHECK_BACKOFF_MS = 15 * 60 * 1000L +private const val CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000L + +internal sealed interface UpdateState { + data object Idle : UpdateState + + data object Checking : UpdateState + + data object Current : UpdateState + + data class Available(val release: AppRelease) : UpdateState + + data class Downloading(val bytes: Long, val total: Long) : UpdateState + + data class Ready(val file: File, val version: String, val sha256: String) : UpdateState + + data class Failed(val message: String) : UpdateState +} + +internal class AppUpdater(private val context: Context) { + private val prefs = context.getSharedPreferences("foxyvpn_updates", Context.MODE_PRIVATE) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + private val client = + OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .callTimeout(10, TimeUnit.MINUTES) + .followSslRedirects(false) + .build() + private val directory = File(context.cacheDir, "updates").apply { mkdirs() } + private var operation: Job? = null + private val publicationLock = Any() + private var generation = 0L + private val mutablePromptVisible = MutableStateFlow(false) + val promptVisible: StateFlow = mutablePromptVisible + + fun showPrompt() { + mutablePromptVisible.value = true + } + + fun hidePrompt() { + mutablePromptVisible.value = false + } + + private val mutableState = MutableStateFlow(UpdateState.Idle) + val state: StateFlow = mutableState + var automaticChecks: Boolean + get() = prefs.getBoolean("automatic_checks", true) + set(value) { + prefs.edit().putBoolean("automatic_checks", value).apply() + } + + init { + directory.listFiles()?.forEach { it.delete() } + } + + fun check(automatic: Boolean = false) { + if (operation?.isActive == true) return + if (mutableState.value is UpdateState.Ready) { + if (automatic) return + cancel() + } + + val now = System.currentTimeMillis() + if ( + automatic && + (!automaticChecks || + now - prefs.getLong("last_successful_check", 0) in 0 until CHECK_INTERVAL_MS || + now - prefs.getLong("last_check_attempt", 0) in 0 until FAILED_CHECK_BACKOFF_MS) + ) + return + prefs.edit().putLong("last_check_attempt", now).apply() + val previousTag = (mutableState.value as? UpdateState.Available)?.release?.tag + operation = + scope.launch { + mutableState.value = UpdateState.Checking + try { + val release = + withContext(Dispatchers.IO) { + discoverRelease(allowSameVersionName = !automatic) + } + prefs + .edit() + .putLong("last_successful_check", System.currentTimeMillis()) + .apply() + mutableState.value = + if (release == null) UpdateState.Current else UpdateState.Available(release) + if (release != null && (!automatic || release.tag != previousTag)) showPrompt() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + mutableState.value = + if (automatic) UpdateState.Idle + else UpdateState.Failed(error.message ?: "Update check failed") + } + } + } + + fun download(release: AppRelease) { + if (operation?.isActive == true) return + val myGeneration = synchronized(publicationLock) { ++generation } + operation = + scope.launch { + val file = File(directory, "${UUID.randomUUID()}.apk") + var retain = false + try { + mutableState.value = UpdateState.Downloading(0, release.apk.size) + val expectedHash = withContext(Dispatchers.IO) { expectedHash(release) } + downloadAsset(release.apk, file, myGeneration) + val version = withContext(Dispatchers.IO) { validateApk(file, expectedHash) } + if (version == null) mutableState.value = UpdateState.Current + else { + retain = true + mutableState.value = UpdateState.Ready(file, version, expectedHash) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + mutableState.value = + UpdateState.Failed(error.message ?: "Update download failed") + } finally { + if (!retain) file.delete() + } + } + } + + fun cancel() { + operation?.cancel() + operation = null + synchronized(publicationLock) { + generation++ + (mutableState.value as? UpdateState.Ready)?.file?.delete() + mutableState.value = UpdateState.Idle + } + } + + suspend fun installationIntent(ready: UpdateState.Ready): Intent { + if (!context.packageManager.canRequestPackageInstalls()) { + return Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${context.packageName}"), + ) + } + withContext(Dispatchers.IO) { + check(validateApk(ready.file, ready.sha256) != null) { + "This update is no longer newer than the installed app" + } + } + val uri = + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", ready.file) + return Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.android.package-archive") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + private suspend fun discoverRelease(allowSameVersionName: Boolean): AppRelease? { + val request = + Request.Builder() + .url(RELEASE_API) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .build() + val json = + client.newCall(request).awaitResponse().use { response -> + check(response.code != 404) { + "No public release is available. Publish signed APKs in ${com.vauth.foxyvpn.data.AppLinks.REPOSITORY} releases." + } + check(response.isSuccessful) { + "Release check failed: HTTP ${response.code}. Try again later." + } + JSONObject( + response.body?.byteStream()?.use { it.readBoundedUtf8(1_048_576) } + ?: error("Empty release response") + ) + } + return parseAppRelease( + json, + BuildConfig.VERSION_NAME, + Build.SUPPORTED_ABIS.toList(), + allowSameVersionName, + ) + } + + private suspend fun expectedHash(release: AppRelease): String { + release.apk.digest + ?.takeIf { it.matches(Regex("[a-fA-F0-9]{64}")) } + ?.let { + return it.lowercase() + } + val sums = + release.checksums + ?: error("Release integrity metadata is missing (SHA-256 digest or SHA256SUMS)") + return client.newCall(Request.Builder().url(sums.url).build()).awaitResponse().use { + response -> + check(response.isSuccessful) { "Cannot fetch release checksums" } + val lines = + response.body?.byteStream()?.use { it.readBoundedUtf8(131_072) } + ?: error("Empty checksum file") + lines + .lineSequence() + .mapNotNull { line -> + val match = Regex("^([a-fA-F0-9]{64}) [ *](.+)$").matchEntire(line.trim()) + match + ?.takeIf { it.groupValues[2] == release.apk.name } + ?.groupValues + ?.get(1) + ?.lowercase() + } + .singleOrNull() ?: error("No unique SHA-256 checksum for this APK") + } + } + + private suspend fun downloadAsset(asset: UpdateAsset, file: File, myGeneration: Long): Unit = + suspendCancellableCoroutine { continuation -> + if ( + !trustedAssetUrl(asset.url) || + android.os.StatFs(directory.absolutePath).availableBytes < + asset.size + 16 * 1024 * 1024 + ) { + continuation.resumeWithException( + IOException("Untrusted download URL or insufficient storage") + ) + return@suspendCancellableCoroutine + } + val call = client.newCall(Request.Builder().url(asset.url).build()) + continuation.invokeOnCancellation { call.cancel() } + call.enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (continuation.isActive) continuation.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + try { + response.use { + check(response.isSuccessful) { + "Download failed: HTTP ${response.code}" + } + val body = response.body ?: error("Empty APK response") + var total = 0L + var published = 0L + body.byteStream().use { input -> + file.outputStream().use { output -> + val buffer = ByteArray(64 * 1024) + while (true) { + if (!continuation.isActive) + throw IOException("Download cancelled") + val n = input.read(buffer) + if (n < 0) break + total += n + check(total <= asset.size && total <= MAX_APK_BYTES) { + "APK exceeds release size" + } + output.write(buffer, 0, n) + if ( + total - published >= 256 * 1024 || + total == asset.size + ) { + published = total + synchronized(publicationLock) { + if ( + continuation.isActive && + generation == myGeneration + ) + mutableState.value = + UpdateState.Downloading( + total, + asset.size, + ) + } + } + } + } + } + check(total == asset.size) { "Incomplete APK download" } + } + if (continuation.isActive) continuation.resume(Unit) else file.delete() + } catch (error: Exception) { + file.delete() + if (continuation.isActive) continuation.resumeWithException(error) + } + } + } + ) + } + + @Suppress("DEPRECATION") + private fun validateApk(file: File, expectedHash: String): String? { + check(file.isFile && file.canonicalFile.parentFile == directory.canonicalFile) { + "Update file is missing" + } + val hash = + MessageDigest.getInstance("SHA-256") + .apply { + file.inputStream().use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + val n = input.read(buffer) + if (n < 0) break + update(buffer, 0, n) + } + } + } + .digest() + .joinToString("") { "%02x".format(it) } + check(hash.equals(expectedHash, true)) { "APK integrity check failed" } + val pm = context.packageManager + val flags = + if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES + else PackageManager.GET_SIGNATURES + val downloaded = + pm.getPackageArchiveInfo(file.absolutePath, flags) ?: error("Invalid or unsigned APK") + val installed = pm.getPackageInfo(context.packageName, flags) + val nativeAbis = + ZipFile(file).use { zip -> nativeCoreAbis(zip.entries().asSequence().map { it.name }) } + fun code(info: PackageInfo) = + if (Build.VERSION.SDK_INT >= 28) info.longVersionCode else info.versionCode.toLong() + val candidate = + ApkIdentity( + downloaded.packageName, + code(downloaded), + downloaded.applicationInfo?.minSdkVersion ?: Int.MAX_VALUE, + signers(downloaded), + nativeAbis, + ) + val current = + ApkIdentity(installed.packageName, code(installed), 26, signers(installed), emptySet()) + if ( + !verifyUpdateIdentity( + candidate, + current, + Build.SUPPORTED_ABIS.toList(), + Build.VERSION.SDK_INT, + ) + ) + return null + return downloaded.versionName ?: code(downloaded).toString() + } + + @Suppress("DEPRECATION") + private fun signers(info: PackageInfo): Set { + val certificates = + if (Build.VERSION.SDK_INT >= 28) info.signingInfo?.apkContentsSigners + else info.signatures + return certificates + .orEmpty() + .map { signature -> + MessageDigest.getInstance("SHA-256").digest(signature.toByteArray()).joinToString( + "" + ) { + "%02x".format(it) + } + } + .toSet() + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/update/UpdatePolicy.kt b/app/src/main/java/com/vauth/foxyvpn/update/UpdatePolicy.kt new file mode 100644 index 00000000..7ace7629 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/update/UpdatePolicy.kt @@ -0,0 +1,145 @@ +package com.vauth.foxyvpn.update + +import org.json.JSONObject + +internal data class UpdateAsset( + val name: String, + val url: String, + val size: Long, + val digest: String?, +) + +internal data class AppRelease(val tag: String, val apk: UpdateAsset, val checksums: UpdateAsset?) + +internal data class ApkIdentity( + val packageName: String, + val versionCode: Long, + val minSdk: Int, + val signers: Set, + val nativeAbis: Set, +) + +internal fun verifyUpdateIdentity( + candidate: ApkIdentity, + installed: ApkIdentity, + supportedAbis: List, + sdk: Int, +): Boolean { + check(candidate.packageName == installed.packageName) { "APK package does not match FoxyVPN" } + check(candidate.signers.isNotEmpty() && candidate.signers == installed.signers) { + "APK signing identity differs. Debug builds cannot install release updates; signing-key rotation requires a reviewed migration." + } + check(candidate.minSdk <= sdk) { "Update requires a newer Android version" } + check(candidate.nativeAbis.isNotEmpty() && supportedAbis.any { it in candidate.nativeAbis }) { + "Update native libraries do not support this device" + } + return candidate.versionCode > installed.versionCode +} + +internal fun nativeCoreAbis(entries: Sequence): Set = + entries + .mapNotNull { name -> + val parts = name.split('/') + parts.takeIf { it.size == 3 && it[0] == "lib" && it[2] == "libbox.so" }?.get(1) + } + .toSet() + +private val knownApkAbis = listOf("armeabi-v7a", "arm64-v8a", "x86_64", "x86", "armeabi", "riscv64") + +internal fun apkAssetAbi(name: String): String? { + val lower = name.lowercase(java.util.Locale.ROOT) + return knownApkAbis.firstOrNull { abi -> + Regex("(^|[-_.])${Regex.escape(abi)}([-_.]|$)").containsMatchIn(lower) + } +} + +private fun releaseTagComparison(tag: String, currentVersionName: String): Int? { + fun parts(value: String): List? { + val segments = value.removePrefix("v").split('.') + if ( + segments.size !in 2..4 || + segments.any { it.isEmpty() || it.any { c -> c !in '0'..'9' } } + ) + return null + return segments.map { it.toLongOrNull() ?: return null } + } + val release = parts(tag) ?: return null + val current = parts(currentVersionName) ?: return null + for (index in 0 until maxOf(release.size, current.size)) { + val comparison = release.getOrElse(index) { 0L }.compareTo(current.getOrElse(index) { 0L }) + if (comparison != 0) return comparison + } + return 0 +} + +internal fun releaseTagIsNotNewer(tag: String, currentVersionName: String): Boolean = + releaseTagComparison(tag, currentVersionName)?.let { it <= 0 } + ?: (tag.removePrefix("v") == currentVersionName) + +internal fun parseAppRelease( + json: JSONObject, + currentVersionName: String, + supportedAbis: List, + allowSameVersionName: Boolean = false, +): AppRelease? { + check(!json.optBoolean("draft") && !json.optBoolean("prerelease")) { + "No stable release is available" + } + val tag = json.getString("tag_name") + + val comparison = releaseTagComparison(tag, currentVersionName) + if (comparison != null && (comparison < 0 || comparison == 0 && !allowSameVersionName)) + return null + if (comparison == null && !allowSameVersionName && tag.removePrefix("v") == currentVersionName) + return null + val assets = json.getJSONArray("assets") + val parsed = + (0 until assets.length()).map { index -> + val asset = assets.getJSONObject(index) + UpdateAsset( + asset.getString("name"), + asset.getString("browser_download_url"), + asset.getLong("size"), + asset + .optString("digest") + .takeIf { it.startsWith("sha256:") } + ?.removePrefix("sha256:"), + ) + } + val apks = + parsed.filter { + it.name.endsWith(".apk", true) && + it.size in 1..512L * 1024 * 1024 && + trustedAssetUrl(it.url) + } + val apk = + supportedAbis.firstNotNullOfOrNull { abi -> + apks.singleOrNull { apkAssetAbi(it.name) == abi } + } + ?: apks.singleOrNull { + apkAssetAbi(it.name) == null && + Regex("(^|[-_.])universal([-_.]|$)") + .containsMatchIn(it.name.lowercase(java.util.Locale.ROOT)) + } + ?: apks.singleOrNull()?.takeIf { apkAssetAbi(it.name) == null } + ?: error("This release has no unambiguous APK for this device's ABI") + return AppRelease( + tag, + apk, + parsed.singleOrNull { it.name == "SHA256SUMS" && trustedAssetUrl(it.url) }, + ) +} + +internal fun trustedAssetUrl(value: String): Boolean = + runCatching { + val uri = java.net.URI(value) + uri.scheme == "https" && + uri.host == "github.com" && + uri.rawUserInfo == null && + uri.rawFragment == null && + uri.rawQuery == null && + uri.port == -1 && + uri.rawPath?.startsWith(com.vauth.foxyvpn.data.AppLinks.RELEASE_DOWNLOAD_PATH) == + true + } + .getOrDefault(false) diff --git a/app/src/main/java/com/vauth/foxyvpn/update/UpdateUi.kt b/app/src/main/java/com/vauth/foxyvpn/update/UpdateUi.kt new file mode 100644 index 00000000..f526dbc3 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/update/UpdateUi.kt @@ -0,0 +1,144 @@ +package com.vauth.foxyvpn.update + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vauth.foxyvpn.R +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun UpdatePrompt(updater: AppUpdater) { + val state by updater.state.collectAsStateWithLifecycle() + val visible by updater.promptVisible.collectAsStateWithLifecycle() + val context = LocalContext.current + val scope = rememberCoroutineScope() + var installError by remember(state) { mutableStateOf(null) } + var installing by remember(state) { mutableStateOf(false) } + if (!visible) return + AlertDialog( + onDismissRequest = { updater.hidePrompt() }, + title = { Text(stringResource(R.string.update_dialog_title)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + Text( + when (val current = state) { + UpdateState.Idle -> stringResource(R.string.update_idle_help) + UpdateState.Checking -> stringResource(R.string.update_checking) + UpdateState.Current -> stringResource(R.string.update_current) + is UpdateState.Available -> + stringResource(R.string.update_available_msg, current.release.tag) + is UpdateState.Downloading -> + stringResource( + R.string.update_downloading_fmt, + current.bytes / (1024 * 1024), + current.total / (1024 * 1024), + ) + is UpdateState.Ready -> + stringResource(R.string.update_ready_msg, current.version) + is UpdateState.Failed -> current.message + } + ) + if (state is UpdateState.Downloading || state == UpdateState.Checking) { + Spacer(Modifier.height(12.dp)) + val progress = state as? UpdateState.Downloading + if (progress == null) LinearProgressIndicator() + else + LinearProgressIndicator( + progress = { + progress.bytes.toFloat() / progress.total.coerceAtLeast(1) + } + ) + } + installError?.let { + Spacer(Modifier.height(8.dp)) + Text(it) + } + } + }, + confirmButton = { + when (val current = state) { + is UpdateState.Available -> + TextButton(onClick = { updater.download(current.release) }) { + Text(stringResource(R.string.update_btn_download)) + } + is UpdateState.Ready -> + TextButton( + enabled = !installing, + onClick = { + installing = true + scope.launch { + try { + context.startActivity(updater.installationIntent(current)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + installError = + error.message + ?: context.getString(R.string.update_installer_failed) + } finally { + installing = false + } + } + }, + ) { + Text(stringResource(R.string.update_btn_install)) + } + UpdateState.Checking, + is UpdateState.Downloading -> + TextButton(onClick = { updater.cancel() }) { + Text(stringResource(R.string.cancel)) + } + else -> + TextButton(onClick = { updater.check() }) { + Text( + if (state is UpdateState.Failed) { + stringResource(R.string.retry) + } else { + stringResource(R.string.update_btn_check) + } + ) + } + } + }, + dismissButton = { + FlowRow(horizontalArrangement = Arrangement.End) { + if (state is UpdateState.Ready) { + TextButton( + enabled = !installing, + onClick = { + updater.cancel() + updater.check() + }, + ) { + Text(stringResource(R.string.update_btn_check_again)) + } + } + TextButton(onClick = { updater.hidePrompt() }) { + Text(stringResource(R.string.close)) + } + } + }, + ) +} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/CredentialDeadline.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/CredentialDeadline.kt new file mode 100644 index 00000000..e2f046ee --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/vpn/CredentialDeadline.kt @@ -0,0 +1,23 @@ +package com.vauth.foxyvpn.vpn + +internal fun credentialDeadlineMillis( + expirySeconds: Long?, + nowWallMillis: Long, + nowElapsedMillis: Long, +): Long { + val maximumAge = 5 * 60_000L + val expiryMillis = + expirySeconds?.let { seconds -> + when { + seconds <= 0 -> 0L + seconds > Long.MAX_VALUE / 1000L -> Long.MAX_VALUE + else -> seconds * 1000L + } + } + val lifetime = + if (expiryMillis == null) maximumAge + else if (expiryMillis <= nowWallMillis) 0L + else (expiryMillis - nowWallMillis - 30_000L).coerceIn(0L, maximumAge) + return if (nowElapsedMillis > Long.MAX_VALUE - lifetime) Long.MAX_VALUE + else nowElapsedMillis + lifetime +} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/ExitCheck.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/ExitCheck.kt index 0f9dcb7e..e4afe935 100644 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/ExitCheck.kt +++ b/app/src/main/java/com/vauth/foxyvpn/vpn/ExitCheck.kt @@ -1,76 +1,57 @@ package com.vauth.foxyvpn.vpn +import com.vauth.foxyvpn.data.ControlPlaneHttp +import com.vauth.foxyvpn.data.SettingsStore +import com.vauth.foxyvpn.data.awaitResponse +import com.vauth.foxyvpn.data.readBoundedText +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient import okhttp3.Request -import java.net.InetSocketAddress -import java.net.Proxy -import java.util.concurrent.TimeUnit private const val EXIT_CHECK_URL = "https://www.cloudflare.com/cdn-cgi/trace" private const val EXIT_CHECK_TIMEOUT_SECONDS = 15L -data class IpLocationInfo( - val ip: String, - val countryCode: String, -) +data class IpLocationInfo(val ip: String, val countryCode: String) class ExitCheck { - suspend fun fetchDirectIpInfo(): Result = withContext(Dispatchers.IO) { - val client = OkHttpClient.Builder() - .connectTimeout(8, TimeUnit.SECONDS) - .readTimeout(8, TimeUnit.SECONDS) - .callTimeout(8, TimeUnit.SECONDS) - .build() - try { + suspend fun fetchDirectIpInfo(): Result = + withContext(Dispatchers.IO) { runCatching { - val request = Request.Builder().url(EXIT_CHECK_URL).build() - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) error("Direct IP check request failed: HTTP ${response.code}") - parseTrace(response.body?.string().orEmpty()) + val request = Request.Builder().url(EXIT_CHECK_URL).build() + ControlPlaneHttp.client.newCall(request).awaitResponse().use { response -> + check(response.isSuccessful) { + "Direct IP check failed: HTTP ${response.code}" + } + parseTrace(response.body?.readBoundedText(16 * 1024).orEmpty()) + } } - } - } finally { - client.dispatcher.executorService.shutdown() - client.connectionPool.evictAll() - } - } - - suspend fun fetchProxiedIpInfo(socksPort: Int): Result = withContext(Dispatchers.IO) { - val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", socksPort)) - val client = OkHttpClient.Builder() - .proxy(proxy) - .connectTimeout(EXIT_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .readTimeout(EXIT_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .callTimeout(EXIT_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .build() - try { - runCatching { - val request = Request.Builder().url(EXIT_CHECK_URL).build() - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) error("Proxied exit check failed: HTTP ${response.code}") - parseTrace(response.body?.string().orEmpty()) - } - } - } finally { - client.dispatcher.executorService.shutdown() - client.connectionPool.evictAll() + .onFailure { if (it is CancellationException) throw it } } - } - suspend fun verifyExitCountry(socksPort: Int, expectedCountryCode: String?): Result = - fetchProxiedIpInfo(socksPort).mapCatching { info -> - val actualCountry = info.countryCode - if (expectedCountryCode != null && - !expectedCountryCode.equals("REC", ignoreCase = true) && - !actualCountry.equals(expectedCountryCode, ignoreCase = true) - ) { - error("Exit country mismatch: expected $expectedCountryCode, got $actualCountry") + suspend fun fetchProxiedIpInfo( + socksPort: Int, + username: String = "", + password: String = "", + ): Result = + withContext(Dispatchers.IO) { + val client = localProxyClient(socksPort, username, password, EXIT_CHECK_TIMEOUT_SECONDS) + try { + runCatching { + val request = Request.Builder().url(EXIT_CHECK_URL).build() + client.newCall(request).awaitResponse().use { response -> + if (!response.isSuccessful) + error("Proxied exit check failed: HTTP ${response.code}") + parseTrace(response.body?.readBoundedText(16 * 1024).orEmpty()) + } + } + .onFailure { if (it is CancellationException) throw it } + } finally { + client.dispatcher.executorService.shutdown() + client.connectionPool.evictAll() } - actualCountry } private fun parseTrace(body: String): IpLocationInfo { @@ -83,9 +64,15 @@ class ExitCheck { loc = line.removePrefix("loc=").trim() } } - if (ip.isEmpty() && loc.isEmpty()) { - error("Trace response did not contain IP or location") + if (!SettingsStore.isValidIpAddress(ip)) { + error("Trace response did not contain a valid IP address") } - return IpLocationInfo(ip = ip, countryCode = loc) + return IpLocationInfo( + ip = ip, + countryCode = + loc.uppercase(java.util.Locale.ROOT) + .takeIf { it.length == 2 && it.all { letter -> letter in 'A'..'Z' } } + .orEmpty(), + ) } } diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyPlatformInterface.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyPlatformInterface.kt new file mode 100644 index 00000000..a0f1f816 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyPlatformInterface.kt @@ -0,0 +1,472 @@ +package com.vauth.foxyvpn.vpn + +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.os.ParcelFileDescriptor +import android.os.Process +import android.system.OsConstants +import com.vauth.foxyvpn.data.AppLogger +import io.nekohasekai.libbox.AutoRedirectHandler +import io.nekohasekai.libbox.AutoRedirectSession +import io.nekohasekai.libbox.BridgeOptions +import io.nekohasekai.libbox.BridgeSession +import io.nekohasekai.libbox.CommandServerHandler +import io.nekohasekai.libbox.ConnectionOwner +import io.nekohasekai.libbox.ExchangeContext +import io.nekohasekai.libbox.InterfaceUpdateListener +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.LocalDNSTransport +import io.nekohasekai.libbox.NeighborUpdateListener +import io.nekohasekai.libbox.NetworkInterface as LibboxNetworkInterface +import io.nekohasekai.libbox.NetworkInterfaceIterator +import io.nekohasekai.libbox.Notification +import io.nekohasekai.libbox.PlatformInterface +import io.nekohasekai.libbox.PlatformUser +import io.nekohasekai.libbox.ShellSession +import io.nekohasekai.libbox.StringIterator +import io.nekohasekai.libbox.SystemProxyStatus +import io.nekohasekai.libbox.TunOptions +import io.nekohasekai.libbox.WIFIState +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetSocketAddress +import java.net.NetworkInterface + +class FoxyPlatformInterface( + private val vpnService: FoxyVpnService, + private val includeOnly: Boolean, + private val onTunOpened: () -> Unit, +) : PlatformInterface, CommandServerHandler { + + companion object { + private const val TAG = "FoxyPlatformInterface" + } + + @Volatile private var vpnInterface: ParcelFileDescriptor? = null + + fun closeTun() { + runCatching { vpnInterface?.close() } + .onFailure { AppLogger.w(TAG, "error closing VPN ParcelFileDescriptor", it) } + vpnInterface = null + } + + override fun usePlatformAutoDetectInterfaceControl(): Boolean = true + + override fun autoDetectInterfaceControl(fd: Int) { + + if (!vpnService.nativeProtectionRequired) return + val protected = vpnService.protect(fd) + if (!protected) { + throw java.io.IOException("VPN refused native socket protection") + } + } + + override fun openTun(options: TunOptions): Int { + AppLogger.i(TAG, "openTun called with MTU=${options.mtu}") + val builder = + vpnService.Builder().setSession("FoxyVPN").setMtu(options.mtu).setBlocking(true) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + builder.setMetered(false) + } + + var hasIpv4 = false + var hasIpv6 = false + + val inet4 = options.inet4Address + if (inet4 != null) { + while (inet4.hasNext()) { + val prefix = inet4.next() + builder.addAddress(prefix.address(), prefix.prefix()) + hasIpv4 = true + } + } + + val inet6 = options.inet6Address + if (inet6 != null) { + while (inet6.hasNext()) { + val prefix = inet6.next() + builder.addAddress(prefix.address(), prefix.prefix()) + hasIpv6 = true + } + } + + if (!hasIpv4 && !hasIpv6) { + builder.addAddress("172.19.0.1", 30) + hasIpv4 = true + } + + if (options.autoRoute) { + if (hasIpv4) { + val routes = options.inet4RouteRange + require(routes != null && routes.hasNext()) { "Missing IPv4 tunnel routes" } + while (routes.hasNext()) { + val prefix = routes.next() + builder.addRoute(prefix.address(), prefix.prefix()) + } + } + if (hasIpv6) { + val routes = options.inet6RouteRange + require(routes != null && routes.hasNext()) { "Missing IPv6 tunnel routes" } + while (routes.hasNext()) { + val prefix = routes.next() + builder.addRoute(prefix.address(), prefix.prefix()) + } + } + var addedDns = false + val dnsIter = runCatching { options.dnsServerAddress }.getOrNull() + if (dnsIter != null) { + while (dnsIter.hasNext()) { + val dns = dnsIter.next() + if (dns.isNotBlank()) { + builder.addDnsServer(dns) + builder.addRoute(dns, if (':' in dns) 128 else 32) + addedDns = true + } + } + } + if (!addedDns) { + builder.addDnsServer("172.19.0.2") + builder.addRoute("172.19.0.2", 32) + } + } + + if (includeOnly) { + val included = options.includePackage + var allowedCount = 0 + while (included != null && included.hasNext()) { + val pkg = included.next() + if (pkg.isBlank() || pkg == vpnService.packageName) continue + try { + builder.addAllowedApplication(pkg) + allowedCount++ + } catch (_: android.content.pm.PackageManager.NameNotFoundException) { + AppLogger.w(TAG, "included app is no longer installed: $pkg") + } + } + + require(allowedCount > 0) { + "No included apps are available. Update split tunneling settings." + } + } else { + val excluded = options.excludePackage + val packages = mutableSetOf(vpnService.packageName) + while (excluded != null && excluded.hasNext()) { + excluded.next().takeIf { it.isNotBlank() }?.let { packages.add(it) } + } + packages.forEach { pkg -> + try { + builder.addDisallowedApplication(pkg) + } catch (_: android.content.pm.PackageManager.NameNotFoundException) { + AppLogger.w(TAG, "excluded app is no longer installed: $pkg") + } + } + } + + vpnInterface?.close() + val pfd = + builder.establish() + ?: error( + "Failed to establish Android VPN interface (Builder.establish() returned null)" + ) + vpnInterface = pfd + val fd = pfd.fd + onTunOpened() + return fd + } + + override fun useProcFS(): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + + override fun findConnectionOwner( + ipProtocol: Int, + sourceAddress: String, + sourcePort: Int, + destinationAddress: String, + destinationPort: Int, + ): ConnectionOwner { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val cm = vpnService.getSystemService(ConnectivityManager::class.java) + if (cm != null) { + val uid = + runCatching { + cm.getConnectionOwnerUid( + ipProtocol, + InetSocketAddress(sourceAddress, sourcePort), + InetSocketAddress(destinationAddress, destinationPort), + ) + } + .getOrDefault(Process.INVALID_UID) + if (uid != Process.INVALID_UID) { + val packages = + runCatching { vpnService.packageManager.getPackagesForUid(uid) }.getOrNull() + val owner = ConnectionOwner() + owner.userId = uid + owner.userName = packages?.firstOrNull() ?: "" + return owner + } + } + } + throw java.io.FileNotFoundException("connection owner not found") + } + + @Volatile private var defaultInterfaceListener: InterfaceUpdateListener? = null + + override fun startDefaultInterfaceMonitor(listener: InterfaceUpdateListener) { + defaultInterfaceListener = listener + notifyDefaultInterface() + } + + override fun closeDefaultInterfaceMonitor(listener: InterfaceUpdateListener) { + if (defaultInterfaceListener == listener) { + defaultInterfaceListener = null + } + } + + fun notifyDefaultInterface() { + val listener = defaultInterfaceListener ?: return + val cm = vpnService.getSystemService(ConnectivityManager::class.java) ?: return + val activeNet = vpnService.firstUsableNetwork() + if (activeNet == null) { + + listener.updateDefaultInterface("", -1, false, false) + return + } + val caps = cm.getNetworkCapabilities(activeNet) ?: return + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return + val lp = cm.getLinkProperties(activeNet) ?: return + val ifaceName = lp.interfaceName ?: return + val nif = + runCatching { NetworkInterface.getByName(ifaceName) }.getOrNull() + ?: runCatching { + NetworkInterface.getNetworkInterfaces()?.toList()?.find { + it.name == ifaceName + } + } + .getOrNull() + val index = nif?.index ?: return + val isExpensive = !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + val isConstrained = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED) + AppLogger.d( + TAG, + "reporting default interface to sing-box: $ifaceName index=$index expensive=$isExpensive", + ) + runCatching { + listener.updateDefaultInterface(ifaceName, index, isExpensive, isConstrained) + } + .onFailure { AppLogger.w(TAG, "updateDefaultInterface failed", it) } + } + + @Suppress("DEPRECATION") + override fun getInterfaces(): NetworkInterfaceIterator { + val cm = + vpnService.getSystemService(ConnectivityManager::class.java) + ?: return emptyNetworkInterfaceIterator() + val networks = runCatching { cm.allNetworks }.getOrNull() ?: emptyArray() + val networkInterfaces = + runCatching { NetworkInterface.getNetworkInterfaces()?.toList() }.getOrNull().orEmpty() + val interfaces = mutableListOf() + + for (network in networks) { + val linkProperties = cm.getLinkProperties(network) ?: continue + val caps = cm.getNetworkCapabilities(network) ?: continue + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) continue + val ifaceName = linkProperties.interfaceName ?: continue + val nif = networkInterfaces.find { it.name == ifaceName } ?: continue + + val boxInterface = + LibboxNetworkInterface().apply { + name = ifaceName + index = nif.index + dnsServer = StringArray(linkProperties.dnsServers.mapNotNull { it.hostAddress }) + gateway = + StringArray( + linkProperties.routes + .filter { it.destination?.prefixLength == 0 } + .mapNotNull { it.gateway } + .filterNot { it.isAnyLocalAddress } + .mapNotNull { it.hostAddress } + ) + type = + when { + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> + Libbox.InterfaceTypeWIFI + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> + Libbox.InterfaceTypeCellular + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> + Libbox.InterfaceTypeEthernet + else -> Libbox.InterfaceTypeOther + } + mtu = runCatching { nif?.mtu }.getOrNull() ?: 1500 + addresses = + StringArray( + nif?.interfaceAddresses?.mapNotNull { addr -> + runCatching { + if (addr.address is Inet6Address) { + "${Inet6Address.getByAddress(addr.address.address).hostAddress}/${addr.networkPrefixLength}" + } else { + "${addr.address.hostAddress}/${addr.networkPrefixLength}" + } + } + .getOrNull() + } ?: linkProperties.linkAddresses.map { it.toString() } + ) + var dumpFlags = 0 + if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { + dumpFlags = dumpFlags or OsConstants.IFF_UP or OsConstants.IFF_RUNNING + } + if (nif?.isLoopback == true) dumpFlags = dumpFlags or OsConstants.IFF_LOOPBACK + if (nif?.isPointToPoint == true) + dumpFlags = dumpFlags or OsConstants.IFF_POINTOPOINT + if (runCatching { nif?.supportsMulticast() == true }.getOrDefault(false)) { + dumpFlags = dumpFlags or OsConstants.IFF_MULTICAST + } + flags = dumpFlags + metered = !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + } + interfaces.add(boxInterface) + } + + return object : NetworkInterfaceIterator { + private val iter = interfaces.iterator() + + override fun hasNext(): Boolean = iter.hasNext() + + override fun next(): LibboxNetworkInterface = iter.next() + } + } + + private fun emptyNetworkInterfaceIterator() = + object : NetworkInterfaceIterator { + override fun hasNext(): Boolean = false + + override fun next(): LibboxNetworkInterface = error("no network interfaces") + } + + private class StringArray(private val list: List) : StringIterator { + private val iterator = list.iterator() + + override fun len(): Int = list.size + + override fun hasNext(): Boolean = iterator.hasNext() + + override fun next(): String = if (iterator.hasNext()) iterator.next() else "" + } + + override fun underNetworkExtension(): Boolean = false + + override fun includeAllNetworks(): Boolean = false + + override fun readWIFIState(): WIFIState { + val wm = vpnService.getSystemService(android.net.wifi.WifiManager::class.java) + @Suppress("DEPRECATION") val info = runCatching { wm?.connectionInfo }.getOrNull() + var ssid = info?.ssid ?: "" + if (ssid == "") ssid = "" + if (ssid.startsWith("\"") && ssid.endsWith("\"") && ssid.length >= 2) { + ssid = ssid.substring(1, ssid.length - 1) + } + return WIFIState(ssid, info?.bssid ?: "") + } + + override fun clearDNSCache() {} + + override fun sendNotification(notification: Notification) {} + + override fun cancelNotification(identifier: String, typeID: Int) {} + + override fun startNeighborMonitor(listener: NeighborUpdateListener) {} + + override fun closeNeighborMonitor(listener: NeighborUpdateListener) {} + + override fun registerMyInterface(name: String) {} + + override fun usePlatformShell(): Boolean = false + + override fun checkPlatformShell() {} + + override fun openShellSession( + user: PlatformUser, + command: String, + environ: StringIterator, + term: String, + rows: Int, + cols: Int, + ): ShellSession = error("shell session not supported") + + override fun lookupUser(username: String): PlatformUser = error("user lookup not supported") + + override fun lookupSFTPServer(): String = error("sftp not supported") + + override fun readSystemSSHHostKey(): String = error("ssh not supported") + + override fun tailscaleHostname(): String = "" + + override fun usePlatformBridge(): Boolean = false + + override fun createBridge(options: BridgeOptions): BridgeSession = error("bridge not supported") + + override fun usePlatformAutoRedirect(): Boolean = false + + override fun createAutoRedirect( + options: ByteArray, + handler: AutoRedirectHandler, + ): AutoRedirectSession = error("auto-redirect not supported") + + override fun localDNSTransport(): LocalDNSTransport = + object : LocalDNSTransport { + override fun raw(): Boolean = false + + override fun exchange(ctx: ExchangeContext, message: ByteArray) { + ctx.errorCode(4) + } + + override fun lookup(ctx: ExchangeContext, network: String, domain: String) { + val cleanDomain = domain.trimEnd('.') + try { + val activeNet = vpnService.firstUsableNetwork() + if (activeNet == null) { + ctx.errorCode(2) + return + } + val addresses = activeNet.getAllByName(cleanDomain) + val filtered = + when (network) { + "ip4" -> addresses.filterIsInstance() + "ip6" -> addresses.filterIsInstance() + else -> addresses.toList() + } + ctx.success(filtered.joinToString("\n") { it.hostAddress.orEmpty() }) + } catch (e: Exception) { + AppLogger.d(TAG, "system lookup failed for $cleanDomain: ${e.message}") + ctx.errorCode(2) + } + } + } + + override fun serviceStop() { + vpnService.stopFromCore(this) + } + + override fun serviceReload(): Unit = + throw UnsupportedOperationException("Reconnect through FoxyVPN to reload settings") + + override fun getSystemProxyStatus(): SystemProxyStatus = SystemProxyStatus() + + override fun setSystemProxyEnabled(enabled: Boolean) {} + + override fun triggerNativeCrash() {} + + override fun writeDebugMessage(message: String) { + when { + message.contains("ERROR", ignoreCase = true) || + message.contains("FATAL", ignoreCase = true) -> + AppLogger.e(TAG, "sing-box: $message") + message.contains("WARN", ignoreCase = true) -> AppLogger.w(TAG, "sing-box: $message") + else -> AppLogger.i(TAG, "sing-box: $message") + } + } + + override fun connectSSHAgent(): Int = -1 +} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyVpnService.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyVpnService.kt index 561713d6..ccad48dc 100644 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyVpnService.kt +++ b/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyVpnService.kt @@ -7,22 +7,25 @@ import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.net.ConnectivityManager +import android.net.LinkProperties import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.net.VpnService import android.net.wifi.WifiManager import android.os.Build -import android.os.ParcelFileDescriptor import android.os.PowerManager +import android.os.SystemClock import androidx.core.app.NotificationCompat +import com.vauth.foxyvpn.BuildConfig import com.vauth.foxyvpn.FoxyVpnApp import com.vauth.foxyvpn.MainActivity import com.vauth.foxyvpn.data.AppLogger import com.vauth.foxyvpn.data.ControlPlaneHttp -import com.vauth.foxyvpn.data.FxaAuthRepository +import com.vauth.foxyvpn.data.FxaRefreshFailure import com.vauth.foxyvpn.data.GUARDIAN_ENDPOINT_DEFAULT import com.vauth.foxyvpn.data.GuardianClient +import com.vauth.foxyvpn.data.GuardianHttpError import com.vauth.foxyvpn.data.ProxyPass import com.vauth.foxyvpn.data.ProxyStateStore import com.vauth.foxyvpn.data.QuotaExceededError @@ -30,28 +33,31 @@ import com.vauth.foxyvpn.data.RECOMMENDED_COUNTRY_CODE import com.vauth.foxyvpn.data.ServerListClient import com.vauth.foxyvpn.data.SettingsStore import com.vauth.foxyvpn.data.TokenInvalidError -import com.vauth.foxyvpn.data.TokenStore +import com.vauth.foxyvpn.data.TunnelSettings +import com.vauth.foxyvpn.data.awaitResponse import com.vauth.foxyvpn.data.formatBytesPerSecond import com.vauth.foxyvpn.data.model.ConnectionState import com.vauth.foxyvpn.data.model.ProxyCandidate -import com.vauth.foxyvpn.data.model.RuntimeAuth -import com.vauth.foxyvpn.vpn.socks.LocalHttpProxyServer -import com.vauth.foxyvpn.vpn.socks.LocalSocks5Server -import com.vauth.foxyvpn.vpn.tun.HevSocks5Tunnel -import com.vauth.foxyvpn.vpn.tun.HevSocks5TunnelConfig -import com.vauth.foxyvpn.vpn.upstream.EdgeAddressResolver -import com.vauth.foxyvpn.vpn.upstream.H2UpstreamSession -import com.vauth.foxyvpn.vpn.upstream.UpstreamProxyConfig -import com.vauth.foxyvpn.vpn.upstream.UpstreamSession +import com.vauth.foxyvpn.data.retryDeadlineMillis +import com.vauth.foxyvpn.data.retryDelayMillis +import com.vauth.foxyvpn.data.tunnelSnapshot import com.vauth.foxyvpn.widget.QuickConnectWidgetProvider +import io.nekohasekai.libbox.CommandServer +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.OverrideOptions +import io.nekohasekai.libbox.SetupOptions +import java.io.File +import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.delay import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive @@ -67,146 +73,122 @@ data class VpnLiveStats( ) private const val TAG = "FoxyVpnService" -private const val CONNECT_TIMEOUT_MS = 20_000L private const val SERVER_LIST_FETCH_TIMEOUT_MS = 15_000L - -private const val UPSTREAM_WATCHDOG_INTERVAL_MS = 2_000L - -private const val RECONNECT_FAILURES_BEFORE_WARNING = 5 -private const val RECONNECT_BACKOFF_BASE_MS = 3_000L -private const val RECONNECT_BACKOFF_CAP_MS = 60_000L - -private const val RECONNECT_FAILURES_BEFORE_EDGE_ROTATION = 2 - -private const val MAX_ALTERNATE_EDGES = 3 - -private const val UNHEALTHY_REDIAL_COOLDOWN_MS = 30_000L - -private const val PROXY_PASS_RENEWAL_FRACTION = 0.5 -private const val PROXY_PASS_RENEWAL_SAFETY_MARGIN_MS = 30_000L - -private const val PROXY_PASS_RENEWAL_FLOOR_MS = 15_000L - -private const val PROXY_PASS_RENEWAL_CEILING_MS = 30 * 60_000L - -private const val PROXY_PASS_RENEWAL_FALLBACK_MS = 4 * 60_000L - -private const val PROXY_PASS_RENEWAL_RETRY_MS = 30_000L - -private const val POST_TUN_SETTLE_MS = 250L - -private const val INITIAL_DIAL_SETTLE_MS = 600L - -private const val INITIAL_DIAL_MAX_ATTEMPTS = MAX_ALTERNATE_EDGES + 1 -private const val INITIAL_DIAL_BACKOFF_BASE_MS = 2_000L -private const val INITIAL_DIAL_BACKOFF_CAP_MS = 8_000L - -private const val SPEED_UPDATE_INTERVAL_MS = 2_000L - -private const val TUN_ADDRESS_IPV6 = "fd00:1:fd00:1:fd00:1:fd00:1" -private const val TUN_PREFIX_LENGTH_IPV6 = 128 - -private const val WAKE_LOCK_TAG = "com.vauth.foxyvpn:vpn" +private const val PROXY_PASS_FETCH_BUDGET_MS = 60_000L +private const val PROXY_PASS_RETRY_MS = 5_000L +private const val WAKE_LOCK_TAG = "com.vauth.foxyvpn:vpn-cpu" private const val WIFI_LOCK_TAG = "com.vauth.foxyvpn:vpn-wifi" -private fun fullJitterBackoffMs(attempt: Int, baseMs: Long, capMs: Long): Long { - val exponential = baseMs * (1L shl attempt.coerceIn(0, 10)) - val upperBound = exponential.coerceAtMost(capMs) - return (upperBound.toDouble() * Math.random()).toLong().coerceAtLeast(baseMs / 4) -} - -private fun proxyPassRenewalDelayMs(expiresAtEpochSeconds: Long?): Long { - if (expiresAtEpochSeconds == null) return PROXY_PASS_RENEWAL_FALLBACK_MS - val remainingMs = expiresAtEpochSeconds * 1_000L - System.currentTimeMillis() - if (remainingMs <= 0L) return PROXY_PASS_RENEWAL_FLOOR_MS - val atHalfLife = (remainingMs * PROXY_PASS_RENEWAL_FRACTION).toLong() - val beforeExpiry = remainingMs - PROXY_PASS_RENEWAL_SAFETY_MARGIN_MS - return minOf(atHalfLife, beforeExpiry) - .coerceAtMost(PROXY_PASS_RENEWAL_CEILING_MS) - .coerceAtLeast(PROXY_PASS_RENEWAL_FLOOR_MS) -} +private fun proxyPassDeadline(expiresAtEpochSeconds: Long?): Long = + credentialDeadlineMillis( + expiresAtEpochSeconds, + System.currentTimeMillis(), + SystemClock.elapsedRealtime(), + ) class FoxyVpnService : VpnService() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val sessionInvalidationListener: () -> Unit = { + requestDisconnect("account session was cleared") + } + private val opMutex = nativeMutex - private val opMutex = Mutex() - - private var tunFd: ParcelFileDescriptor? = null - private var socksServer: LocalSocks5Server? = null - private var httpProxyServer: LocalHttpProxyServer? = null - private var upstreamSession: UpstreamSession? = null + @Volatile private var commandServer: CommandServer? = null private var connectJob: Job? = null - private var watchdogJob: Job? = null - private var speedJob: Job? = null - + private var retryJob: Job? = null + private var trafficMonitor: NativeTrafficMonitor? = null + private var pingJob: Job? = null + private var exitCheckJob: Job? = null + private var healthJob: Job? = null + private var connectionSettings: TunnelSettings? = null + private var connectionCandidate: ProxyCandidate? = null + private var directoryRefreshAfterElapsedMs = 0L + private val pingMutex = Mutex() + @Volatile private var activeSocksPort: Int? = null + @Volatile private var activeProxyCredentials: Pair = "" to "" private var tokenRenewalJob: Job? = null + private var credentialExpiryJob: Job? = null + private val installedCredentialDeadline = AtomicLong(0L) private var wakeLock: PowerManager.WakeLock? = null + private var wakeLockRenewalJob: Job? = null private var wifiLock: WifiManager.WifiLock? = null - @Volatile private var tunnelInterfaceActive = false + @Volatile + internal var nativeProtectionRequired = true + private set + @Volatile private var tunnelInterfaceActive = false @Volatile private var boundNetwork: Network? = null - @Volatile private var reportedUnderlying: Network? = null - - @Volatile private var lastUnhealthyRedialAt = 0L - @Volatile private var statusLabel: String = "Connecting\u2026" - @Volatile private var foregroundActive = false - @Volatile private var lastNotificationText: String? = null + @Volatile private var lastNotificationSubText: String? = null + private val connectionGeneration = AtomicInteger() + @Volatile private var stopping = false + private var libboxReady = false + private var healthRecoveryInProgress = false + private var reconnectAttempt = 0 + private val retryNotBeforeElapsedMs = AtomicLong(0L) + private var controllerSecret = UUID.randomUUID().toString() + @Volatile private var activePlatformInterface: FoxyPlatformInterface? = null private class SessionResources( - val tunFd: ParcelFileDescriptor?, - val socksServer: LocalSocks5Server?, - val httpProxyServer: LocalHttpProxyServer?, - val upstreamSession: UpstreamSession?, + val commandServer: CommandServer?, + val platformInterface: FoxyPlatformInterface?, ) { - val isEmpty: Boolean get() = tunFd == null && socksServer == null && httpProxyServer == null && upstreamSession == null + val isEmpty: Boolean + get() = commandServer == null && platformInterface == null } - @Volatile - private var connectionGeneration = 0 + private val networkCallback = + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + reportUnderlyingNetwork(network) + } - private val networkCallback = object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - reportUnderlyingNetwork(network) - } + override fun onCapabilitiesChanged( + network: Network, + capabilities: NetworkCapabilities, + ) { + reportUnderlyingNetwork(network, capabilities) + } - override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { - reportUnderlyingNetwork(network, capabilities) - } + override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) { + reportUnderlyingNetwork(network) + if (boundNetwork == network) activePlatformInterface?.notifyDefaultInterface() + } - override fun onLost(network: Network) { - if (boundNetwork != network) return - boundNetwork = null - reportedUnderlying = null + override fun onLost(network: Network) { + if (boundNetwork != network) return + boundNetwork = null + reportedUnderlying = null - if (tunnelInterfaceActive) { - runCatching { setUnderlyingNetworks(null) } + if (tunnelInterfaceActive) runCatching { setUnderlyingNetworks(null) } + firstUsableNetwork()?.let { reportUnderlyingNetwork(it) } + activePlatformInterface?.notifyDefaultInterface() } } - } private fun reportUnderlyingNetwork( network: Network, capabilities: NetworkCapabilities? = null, ) { - val caps = capabilities - ?: getSystemService(ConnectivityManager::class.java)?.getNetworkCapabilities(network) - - if (caps == null) return + val caps = + capabilities + ?: getSystemService(ConnectivityManager::class.java) + ?.getNetworkCapabilities(network) + if (caps == null || caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return + if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) return - if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return + if (firstUsableNetwork() != network) return if (tunnelInterfaceActive && reportedUnderlying != network) { reportedUnderlying = network runCatching { setUnderlyingNetworks(arrayOf(network)) } .onFailure { - reportedUnderlying = null AppLogger.w(TAG, "failed to report the underlying network to the platform", it) } @@ -215,55 +197,47 @@ class FoxyVpnService : VpnService() { val previous = boundNetwork if (previous == network) return - if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) && previous != null) return boundNetwork = network runCatching { ControlPlaneHttp.client.connectionPool.evictAll() } - if (previous == null) return - - if (_state.value != ConnectionState.CONNECTED) return - val session = upstreamSession ?: return + activePlatformInterface?.notifyDefaultInterface() - AppLogger.i( - TAG, - "underlying network changed; closing the upstream session bound to the old one so it is redialed now " + - "(its socket would otherwise stay ESTABLISHED and look healthy for minutes)", - ) - runCatching { session.close() } + if (!stopping && _state.value == ConnectionState.CONNECTING && connectJob == null) { + connectJob = scope.launch { opMutex.withLock { connect() } } + } } - private fun onUpstreamSessionUnhealthy() { - if (_state.value != ConnectionState.CONNECTED) return - val session = upstreamSession ?: return - val now = System.currentTimeMillis() - val sinceLast = now - lastUnhealthyRedialAt - if (lastUnhealthyRedialAt != 0L && sinceLast < UNHEALTHY_REDIAL_COOLDOWN_MS) { - AppLogger.d( - TAG, - "ignoring an unhealthy-session verdict ${sinceLast}ms after the last rebuild " + - "(cooldown ${UNHEALTHY_REDIAL_COOLDOWN_MS}ms)", - ) - return - } - lastUnhealthyRedialAt = now - AppLogger.w( - TAG, - "the local proxy reports the upstream session is failing as a whole rather than for one destination; " + - "closing it so the watchdog redials", - ) - runCatching { session.close() } + private fun ensureLibboxSetup() { + if (libboxReady) return + val baseDir = filesDir + val workingDir = filesDir + val tempDir = cacheDir + val options = + SetupOptions().apply { + basePath = baseDir.absolutePath + workingPath = workingDir.absolutePath + tempPath = tempDir.absolutePath + logMaxLines = 5000 + debug = BuildConfig.DEBUG + } + Libbox.setup(options) + libboxReady = true } override fun onCreate() { super.onCreate() + _state.value = ConnectionState.DISCONNECTED + (applicationContext as FoxyVpnApp).tokenStore.onSessionInvalidated = + sessionInvalidationListener runCatching { - - val underlyingNetworks = NetworkRequest.Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) - .build() - getSystemService(ConnectivityManager::class.java) - ?.registerNetworkCallback(underlyingNetworks, networkCallback) - }.onFailure { AppLogger.w(TAG, "could not register a network callback; handovers will be slower to detect", it) } + val underlyingNetworks = + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + .build() + getSystemService(ConnectivityManager::class.java) + ?.registerNetworkCallback(underlyingNetworks, networkCallback) + } + .onFailure { AppLogger.w(TAG, "could not register network callback", it) } scope.launch { _state.collect { state -> @@ -273,61 +247,104 @@ class FoxyVpnService : VpnService() { } override fun onDestroy() { - ControlPlaneHttp.socketProtector = null - runCatching { - getSystemService(ConnectivityManager::class.java)?.unregisterNetworkCallback(networkCallback) + stopping = true + synchronized(credentialCommitLock) { + connectionGeneration.incrementAndGet() + if (coreOwner === this) Libbox.setBearerToken("") } connectJob?.cancel() - connectJob = null - watchdogJob?.cancel() - watchdogJob = null - connectionGeneration++ - releaseResources(detachResources(), stopNativeTunnel = true) + retryJob?.cancel() + tokenRenewalJob?.cancel() + runCatching { + getSystemService(ConnectivityManager::class.java) + ?.unregisterNetworkCallback(networkCallback) + } + + stopMonitoring() releaseWakeLocks() - foregroundActive = false - lastNotificationText = null - boundNetwork = null - reportedUnderlying = null - scope.cancel() - _state.value = ConnectionState.DISCONNECTED - QuickConnectWidgetProvider.updateAll(this, ConnectionState.DISCONNECTED) + val server = commandServer + CoroutineScope(Dispatchers.IO).launch { + try { + interruptNativeStart(server) + opMutex.withLock { teardown() } + } finally { + scope.cancel() + } + } + (applicationContext as FoxyVpnApp) + .tokenStore + .clearSessionInvalidationListener(sessionInvalidationListener) super.onDestroy() } + @Synchronized private fun acquireWakeLocks() { + if (stopping) return + if (wakeLock == null) { + runCatching { + val power = + applicationContext.getSystemService(Context.POWER_SERVICE) as? PowerManager + ?: error("PowerManager unavailable") + wakeLock = + power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply { + setReferenceCounted(false) + acquire(300_000L) + } + } + .onFailure { AppLogger.d(TAG, "CPU wake lock unavailable: ${it.message}") } + } + if (wakeLock != null && wakeLockRenewalJob?.isActive != true) { + wakeLockRenewalJob = + scope.launch { + while (isActive) { + delay(180_000L) + synchronized(this@FoxyVpnService) { + if (!stopping) + runCatching { wakeLock?.acquire(300_000L) } + .onFailure { AppLogger.w(TAG, "CPU wake lock renewal failed") } + } + } + } + } if (wifiLock == null) { runCatching { - val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager - ?: error("WifiManager unavailable") - @Suppress("DEPRECATION") - wifiLock = wifi.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, WIFI_LOCK_TAG).apply { - setReferenceCounted(false) - acquire() + val wifi = + applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager + ?: error("WifiManager unavailable") + @Suppress("DEPRECATION") + wifiLock = + wifi + .createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, WIFI_LOCK_TAG) + .apply { + setReferenceCounted(false) + acquire() + } } - }.onFailure { AppLogger.d(TAG, "Wi-Fi high-performance lock unavailable: ${it.message}") } + .onFailure { AppLogger.d(TAG, "Wi-Fi lock unavailable: ${it.message}") } } } + @Synchronized private fun releaseWakeLocks() { + wakeLockRenewalJob?.cancel() + wakeLockRenewalJob = null + runCatching { wakeLock?.takeIf { it.isHeld }?.release() } + .onFailure { AppLogger.d(TAG, "error releasing CPU wake lock: ${it.message}") } + wakeLock = null runCatching { wifiLock?.takeIf { it.isHeld }?.release() } - .onFailure { AppLogger.d(TAG, "error releasing the Wi-Fi lock: ${it.message}") } + .onFailure { AppLogger.d(TAG, "error releasing Wi-Fi lock: ${it.message}") } wifiLock = null } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val action = intent?.action ?: ACTION_CONNECT if (action == ACTION_CONNECT || action == VpnService.SERVICE_INTERFACE) { - + if (stopping) return START_NOT_STICKY if (_state.value != ConnectionState.DISCONNECTED) { AppLogger.d(TAG, "ignoring duplicate connect request while ${_state.value}") - enterForeground(statusLabel) return START_STICKY } - if (intent == null) { - AppLogger.i(TAG, "the service was restarted by the system; bringing the tunnel back up") - } _state.value = ConnectionState.CONNECTING _lastError.value = null statusLabel = "Connecting\u2026" @@ -335,7 +352,6 @@ class FoxyVpnService : VpnService() { acquireWakeLocks() connectJob = scope.launch { opMutex.withLock { connect() } } - return START_STICKY } else if (action == ACTION_DISCONNECT) { requestDisconnect("requested by the user") @@ -343,826 +359,668 @@ class FoxyVpnService : VpnService() { return START_NOT_STICKY } - private fun requestDisconnect(reason: String) { - val idle = _state.value == ConnectionState.DISCONNECTED && - connectJob == null && - watchdogJob == null - if (idle) { - releaseWakeLocks() - stopSelf() - return + internal fun stopFromCore(platform: FoxyPlatformInterface) { + synchronized(credentialCommitLock) { + if (coreOwner === this && activePlatformInterface === platform && !stopping) { + requestDisconnect("requested by sing-box") + } } - AppLogger.i(TAG, "disconnect: $reason") + } - val endedGeneration = ++connectionGeneration - _state.value = ConnectionState.DISCONNECTED - _vpnLiveStats.value = VpnLiveStats() - _connectedAtEpochMs.value = null - _pingLatencyMs.value = null - _vpnEdgeIp.value = null - _connectedEndpoint.value = null + private fun requestDisconnect(reason: String) { + synchronized(credentialCommitLock) { + if (stopping) return + stopping = true + connectionGeneration.incrementAndGet() + if (coreOwner === this) Libbox.setBearerToken("") + } connectJob?.cancel() - connectJob = null - watchdogJob?.cancel() - watchdogJob = null - exitForeground() - + retryJob?.cancel() + tokenRenewalJob?.cancel() + AppLogger.i(TAG, "disconnect: $reason") + statusLabel = "Disconnecting…" + stopMonitoring() releaseWakeLocks() - - val doomed = detachResources() - + val server = commandServer scope.launch { - opMutex.withLock { - - val stillOurs = connectionGeneration == endedGeneration - releaseResources(doomed, stopNativeTunnel = stillOurs) - - if (stillOurs && _state.value == ConnectionState.DISCONNECTED) { - stopSelf() - } + try { + interruptNativeStart(server) + opMutex.withLock { teardown() } + } finally { + exitForeground() + releaseWakeLocks() + stopSelf() } } } + private fun interruptNativeStart(server: CommandServer?) { + runCatching { server?.closeService() } + .onFailure { AppLogger.w(TAG, "Could not interrupt the native session", it) } + } + + private fun stopMonitoring() { + trafficMonitor?.stop() + trafficMonitor = null + pingJob?.cancel() + exitCheckJob?.cancel() + healthJob?.cancel() + credentialExpiryJob?.cancel() + } + private fun ensureGenerationCurrent(myGeneration: Int) { - if (myGeneration != connectionGeneration) { + if (myGeneration != connectionGeneration.get()) { throw CancellationException("connect was superseded by a newer request") } } - private fun firstUsableNetwork(): Network? { + internal fun firstUsableNetwork(): Network? { val manager = getSystemService(ConnectivityManager::class.java) ?: return null @Suppress("DEPRECATION") val networks = runCatching { manager.allNetworks }.getOrDefault(emptyArray()) + val ordered = (listOfNotNull(manager.activeNetwork, boundNetwork) + networks).distinct() var unvalidated: Network? = null - for (network in networks) { + for (network in ordered) { val capabilities = manager.getNetworkCapabilities(network) ?: continue - if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) continue if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) continue - if (capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) return network + if (capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) + return network if (unvalidated == null) unvalidated = network } return unvalidated } - private fun hasUsableNetwork(): Boolean { - - getSystemService(ConnectivityManager::class.java) ?: return true - return firstUsableNetwork() != null - } - private fun isFatalUpstreamError(error: Throwable): Boolean = - error is TokenInvalidError || error is QuotaExceededError + error is TokenInvalidError || + error is QuotaExceededError || + error is IllegalArgumentException || + error is com.vauth.foxyvpn.data.SecureStorageException || + (error is FxaRefreshFailure && error.permanent) || + (error is GuardianHttpError && error.statusCode in listOf(401, 403)) private fun startProxyPassRenewal( - myGeneration: Int, - initialExpiry: Long?, + generation: Int, + initialDeadline: Long, + sessionEpoch: Long, mintProxyPass: suspend () -> ProxyPass, ) { tokenRenewalJob?.cancel() - tokenRenewalJob = scope.launch { - var expiry = initialExpiry - AppLogger.i( - TAG, - "proxy pass renewal scheduled in ${proxyPassRenewalDelayMs(expiry) / 1_000}s " + - (if (expiry == null) "(pass lifetime unknown; using a fixed interval)" else "(at half of its remaining life)"), - ) - while (isActive) { - delay(proxyPassRenewalDelayMs(expiry)) - if (myGeneration != connectionGeneration) return@launch - - val session = upstreamSession - if (session == null || !session.isConnected) { - AppLogger.d(TAG, "skipping proxy pass renewal: no live session (the watchdog's redial mints its own)") - continue - } - - val attempt = runCatching { mintProxyPass() } - val pass = attempt.getOrNull() - if (pass == null) { - val error = attempt.exceptionOrNull() - if (error is CancellationException) throw error - if (error != null && isFatalUpstreamError(error)) { - AppLogger.e( + tokenRenewalJob = + scope.launch { + var deadline = initialDeadline + var nextDelay = + (deadline - SystemClock.elapsedRealtime() - PROXY_PASS_FETCH_BUDGET_MS) + .coerceAtLeast(0L) + while (isActive) { + delay(nextDelay) + if (generation != connectionGeneration.get() || stopping) return@launch + val remaining = deadline - SystemClock.elapsedRealtime() + if (remaining <= 0L) { + _lastError.value = + "Proxy credentials could not be renewed in time. Reconnect to retry." + requestDisconnect("proxy pass renewal deadline reached") + return@launch + } + try { + val pass = + withTimeout(minOf(PROXY_PASS_FETCH_BUDGET_MS, remaining)) { + mintProxyPass() + } + opMutex.withLock { + val store = (applicationContext as FoxyVpnApp).tokenStore + store.withCurrentSession(sessionEpoch) { + synchronized(credentialCommitLock) { + ensureGenerationCurrent(generation) + validateProxyPass(pass) + Libbox.setBearerToken(pass.token) + installedCredentialDeadline.set( + proxyPassDeadline(pass.expiresAtEpochSeconds) + ) + } + } + } + deadline = installedCredentialDeadline.get() + nextDelay = + (deadline - SystemClock.elapsedRealtime() - PROXY_PASS_FETCH_BUDGET_MS) + .coerceAtLeast(0L) + AppLogger.i(TAG, "proxy pass replaced through the scoped core API") + } catch (error: Exception) { + if ( + error is CancellationException && error !is TimeoutCancellationException + ) + throw error + if (isFatalUpstreamError(error)) { + _lastError.value = friendlyErrorMessage(error) + requestDisconnect("credential renewal was rejected") + return@launch + } + AppLogger.w( TAG, - "proxy pass renewal failed for a reason retrying cannot fix; leaving the session to the watchdog", + "proxy pass renewal failed; retrying within its validity deadline", error, ) - return@launch + nextDelay = + minOf( + maxOf( + PROXY_PASS_RETRY_MS, + (error as? GuardianHttpError)?.retryAfterMs ?: 0L, + ), + (deadline - SystemClock.elapsedRealtime()).coerceAtLeast(0L), + ) } - AppLogger.w(TAG, "proxy pass renewal failed; retrying shortly: ${error?.message}") - - expiry = null - delay(PROXY_PASS_RENEWAL_RETRY_MS) - continue } + } + } - if (myGeneration != connectionGeneration) return@launch - - val live = upstreamSession - if (live == null || !live.isConnected) { + private fun startCredentialExpiryWatchdog(generation: Int) { + credentialExpiryJob?.cancel() + credentialExpiryJob = + scope.launch { + while (isActive) { + val remaining = + installedCredentialDeadline.get() - SystemClock.elapsedRealtime() + if (remaining > 0L) { + delay(remaining) + continue + } + synchronized(credentialCommitLock) { + if ( + stopping || + coreOwner !== this@FoxyVpnService || + generation != connectionGeneration.get() + ) + return@launch - AppLogger.d( - TAG, - "minted a fresh proxy pass but the session went down meanwhile; the watchdog's redial " + - "will mint its own", - ) - expiry = pass.expiresAtEpochSeconds - continue + if (SystemClock.elapsedRealtime() >= installedCredentialDeadline.get()) { + _lastError.value = "Proxy credentials expired. Reconnect to retry." + requestDisconnect("installed credential deadline reached") + return@launch + } + } } - - val lifetimeNote = pass.expiresAtEpochSeconds?.let { expiresAt -> - val seconds = (expiresAt * 1_000L - System.currentTimeMillis()) / 1_000L - " (valid for ${seconds}s)" - } ?: " (lifetime not stated)" - live.updateBearerToken(pass.token) - AppLogger.i(TAG, "proxy pass renewed in place$lifetimeNote; the tunnel was not rebuilt") - - expiry = pass.expiresAtEpochSeconds } + } + + private fun validateProxyPass(pass: ProxyPass) { + require( + pass.token.isNotBlank() && !pass.token.contains('\r') && !pass.token.contains('\n') + ) { + "Guardian returned an invalid proxy credential" + } + val expiry = pass.expiresAtEpochSeconds + require(expiry == null || expiry - System.currentTimeMillis() / 1_000L > 60L) { + "Guardian returned a proxy credential with insufficient lifetime" } } private suspend fun connect() { + if (_state.value == ConnectionState.CONNECTED) { + AppLogger.d(TAG, "connect: already connected, skipping duplicate connect request") + return + } + if (stopping) throw CancellationException("Service is stopping") - val myGeneration = ++connectionGeneration - - lastUnhealthyRedialAt = 0L - + val retryWait = + (retryNotBeforeElapsedMs.get() - SystemClock.elapsedRealtime()).coerceAtLeast(0L) + if (retryWait > 0) { + connectJob = null + scheduleReconnect(retryWait) + return + } + if (stopping) throw CancellationException("Service is stopping") + val previousOwner = coreOwner + if (previousOwner != null && previousOwner !== this) previousOwner.teardown() + synchronized(credentialCommitLock) { coreOwner = this } + val myGeneration = connectionGeneration.incrementAndGet() boundNetwork = null - EdgeAddressResolver.invalidate() runCatching { - AppLogger.i(TAG, "connect: starting") - val tokenStore = TokenStore(applicationContext) - if (tokenStore.loadAuth() == null) error("Not signed in") - val proxyStateStore = ProxyStateStore(applicationContext) - val settingsStore = SettingsStore(applicationContext) - - val primaryCandidate = resolveConnectCandidate(proxyStateStore) - ensureGenerationCurrent(myGeneration) - - var candidates = listOf(primaryCandidate) - var candidateIndex = 0 - var alternatesDiscovered = false - - fun activeCandidate(): ProxyCandidate = candidates[candidateIndex.coerceIn(0, candidates.lastIndex)] - - suspend fun rotateCandidate(): Boolean { - if (candidateIndex + 1 >= candidates.size && !alternatesDiscovered) { - alternatesDiscovered = true - val fresh = discoverAlternateCandidates(primaryCandidate) - .filter { discovered -> candidates.none { known -> known.authority == discovered.authority } } - .take(MAX_ALTERNATE_EDGES) - if (fresh.isNotEmpty()) { - candidates = candidates + fresh - AppLogger.i( - TAG, - "found ${fresh.size} alternate edge(s) in ${primaryCandidate.countryCode} to fail over to", + AppLogger.i(TAG, "connect: starting sing-box engine") + val app = applicationContext as FoxyVpnApp + val tokenStore = app.tokenStore + val sessionEpoch = tokenStore.sessionEpoch + if (tokenStore.loadAuth() == null) { + tokenStore.storageError.value?.let { + throw com.vauth.foxyvpn.data.SecureStorageException( + IllegalStateException(it) ) } + throw TokenInvalidError("Not signed in") } - if (candidateIndex + 1 >= candidates.size) return false - candidateIndex++ - AppLogger.i( - TAG, - "failing over to edge ${activeCandidate().authority} " + - "(${candidateIndex + 1} of ${candidates.size})", - ) - return true - } + val proxyStateStore = app.proxyStateStore + val settingsStore = app.settingsStore + val tunnelSettings = + connectionSettings + ?: settingsStore.tunnelSnapshot().also { connectionSettings = it } + controllerSecret = UUID.randomUUID().toString() + + val directoryClient = app.serverListClient + val existingCandidate = connectionCandidate + val refreshRetry = + existingCandidate != null && + reconnectAttempt > 0 && + SystemClock.elapsedRealtime() >= directoryRefreshAfterElapsedMs && + !directoryClient.hasFreshCache() + val primaryCandidate = + if (existingCandidate == null || refreshRetry) { + directoryRefreshAfterElapsedMs = SystemClock.elapsedRealtime() + 60_000L + resolveConnectCandidate(proxyStateStore, existingCandidate).also { + connectionCandidate = it + } + } else existingCandidate + ensureGenerationCurrent(myGeneration) - val upstreamProxyConfig = if (settingsStore.upstreamProxyEnabled && settingsStore.upstreamProxyHost.isNotBlank()) { - UpstreamProxyConfig( - type = when (settingsStore.upstreamProxyType) { - SettingsStore.UpstreamProxyType.SOCKS5 -> UpstreamProxyConfig.Type.SOCKS5 - SettingsStore.UpstreamProxyType.HTTP -> UpstreamProxyConfig.Type.HTTP - }, - host = settingsStore.upstreamProxyHost, - port = settingsStore.upstreamProxyPort, - username = settingsStore.upstreamProxyUsername.ifBlank { null }, - password = settingsStore.upstreamProxyPassword.ifBlank { null }, + val socksPort = tunnelSettings.socksPort + ListenerAvailability.requireAvailable( + listOf(socksPort, SettingsStore.CONTROLLER_PORT) ) - } else { - null - } - val chainSuffix = if (upstreamProxyConfig != null) { - " via ${upstreamProxyConfig.type} proxy ${upstreamProxyConfig.host}:${upstreamProxyConfig.port}" - } else { - "" - } - - val customEdgeAddress = settingsStore.effectiveCustomEdgeAddress - val edgeSuffix = if (customEdgeAddress != null) { - " via pinned edge $customEdgeAddress:${primaryCandidate.port}" - } else { - "" - } - - val dohEndpointAddresses = settingsStore.effectiveBootstrapAddresses - val bootstrapDesc = if (dohEndpointAddresses.isEmpty()) { - "system DNS" - } else { - "${settingsStore.bootstrapDnsProvider.label} (${dohEndpointAddresses.joinToString(", ")})" - } - - AppLogger.i( - TAG, - "connect: target=${primaryCandidate.authority} country=${primaryCandidate.countryCode} bootstrap=$bootstrapDesc$chainSuffix$edgeSuffix", - ) - - val socksPort = settingsStore.socksPort - val socksBindAddress = settingsStore.socksBindAddress - val httpProxyPort = settingsStore.httpProxyPort - val httpProxyEnabled = settingsStore.httpProxyEnabled - val proxyOnlyMode = settingsStore.proxyOnlyMode - val excludedApps = settingsStore.excludedApps - - val dohUrl = settingsStore.effectiveDohUrl - - fun connectedLabel(): String = if (proxyOnlyMode) { - if (httpProxyEnabled) { - "Proxy active \u2022 SOCKS $socksPort, HTTP $httpProxyPort" - } else { - "Proxy active \u2022 $socksBindAddress:$socksPort" - } - } else { - val target = activeCandidate() - "Connected \u2022 ${target.countryName.ifBlank { target.countryCode }}" - } - - if (proxyOnlyMode) { - ControlPlaneHttp.socketProtector = null - } else { - ControlPlaneHttp.socketProtector = { socket -> protect(socket) } - } + val socksBindAddress = tunnelSettings.socksBindAddress - val mapdnsActive = !proxyOnlyMode && dohUrl == null - val socks = LocalSocks5Server( - socksBindAddress, - socksPort, - mapdnsActive, - dohUrl, - { upstreamSession }, - ) { - onUpstreamSessionUnhealthy() - } - socks.start() - socksServer = socks - - if (httpProxyEnabled) { - val http = LocalHttpProxyServer( - socksBindAddress, - httpProxyPort, - { upstreamSession }, - ) { - onUpstreamSessionUnhealthy() + val proxyOnlyMode = tunnelSettings.proxyOnlyMode + nativeProtectionRequired = !proxyOnlyMode + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isAlwaysOn && proxyOnlyMode) { + throw IllegalArgumentException( + "Proxy-only mode cannot satisfy Android always-on VPN" + ) } - http.start() - httpProxyServer = http - } - ensureGenerationCurrent(myGeneration) - - if (proxyOnlyMode) { + _proxyOnlyActive.value = proxyOnlyMode - tunnelInterfaceActive = false - AppLogger.i( - TAG, - "connect: proxy-only mode enabled, skipping TUN interface and tun2socks; " + - "local proxy at $socksBindAddress:$socksPort", - ) - } else { - val fd = establishTun(excludedApps, dohUrl, mtu = settingsStore.mtu) ?: error("Failed to establish TUN interface") - tunFd = fd - tunnelInterfaceActive = true - - reportedUnderlying = null - runCatching { + fun connectedLabel(): String = + if (proxyOnlyMode) { + "Proxy active \u2022 $socksBindAddress:$socksPort" + } else { + val name = + if (primaryCandidate.countryCode.equals(RECOMMENDED_COUNTRY_CODE, ignoreCase = true) || + primaryCandidate.countryName.equals("Recomended", ignoreCase = true) + ) { + "Recommended" + } else { + primaryCandidate.countryName.ifBlank { primaryCandidate.countryCode } + } + "Connected \u2022 $name" + } - firstUsableNetwork()?.let { underlying -> reportUnderlyingNetwork(underlying) } + if (proxyOnlyMode) { + ControlPlaneHttp.socketProtector = null + } else { + ControlPlaneHttp.socketProtector = { socket -> protect(socket) } } - val tunnelConfigPath = HevSocks5TunnelConfig.write( - applicationContext, - socksPort, - useMapDns = mapdnsActive, - mtu = settingsStore.mtu, - ) - val tunnelStarted = HevSocks5Tunnel.start(tunnelConfigPath, fd.fd) - if (!tunnelStarted) error("Failed to start the tun2socks tunnel (hev-socks5-tunnel)") - AppLogger.i(TAG, "connect: TUN interface up, hev-socks5-tunnel started") - delay(POST_TUN_SETTLE_MS) - } - ensureGenerationCurrent(myGeneration) - - suspend fun mintProxyPass(): ProxyPass { - var auth = tokenStore.loadAuth() ?: throw TokenInvalidError("Not signed in") - - if (!tokenStore.hasValidAccessToken()) { - val renewed = renewAccessToken(tokenStore) - if (renewed != null) { - auth = renewed - AppLogger.i(TAG, "the account's access token had expired and was renewed") - } else { + ControlPlaneHttp.networkProvider = { firstUsableNetwork() } - AppLogger.w( - TAG, - "the account's access token is past its expiry and could not be renewed automatically; " + - "you may need to sign in again", - ) - } - } - val guardian = GuardianClient() - - val pass = try { - guardian.fetchProxyPass(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) - } catch (rejected: TokenInvalidError) { - AppLogger.w(TAG, "Guardian rejected the OAuth access token; renewing it and retrying", rejected) - val renewed = renewAccessToken(tokenStore) - if (renewed != null) { - auth = renewed - AppLogger.i(TAG, "the account's OAuth token was renewed after the rejection") + suspend fun mintProxyPass(): ProxyPass { + var auth = app.authRepository.ensureFreshAccessToken() + if (tokenStore.sessionEpoch != sessionEpoch) + throw TokenInvalidError("Session changed") + val guardian = GuardianClient() + val pass = try { guardian.fetchProxyPass(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) - } catch (stillRejected: TokenInvalidError) { - AppLogger.w(TAG, "still rejected; activating the Guardian entitlement and retrying", stillRejected) - guardian.activateGuardian(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) - guardian.fetchProxyPass(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) + } catch (quota: QuotaExceededError) { + settingsStore.saveAccountQuota( + false, + quota.quotaRemaining, + quota.quotaMax, + ) + throw quota + } catch (rejected: TokenInvalidError) { + AppLogger.w( + TAG, + "Guardian rejected the OAuth access token; renewing it and retrying", + rejected, + ) + val renewed = app.authRepository.refreshAccessToken() + if (tokenStore.sessionEpoch != sessionEpoch) + throw TokenInvalidError("Session changed") + if (renewed != null) { + auth = renewed + try { + guardian.fetchProxyPass(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) + } catch (stillRejected: TokenInvalidError) { + AppLogger.w( + TAG, + "still rejected; activating the Guardian entitlement and retrying", + stillRejected, + ) + guardian.activateGuardian(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) + guardian.fetchProxyPass(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) + } + } else { + AppLogger.w(TAG, "token renewal failed; activating Guardian entitlement and retrying") + guardian.activateGuardian(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) + guardian.fetchProxyPass(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) + } } - } else { - AppLogger.w(TAG, "token renewal failed; activating Guardian entitlement and retrying") - guardian.activateGuardian(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) - guardian.fetchProxyPass(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) - } - } - if (pass.quotaRemaining != null) { - settingsStore.cachedQuotaRemaining = pass.quotaRemaining - } - if (pass.quotaMax != null) { - settingsStore.cachedQuotaMax = pass.quotaMax + val hasQuota = pass.quotaRemaining != null || pass.quotaMax != null + val isUnlimited = if (hasQuota) false else settingsStore.cachedIsUnlimited + settingsStore.saveAccountQuota( + isUnlimited, + pass.quotaRemaining, + pass.quotaMax, + ) + return pass } - return pass - } - var currentPassExpiry: Long? = null - - suspend fun dialUpstream(): Pair { - val target = activeCandidate() val pass = mintProxyPass() - currentPassExpiry = pass.expiresAtEpochSeconds - val lifetimeNote = pass.expiresAtEpochSeconds?.let { expiresAt -> - val seconds = (expiresAt * 1_000L - System.currentTimeMillis()) / 1_000L - " (valid for ${seconds}s)" - } ?: " (lifetime not stated)" + validateProxyPass(pass) + val lifetimeNote = + pass.expiresAtEpochSeconds?.let { expiresAt -> + val seconds = (expiresAt * 1_000L - System.currentTimeMillis()) / 1_000L + " (valid for ${seconds}s)" + } ?: " (lifetime not stated)" AppLogger.i(TAG, "connect: acquired Guardian proxy pass$lifetimeNote") - - val edgeAddress = customEdgeAddress - ?: resolveEdgeAddress(target.host, upstreamProxyConfig, dohEndpointAddresses) - - val session = H2UpstreamSession( - target.host, - target.port, - pass.token, - upstreamProxyConfig, - edgeAddress, - tlsSplitMode = settingsStore.tlsSplitMode, - ) - try { - session.connect() - } catch (failure: Throwable) { - - runCatching { session.close() } - throw failure - } - AppLogger.i(TAG, "connect: upstream HTTP/2 tunnel established to ${target.authority}") - return session to pass.expiresAtEpochSeconds - } - - var dialAttempt = 0 - var lastDialFailure: Throwable? = null - while (true) { - dialAttempt++ - ensureGenerationCurrent(myGeneration) - val target = activeCandidate() - - val dialResult = try { - withTimeout(CONNECT_TIMEOUT_MS) { dialUpstream() } - } catch (cancellation: CancellationException) { - - if (cancellation !is TimeoutCancellationException) throw cancellation - lastDialFailure = cancellation - AppLogger.w(TAG, "dial to ${target.authority} timed out (attempt $dialAttempt)") - null - } catch (error: Throwable) { - - if (isFatalUpstreamError(error)) throw error - lastDialFailure = error - AppLogger.w(TAG, "dial to ${target.authority} failed (attempt $dialAttempt): ${error.message}") - null - } - - val session = dialResult?.first - - if (session != null) { - if (myGeneration != connectionGeneration) { - - runCatching { session.close() } + val initialDeadline = proxyPassDeadline(pass.expiresAtEpochSeconds) + tokenStore.withCurrentSession(sessionEpoch) { + synchronized(credentialCommitLock) { ensureGenerationCurrent(myGeneration) + Libbox.setBearerToken(pass.token) + installedCredentialDeadline.set(initialDeadline) } - upstreamSession = session - delay(INITIAL_DIAL_SETTLE_MS) - if (upstreamSession?.isConnected == true) break - AppLogger.w( - TAG, - "upstream tunnel to ${target.authority} died immediately after connecting " + - "(attempt $dialAttempt/$INITIAL_DIAL_MAX_ATTEMPTS)", - ) - runCatching { upstreamSession?.close() } - upstreamSession = null - - lastDialFailure = null } - if (dialAttempt >= INITIAL_DIAL_MAX_ATTEMPTS) { + startCredentialExpiryWatchdog(myGeneration) - val (failures, cleared) = proxyStateStore.recordFailure() - if (cleared) { - AppLogger.w( - TAG, - "the saved location failed $failures connects in a row; clearing it so the next " + - "connect auto-selects a location", - ) - } - val failure = lastDialFailure - if (failure != null) { - error( - "Could not reach a VPN server after $dialAttempt attempts across " + - "${candidates.size} server(s). Last error: ${friendlyErrorMessage(failure)}", - ) - } - error( - "The VPN server closed the connection immediately, $dialAttempt times in a row. " + - "Try again shortly or pick a different location.", + val configContent = + SingBoxConfigBuilder.build( + primaryCandidate = primaryCandidate, + settings = tunnelSettings, + bearerToken = pass.token, + appPackageName = packageName, + controllerSecret = controllerSecret, + ) + ensureGenerationCurrent(myGeneration) + ensureLibboxSetup() + try { + Libbox.checkConfig(configContent) + } catch (error: Exception) { + throw IllegalArgumentException( + "Invalid VPN configuration: ${error.message}", + error, ) } + AppLogger.d(TAG, "sing-box config validated successfully") - rotateCandidate() - delay(fullJitterBackoffMs(dialAttempt - 1, INITIAL_DIAL_BACKOFF_BASE_MS, INITIAL_DIAL_BACKOFF_CAP_MS)) - } - - ensureGenerationCurrent(myGeneration) - - val establishedCandidate = activeCandidate() - - if (establishedCandidate.authority == primaryCandidate.authority) { - proxyStateStore.save(primaryCandidate) - } - - val connectedText = connectedLabel() - _connectedAtEpochMs.value = System.currentTimeMillis() - _state.value = ConnectionState.CONNECTED - statusLabel = connectedText - updateNotification(connectedText) - AppLogger.i(TAG, "connect: CONNECTED via ${establishedCandidate.authority}") - - _connectedEndpoint.value = establishedCandidate.host to establishedCandidate.port - scope.launch { - val ping = PingUtil.measureTcpLatencyMs(establishedCandidate.host, establishedCandidate.port) - if (myGeneration == connectionGeneration) { - _pingLatencyMs.value = ping - } - } - - startProxyPassRenewal(myGeneration, currentPassExpiry) { mintProxyPass() } - - if (settingsStore.exitCheckEnabled) { - scope.launch { - val exitCheck = ExitCheck() - exitCheck.fetchProxiedIpInfo(socksPort) - .onSuccess { info -> - if (myGeneration == connectionGeneration) { - _vpnEdgeIp.value = info - AppLogger.i(TAG, "exit check: observed ip=${info.ip}, country=${info.countryCode}") + ensureGenerationCurrent(myGeneration) + val platformInterface = + FoxyPlatformInterface( + this, + includeOnly = + tunnelSettings.splitTunnelMode == SettingsStore.SplitTunnelMode.INCLUDE, + ) { + tunnelInterfaceActive = true + reportedUnderlying = null + runCatching { + firstUsableNetwork()?.let { underlying -> + reportUnderlyingNetwork(underlying) } } - .onFailure { AppLogger.w(TAG, "exit check failed (non-fatal)", it) } - } - } - - if (!proxyOnlyMode) { - startSpeedUpdates() - } - - watchdogJob = scope.launch { - var consecutiveFailures = 0 - var waitingForNetwork = false - - suspend fun stopWithFatalError(message: String) { - AppLogger.e(TAG, "unrecoverable upstream failure; disconnecting: $message") - _lastError.value = message - connectionGeneration++ - watchdogJob = null - _state.value = ConnectionState.DISCONNECTED - exitForeground() - releaseWakeLocks() - val doomed = detachResources() - opMutex.withLock { releaseResources(doomed, stopNativeTunnel = true) } - stopSelf() - } - - while (isActive) { - delay(UPSTREAM_WATCHDOG_INTERVAL_MS) - val current = upstreamSession - if (current != null && current.isConnected) { - if (consecutiveFailures > 0 || waitingForNetwork) { - AppLogger.i(TAG, "upstream tunnel is healthy again") - _lastError.value = null - statusLabel = connectedLabel() - updateNotification(statusLabel) - } - consecutiveFailures = 0 - waitingForNetwork = false - continue - } - - if (!hasUsableNetwork()) { - if (!waitingForNetwork) { - AppLogger.i(TAG, "no usable network; holding the session and waiting for connectivity") - waitingForNetwork = true - statusLabel = "Waiting for network\u2026" - updateNotification(statusLabel) - } - consecutiveFailures = 0 - continue - } - if (waitingForNetwork) { - AppLogger.i(TAG, "network is back; redialing the upstream tunnel") - waitingForNetwork = false } + activePlatformInterface = platformInterface + + ensureLibboxSetup() + runCatching { File(filesDir, "command.sock").delete() } + runCatching { File(filesDir, "cache.db").delete() } + val server = CommandServer(platformInterface, platformInterface) + commandServer = server + server.start() + ensureGenerationCurrent(myGeneration) + server.startOrReloadService(configContent, OverrideOptions()) - AppLogger.w(TAG, "upstream tunnel is down; attempting to reconnect") - runCatching { current?.close() } - statusLabel = "Reconnecting\u2026" - updateNotification(statusLabel) + ensureGenerationCurrent(myGeneration) - val dialResult = try { - withTimeout(CONNECT_TIMEOUT_MS) { dialUpstream() } - } catch (cancellation: CancellationException) { - if (cancellation !is TimeoutCancellationException) throw cancellation - consecutiveFailures++ - AppLogger.w(TAG, "upstream reconnect attempt $consecutiveFailures timed out") - null - } catch (error: Throwable) { - if (isFatalUpstreamError(error)) { - stopWithFatalError(friendlyErrorMessage(error)) - return@launch - } - consecutiveFailures++ - AppLogger.w(TAG, "upstream reconnect attempt $consecutiveFailures failed: ${error.message}") - null + val connectedText = connectedLabel() + tokenStore.withCurrentSession(sessionEpoch) { + synchronized(credentialCommitLock) { + ensureGenerationCurrent(myGeneration) + validateProxyPass(pass) + _connectedAtElapsedMs.value = SystemClock.elapsedRealtime() + _lastError.value = null + _activeCandidate.value = primaryCandidate + _tunnelHealth.value = TunnelHealth.CHECKING + _state.value = ConnectionState.CONNECTED } - val fresh = dialResult?.first - - if (fresh == null) { + } + reconnectAttempt = 0 + healthRecoveryInProgress = false + retryNotBeforeElapsedMs.set(0L) + statusLabel = connectedText + updateNotification(connectedText) + AppLogger.i( + TAG, + "connect: CONNECTED via sing-box core to ${primaryCandidate.authority}", + ) - if (consecutiveFailures % RECONNECT_FAILURES_BEFORE_EDGE_ROTATION == 0) { - rotateCandidate() - } + val proxyCredentials = + if (tunnelSettings.localProxyAuthEnabled) + tunnelSettings.localProxyUsername to tunnelSettings.localProxyPassword + else "" to "" + activeProxyCredentials = proxyCredentials + activeSocksPort = socksPort + pingJob = scope.launch { measureActiveLatency() } + + startProxyPassRenewal( + generation = myGeneration, + initialDeadline = initialDeadline, + sessionEpoch = sessionEpoch, + ) { + mintProxyPass() + } - if (consecutiveFailures == RECONNECT_FAILURES_BEFORE_WARNING) { - _lastError.value = - "Still trying to reconnect to the VPN server. Tap Disconnect to stop." - AppLogger.e( + exitCheckJob = + scope.launch { + val exitCheck = ExitCheck() + repeat(3) { attempt -> + if (!isCurrentSession(myGeneration)) return@launch + val result = + exitCheck.fetchProxiedIpInfo( + socksPort, + proxyCredentials.first, + proxyCredentials.second, + ) + val info = result.getOrNull() + if (info != null) { + synchronized(credentialCommitLock) { + if ( + isActive && + isCurrentSession(myGeneration) && + _activeCandidate.value == primaryCandidate + ) { + _vpnEdgeIp.value = info + AppLogger.i( + TAG, + "exit check: observed ip=${info.ip}, country=${info.countryCode}", + ) + } + } + return@launch + } + AppLogger.w( TAG, - "$consecutiveFailures consecutive reconnect failures; continuing to retry with backoff " + - "(capped at ${RECONNECT_BACKOFF_CAP_MS / 1_000}s between attempts)", + "exit check attempt ${attempt + 1} failed (non-fatal)", + result.exceptionOrNull(), ) + if (attempt < 2) delay(2_000L * (attempt + 1)) } - delay(fullJitterBackoffMs(consecutiveFailures - 1, RECONNECT_BACKOFF_BASE_MS, RECONNECT_BACKOFF_CAP_MS)) - continue } - if (myGeneration != connectionGeneration) { - - AppLogger.i(TAG, "discarding stale reconnect from a torn-down session generation") - runCatching { fresh.close() } - return@launch - } - upstreamSession = fresh - consecutiveFailures = 0 - _lastError.value = null - - statusLabel = connectedLabel() + startSpeedUpdates(myGeneration) + startHealthMonitor(myGeneration, tunnelSettings, primaryCandidate) + } + .onFailure { error -> + if (error is CancellationException) throw error + synchronized(credentialCommitLock) { Libbox.setBearerToken("") } + releaseResources(detachResources()) + ensureGenerationCurrent(myGeneration) + AppLogger.e(TAG, "connect failed", error) + val failedAutomaticRecovery = healthRecoveryInProgress + healthRecoveryInProgress = false + _lastError.value = + if (failedAutomaticRecovery) "Automatic recovery failed. Reconnect to retry." + else friendlyErrorMessage(error) + + if ( + !failedAutomaticRecovery && + !isFatalUpstreamError(error) && + _state.value != ConnectionState.DISCONNECTED + ) { + reconnectAttempt = (reconnectAttempt + 1).coerceAtMost(6) + val retryDelay = + retryDelayMillis( + minOf(60_000L, 1_000L shl reconnectAttempt), + (error as? GuardianHttpError)?.retryAfterMs, + kotlin.random.Random.nextLong(250L, 1_000L), + ) + val nowElapsed = SystemClock.elapsedRealtime() + retryNotBeforeElapsedMs.set(retryDeadlineMillis(nowElapsed, retryDelay)) + AppLogger.w( + TAG, + "transient connect failure; retrying in ${retryDelay / 1_000L}s", + ) + _state.value = ConnectionState.CONNECTING + statusLabel = "Reconnecting\u2026" updateNotification(statusLabel) - AppLogger.i(TAG, "upstream tunnel reconnected via ${activeCandidate().authority}") - - startProxyPassRenewal(myGeneration, currentPassExpiry) { mintProxyPass() } + connectJob = null + scheduleReconnect(retryDelay) + return } + requestDisconnect("connection failed") } - }.onFailure { failure -> - - if (failure is CancellationException) { - AppLogger.i(TAG, "connect: aborted because the connection was cancelled") - releaseResources(detachResources(), stopNativeTunnel = true) - return@onFailure - } - val message = if (failure is TimeoutCancellationException) { - "Connection timed out. Check your network and try again." - } else { - friendlyErrorMessage(failure) - } - AppLogger.e(TAG, "connect failed", failure) - _lastError.value = message - teardown() - exitForeground() - releaseWakeLocks() - stopSelf() - } connectJob = null } - private suspend fun resolveConnectCandidate(proxyStateStore: ProxyStateStore): ProxyCandidate { - proxyStateStore.load()?.let { return it } - AppLogger.i(TAG, "connect: no server previously selected; auto-selecting recommended location") - val client = (applicationContext as? FoxyVpnApp)?.serverListClient ?: ServerListClient(applicationContext) - val countries = runCatching { - withTimeout(SERVER_LIST_FETCH_TIMEOUT_MS) { client.fetchCountries() } - }.getOrElse { - client.loadCachedCountries() - } - val recommended = ServerListClient.candidatesForCountry(countries, RECOMMENDED_COUNTRY_CODE).randomOrNull() - val chosen = recommended ?: countries.firstOrNull { it.code.isNotBlank() }?.let { country -> - ServerListClient.candidatesForCountry(countries, country.code).randomOrNull() - } ?: error("No server selected and no servers are available. Choose a location first.") - proxyStateStore.save(chosen) - AppLogger.i(TAG, "connect: auto-selected ${chosen.authority} country=${chosen.countryCode}") - return chosen - } - - private suspend fun discoverAlternateCandidates(primary: ProxyCandidate): List { - val attempt = runCatching { - val client = (applicationContext as? FoxyVpnApp)?.serverListClient ?: ServerListClient(applicationContext) - val countries = runCatching { - withTimeout(SERVER_LIST_FETCH_TIMEOUT_MS) { client.fetchCountries() } - }.getOrElse { - client.loadCachedCountries() + private fun scheduleReconnect(delayMillis: Long) { + if (retryJob?.isActive == true || stopping) return + retryJob = + scope.launch { + try { + delay(delayMillis) + if ( + !stopping && + _state.value == ConnectionState.CONNECTING && + connectJob == null + ) { + connectJob = scope.launch { opMutex.withLock { connect() } } + } + } finally { + retryJob = null + } } - val sameCity = ServerListClient.candidatesForCity(countries, primary.countryCode, primary.cityCode) - val sameCountry = ServerListClient.candidatesForCountry(countries, primary.countryCode) - (sameCity + sameCountry) - .distinctBy { it.authority } - .filter { it.authority != primary.authority } - } - val failure = attempt.exceptionOrNull() - if (failure is CancellationException && failure !is TimeoutCancellationException) throw failure - if (failure != null) { - AppLogger.w(TAG, "could not fetch alternate edges; staying with ${primary.authority}", failure) - return emptyList() - } - return attempt.getOrDefault(emptyList()) } - private suspend fun resolveEdgeAddress( - host: String, - upstreamProxy: UpstreamProxyConfig?, - dohEndpointAddresses: List, - ): String? { - if (upstreamProxy != null) return null - if (dohEndpointAddresses.isEmpty()) { - AppLogger.d(TAG, "resolving $host via system DNS resolver (direct network)") - return null - } - return EdgeAddressResolver.resolve(host, dohEndpointAddresses) - } + private suspend fun resolveConnectCandidate( + proxyStateStore: ProxyStateStore, + current: ProxyCandidate? = null, + ): ProxyCandidate { + val saved = current ?: proxyStateStore.load() + val client = + (applicationContext as? FoxyVpnApp)?.serverListClient + ?: ServerListClient(applicationContext) + val cached = client.loadCachedCountries() + val countries = + if (cached.isNotEmpty() && client.hasFreshCache()) cached + else + runCatching { + withTimeout(SERVER_LIST_FETCH_TIMEOUT_MS) { client.fetchCountries() } + } + .getOrElse { + if (it is CancellationException && it !is TimeoutCancellationException) + throw it + AppLogger.w( + TAG, + "could not refresh server directory; using cached selection", + it, + ) + cached + } - private suspend fun renewAccessToken(tokenStore: TokenStore): RuntimeAuth? { - val attempt = runCatching { FxaAuthRepository(tokenStore).refreshAccessToken() } - val failure = attempt.exceptionOrNull() - if (failure is CancellationException) throw failure - if (failure != null) { - AppLogger.w(TAG, "renewing the account's access token failed", failure) - return null - } - return attempt.getOrNull() - } + if (countries.isEmpty() && saved != null) return saved + val candidates = + countries.flatMap { ServerListClient.candidatesForCountry(countries, it.code) } + val chosen = + com.vauth.foxyvpn.data.preferredConnectionCandidates(candidates, saved).randomOrNull() + ?: throw IllegalArgumentException( + if (saved == null) "No servers are available. Choose a location first." + else "No available servers in the selected location. Choose another location." + ) - private fun friendlyErrorMessage(error: Throwable): String = when (error) { - is QuotaExceededError -> "Your VPN quota is exhausted. Try again later." - is TokenInvalidError -> "Your session was rejected. Please sign in again." - is TimeoutCancellationException -> "the connection timed out" - else -> { - val untrusted = generateSequence(error) { it.cause }.take(8).any { - it is java.security.cert.CertPathValidatorException || it is java.security.cert.CertificateException - } - val base = error.message ?: (error::class.simpleName ?: "Connection failed") - if (untrusted) { - "$base (this device does not trust the VPN server's certificate; updating Android or the device's date/time may help)" - } else { - base - } - } + if (current == null) proxyStateStore.saveIfUnchanged(saved, chosen) + return chosen } - private fun establishTun( - excludedApps: Set, - dohUrl: String?, - mtu: Int = HevSocks5TunnelConfig.TUN_MTU, - ): ParcelFileDescriptor? { - val dnsServer = HevSocks5TunnelConfig.MAPDNS_ADDRESS - if (dohUrl != null) { - AppLogger.i(TAG, "establishTun: dns=DoH ($dohUrl)") - } else { - AppLogger.i(TAG, "establishTun: dns=mapdns ($dnsServer)") + private fun friendlyErrorMessage(error: Throwable): String = + when (error) { + is QuotaExceededError -> "Your VPN quota is exhausted. Try again later." + is TokenInvalidError -> "Your session was rejected. Please sign in again." + is GuardianHttpError -> + if (error.statusCode in listOf(401, 403)) { + "Your session was rejected. Please sign in again." + } else error.message ?: "Guardian is temporarily unavailable" + is TimeoutCancellationException -> "the connection timed out" + else -> error.message ?: (error::class.simpleName ?: "Connection failed") } - return Builder() - .setSession("FoxyVPN") - .addAddress(HevSocks5TunnelConfig.TUN_ADDRESS, 32) - .addRoute("0.0.0.0", 0) - .apply { - - val ipv6Address = runCatching { - addAddress(TUN_ADDRESS_IPV6, TUN_PREFIX_LENGTH_IPV6) - }.onFailure { - AppLogger.w( - TAG, - "could not add an IPv6 address to the TUN interface; IPv6 will be captured by the " + - "tunnel's default route and dropped rather than sent in the clear", - it, - ) - } - - runCatching { addRoute("::", 0) }.onFailure { - AppLogger.e( - TAG, - "could not claim the IPv6 default route; IPv6 traffic may bypass the tunnel and leak " + - "the device's real address", - it, - ) - } - if (ipv6Address.isFailure) { - AppLogger.i(TAG, "establishTun: IPv6 is routed into the tunnel and blackholed (no IPv6 address)") - } - } - .addDnsServer(dnsServer) - - .setMtu(mtu) - .setBlocking(true) - .apply { - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - runCatching { setMetered(false) } - .onFailure { AppLogger.d(TAG, "could not mark the tunnel as unmetered: ${it.message}") } - } - } - .apply { - runCatching { addDisallowedApplication(packageName) } - .onFailure { AppLogger.w(TAG, "failed to exclude own app from the VPN tunnel", it) } - for (excludedPackage in excludedApps) { - runCatching { addDisallowedApplication(excludedPackage) } - .onFailure { AppLogger.w(TAG, "failed to exclude $excludedPackage from the VPN tunnel (app may be uninstalled)", it) } - } - } - .establish() - } private fun teardown() { - connectionGeneration++ - watchdogJob?.cancel() - watchdogJob = null - releaseResources(detachResources(), stopNativeTunnel = true) + retryJob?.cancel() + retryJob = null + connectJob?.cancel() + connectJob = null + val ownsCore = + synchronized(credentialCommitLock) { + if (coreOwner === this) { + Libbox.setBearerToken("") + coreOwner = null + true + } else false + } + releaseResources(detachResources()) + if (!ownsCore) return + ControlPlaneHttp.socketProtector = null + ControlPlaneHttp.networkProvider = null _state.value = ConnectionState.DISCONNECTED _vpnLiveStats.value = VpnLiveStats() - _connectedAtEpochMs.value = null + _connectedAtElapsedMs.value = null _pingLatencyMs.value = null _vpnEdgeIp.value = null - _connectedEndpoint.value = null + _activeCandidate.value = null + _tunnelHealth.value = TunnelHealth.UNKNOWN + connectionSettings = null + connectionCandidate = null + healthRecoveryInProgress = false + QuickConnectWidgetProvider.updateAll(this, ConnectionState.DISCONNECTED) } private fun detachResources(): SessionResources { - speedJob?.cancel() - speedJob = null + credentialExpiryJob?.cancel() + credentialExpiryJob = null + activeSocksPort = null + activeProxyCredentials = "" to "" + pingJob?.cancel() + pingJob = null + exitCheckJob?.cancel() + exitCheckJob = null + healthJob?.cancel() + healthJob = null + trafficMonitor?.stop() + trafficMonitor = null tokenRenewalJob?.cancel() tokenRenewalJob = null - val detached = SessionResources(tunFd, socksServer, httpProxyServer, upstreamSession) - tunFd = null + val detached = SessionResources(commandServer, activePlatformInterface) tunnelInterfaceActive = false - socksServer = null - httpProxyServer = null - upstreamSession = null + commandServer = null + activePlatformInterface = null return detached } - private fun releaseResources(resources: SessionResources, stopNativeTunnel: Boolean) { - if (resources.isEmpty && !stopNativeTunnel) return - if (stopNativeTunnel) { - runCatching { HevSocks5Tunnel.stop() }.onFailure { AppLogger.w(TAG, "error stopping tun2socks tunnel", it) } - } - runCatching { resources.socksServer?.stop() }.onFailure { AppLogger.w(TAG, "error stopping local SOCKS5 server", it) } - runCatching { resources.httpProxyServer?.stop() }.onFailure { AppLogger.w(TAG, "error stopping local HTTP proxy server", it) } - runCatching { resources.upstreamSession?.close() }.onFailure { AppLogger.w(TAG, "error closing upstream session", it) } - runCatching { resources.tunFd?.close() }.onFailure { AppLogger.w(TAG, "error closing TUN interface", it) } + private fun releaseResources(resources: SessionResources) { + if (resources.isEmpty) return + runCatching { resources.commandServer?.closeService() } + .onFailure { AppLogger.w(TAG, "error closing sing-box service", it) } + runCatching { resources.commandServer?.close() } + .onFailure { AppLogger.w(TAG, "error closing sing-box command server", it) } + runCatching { resources.platformInterface?.closeTun() } + .onFailure { AppLogger.w(TAG, "error closing VPN interface", it) } } override fun onRevoke() { @@ -1170,106 +1028,273 @@ class FoxyVpnService : VpnService() { super.onRevoke() } - private fun startSpeedUpdates() { - speedJob?.cancel() - speedJob = scope.launch { - var lastStats = HevSocks5Tunnel.stats() - var lastSampleAt = System.currentTimeMillis() - while (isActive) { - delay(SPEED_UPDATE_INTERVAL_MS) - val stats = HevSocks5Tunnel.stats() - val now = System.currentTimeMillis() - val elapsedSeconds = ((now - lastSampleAt).coerceAtLeast(1)).toDouble() / 1_000.0 - - val txRate = ((stats[1] - lastStats[1]).coerceAtLeast(0) / elapsedSeconds).toLong() - val rxRate = ((stats[3] - lastStats[3]).coerceAtLeast(0) / elapsedSeconds).toLong() - val totalSessionBytes = (stats[1] + stats[3]).coerceAtLeast(0L) - lastStats = stats - lastSampleAt = now - if (_state.value != ConnectionState.CONNECTED) continue - _vpnLiveStats.value = VpnLiveStats( - txRateBytesPerSec = txRate, - rxRateBytesPerSec = rxRate, - sessionBytes = totalSessionBytes, - ) - updateNotification( - statusLabel, - "\u2193 ${formatBytesPerSecond(rxRate)} \u2191 ${formatBytesPerSecond(txRate)}", - ) + private fun isCurrentSession(generation: Int): Boolean = + !stopping && + coreOwner === this && + generation == connectionGeneration.get() && + _state.value == ConnectionState.CONNECTED + + private suspend fun measureActiveLatency(): Int? { + + val generation = connectionGeneration.get() + val port = activeSocksPort ?: return null + return pingMutex.withLock { + fun current() = isCurrentSession(generation) && activeSocksPort == port + if (!current()) return@withLock null + val url = (applicationContext as FoxyVpnApp).settingsStore.latencyProbeUrl + val credentials = activeProxyCredentials + val latency = + PingUtil.measureProxyLatencyMs(port, url, credentials.first, credentials.second) + synchronized(credentialCommitLock) { + if (current()) { + _pingLatencyMs.value = latency + latency + } else null } } } - private fun buildNotification(text: String, subText: String? = null): Notification { - val openIntent = PendingIntent.getActivity( - this, REQUEST_CODE_OPEN, Intent(this, MainActivity::class.java), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - val disconnectIntent = PendingIntent.getService( - this, REQUEST_CODE_DISCONNECT, Intent(this, FoxyVpnService::class.java).setAction(ACTION_DISCONNECT), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - val builder = NotificationCompat.Builder(this, FoxyVpnApp.VPN_NOTIFICATION_CHANNEL_ID) - .setContentTitle("FoxyVPN") - .setContentText(text) - .setSmallIcon(android.R.drawable.ic_lock_lock) - .setContentIntent(openIntent) - .setOngoing(true) + private fun startHealthMonitor( + generation: Int, + settings: TunnelSettings, + candidate: ProxyCandidate, + ) { + healthJob?.cancel() + healthJob = + scope.launch { + var failures = 0 + val credentials = activeProxyCredentials + val urls = + listOf( + "https://www.gstatic.com/generate_204", + "https://www.cloudflare.com/cdn-cgi/trace", + ) + fun current() = isActive && isCurrentSession(generation) + while (current()) { + val network = firstUsableNetwork() + val capabilities = + network?.let { + getSystemService(ConnectivityManager::class.java) + ?.getNetworkCapabilities(it) + } + if ( + capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) != + true + ) { + synchronized(credentialCommitLock) { + if (current()) _tunnelHealth.value = TunnelHealth.NO_NETWORK + } + failures = 0 + delay(60_000L) + continue + } + val healthy = + urls.any { + PingUtil.measureProxyLatencyMs( + settings.socksPort, + it, + credentials.first, + credentials.second, + ) != null + } + synchronized(credentialCommitLock) { + if (!current()) return@launch + _tunnelHealth.value = + if (healthy) TunnelHealth.HEALTHY else TunnelHealth.UNREACHABLE + } + failures = if (healthy) 0 else failures + 1 + if ( + failures >= 3 && + settings.automaticRecovery && + settings.effectiveCustomEdgeAddress == null + ) { + + val directWorks = + urls.all { url -> + try { + val request = okhttp3.Request.Builder().url(url).build() + ControlPlaneHttp.client.newCall(request).awaitResponse().use { + it.isSuccessful + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + false + } + } + if ( + directWorks && + current() && + firstUsableNetwork() == network && + recoveryBudget.tryAcquire(SystemClock.elapsedRealtime()) + ) { + val directory = (applicationContext as FoxyVpnApp).serverListClient + val cached = directory.loadCachedCountries() + val countries = + if (cached.isNotEmpty() && directory.hasFreshCache()) cached + else + try { + withTimeout(SERVER_LIST_FETCH_TIMEOUT_MS) { + directory.fetchCountries() + } + } catch (cancelled: CancellationException) { + if (cancelled !is TimeoutCancellationException) + throw cancelled + cached + } catch (_: Exception) { + cached + } + if (!current() || firstUsableNetwork() != network) return@launch + val alternate = + ServerListClient.candidatesForCountry( + countries, + candidate.countryCode, + ) + .filter { it.authority != candidate.authority } + .randomOrNull() + AppLogger.w( + TAG, + "confirmed proxy-path failure; bounded recovery within the selected country", + ) - .setOnlyAlertOnce(true) + connectJob = + scope.launch { + opMutex.withLock { + synchronized(credentialCommitLock) { + if ( + stopping || + generation != connectionGeneration.get() || + coreOwner !== this@FoxyVpnService + ) + return@withLock + Libbox.setBearerToken("") + _state.value = ConnectionState.CONNECTING + _activeCandidate.value = null + _vpnEdgeIp.value = null + _pingLatencyMs.value = null + _vpnLiveStats.value = VpnLiveStats() + _connectedAtElapsedMs.value = null + _tunnelHealth.value = TunnelHealth.CHECKING + } + releaseResources(detachResources()) + connectionCandidate = alternate ?: candidate + statusLabel = "Reconnecting…" + updateNotification(statusLabel) + healthRecoveryInProgress = true + connect() + } + } + return@launch + } + } + delay(60_000L) + } + } + } - .setShowWhen(false) - .setCategory(NotificationCompat.CATEGORY_SERVICE) + private fun startSpeedUpdates(generation: Int) { + trafficMonitor?.stop() + trafficMonitor = + NativeTrafficMonitor(scope) { stats -> + synchronized(credentialCommitLock) { + if (isCurrentSession(generation)) { + _vpnLiveStats.value = stats + updateNotification( + statusLabel, + "\u2193 ${formatBytesPerSecond(stats.rxRateBytesPerSec)} \u2191 ${formatBytesPerSecond(stats.txRateBytesPerSec)}", + ) + } + } + } + .also { it.start() } + } - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + private fun buildNotification(text: String, subText: String? = null): Notification { + val openIntent = + PendingIntent.getActivity( + this, + REQUEST_CODE_OPEN, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) - .setLocalOnly(true) + val disconnectIntent = + PendingIntent.getService( + this, + REQUEST_CODE_DISCONNECT, + Intent(this, FoxyVpnService::class.java).setAction(ACTION_DISCONNECT), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val builder = + NotificationCompat.Builder(this, FoxyVpnApp.VPN_NOTIFICATION_CHANNEL_ID) + .setContentTitle("FoxyVPN") + .setContentText(text) + .setSmallIcon(android.R.drawable.ic_lock_lock) + .setContentIntent(openIntent) + .setOngoing(true) + .setOnlyAlertOnce(true) + .addAction( + android.R.drawable.ic_menu_close_clear_cancel, + "Disconnect", + disconnectIntent, + ) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setPriority(NotificationCompat.PRIORITY_LOW) - .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) - .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Disconnect", disconnectIntent) if (subText != null) { - builder.setStyle(NotificationCompat.BigTextStyle().bigText("$text\n$subText")) + builder.setSubText(subText) } return builder.build() } private fun enterForeground(text: String) { - foregroundActive = true + val notification = buildNotification(text) lastNotificationText = text + lastNotificationSubText = null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { startForeground( NOTIFICATION_ID, - buildNotification(text), + notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE, ) } else { - startForeground(NOTIFICATION_ID, buildNotification(text)) + startForeground(NOTIFICATION_ID, notification) } + foregroundActive = true } private fun exitForeground() { + if (!foregroundActive) return foregroundActive = false lastNotificationText = null - runCatching { stopForeground(STOP_FOREGROUND_REMOVE) } + lastNotificationSubText = null + stopForeground(STOP_FOREGROUND_REMOVE) } private fun updateNotification(text: String, subText: String? = null) { if (!foregroundActive) return - val key = if (subText == null) text else "$text\n$subText" - if (key == lastNotificationText) return - lastNotificationText = key - val manager = getSystemService(NotificationManager::class.java) ?: return - runCatching { manager.notify(NOTIFICATION_ID, buildNotification(text, subText)) } + if (lastNotificationText == text && lastNotificationSubText == subText) return + lastNotificationText = text + lastNotificationSubText = subText + val notification = buildNotification(text, subText) + val manager = getSystemService(NotificationManager::class.java) + manager?.notify(NOTIFICATION_ID, notification) } companion object { + private val recoveryBudget = RecoveryBudget() + private val nativeMutex = Mutex() + private val credentialCommitLock = Any() + @Volatile private var coreOwner: FoxyVpnService? = null const val ACTION_CONNECT = "com.vauth.foxyvpn.action.CONNECT" const val ACTION_DISCONNECT = "com.vauth.foxyvpn.action.DISCONNECT" private const val NOTIFICATION_ID = 1 private const val REQUEST_CODE_OPEN = 0 private const val REQUEST_CODE_DISCONNECT = 1 + private val _proxyOnlyActive = MutableStateFlow(false) + val proxyOnlyActive: StateFlow = _proxyOnlyActive + private val _state = MutableStateFlow(ConnectionState.DISCONNECTED) val state: StateFlow = _state @@ -1279,37 +1304,37 @@ class FoxyVpnService : VpnService() { private val _vpnLiveStats = MutableStateFlow(VpnLiveStats()) val vpnLiveStats: StateFlow = _vpnLiveStats - private val _connectedAtEpochMs = MutableStateFlow(null) - val connectedAtEpochMs: StateFlow = _connectedAtEpochMs + private val _connectedAtElapsedMs = MutableStateFlow(null) + val connectedAtElapsedMs: StateFlow = _connectedAtElapsedMs private val _pingLatencyMs = MutableStateFlow(null) val pingLatencyMs: StateFlow = _pingLatencyMs + private val _activeCandidate = MutableStateFlow(null) + val activeCandidate: StateFlow = _activeCandidate + private val _tunnelHealth = MutableStateFlow(TunnelHealth.UNKNOWN) + val tunnelHealth: StateFlow = _tunnelHealth + private val _vpnEdgeIp = MutableStateFlow(null) val vpnEdgeIp: StateFlow = _vpnEdgeIp - private val _connectedEndpoint = MutableStateFlow?>(null) - val connectedEndpoint: StateFlow?> = _connectedEndpoint - - suspend fun pingNow(): Int? { - val endpoint = _connectedEndpoint.value ?: return null - val latency = PingUtil.measureTcpLatencyMs(endpoint.first, endpoint.second) - if (_state.value == ConnectionState.CONNECTED) { - _pingLatencyMs.value = latency - } - return latency - } + suspend fun pingNow(): Int? = coreOwner?.measureActiveLatency() fun start(context: Context) { - context.startForegroundService(Intent(context, FoxyVpnService::class.java).setAction(ACTION_CONNECT)) + context.startForegroundService( + Intent(context, FoxyVpnService::class.java).setAction(ACTION_CONNECT) + ) } fun stop(context: Context) { - val intent = Intent(context, FoxyVpnService::class.java).setAction(ACTION_DISCONNECT) runCatching { context.startService(intent) } .onFailure { - AppLogger.w(TAG, "could not deliver the disconnect intent; the service is not running", it) + AppLogger.w( + TAG, + "could not deliver the disconnect intent; service is not running", + it, + ) } } } diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/ListenerAvailability.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/ListenerAvailability.kt new file mode 100644 index 00000000..2f83154a --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/vpn/ListenerAvailability.kt @@ -0,0 +1,29 @@ +package com.vauth.foxyvpn.vpn + +import java.net.BindException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket + +internal object ListenerAvailability { + fun requireAvailable(ports: List) { + val reservations = mutableListOf() + try { + for (port in ports.distinct()) { + val socket = ServerSocket() + reservations.add(socket) + socket.reuseAddress = false + try { + socket.bind(InetSocketAddress(InetAddress.getByName("127.0.0.1"), port)) + } catch (error: BindException) { + throw IllegalArgumentException( + "Local port $port is unavailable. Close the other listener or change the proxy port.", + error, + ) + } + } + } finally { + reservations.forEach { socket -> runCatching { socket.close() } } + } + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/LocalProxyClient.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/LocalProxyClient.kt new file mode 100644 index 00000000..0013ca21 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/vpn/LocalProxyClient.kt @@ -0,0 +1,36 @@ +package com.vauth.foxyvpn.vpn + +import java.net.InetSocketAddress +import java.net.Proxy +import java.util.concurrent.TimeUnit +import okhttp3.Credentials +import okhttp3.OkHttpClient + +internal fun localProxyClient( + port: Int, + username: String = "", + password: String = "", + timeoutSeconds: Long = 8, +): OkHttpClient { + require(port in 1..65_535) + return OkHttpClient.Builder() + .proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress("127.0.0.1", port))) + .proxyAuthenticator { _, response -> + if (username.isBlank() || response.request.header("Proxy-Authorization") != null) null + else + response.request + .newBuilder() + .header( + "Proxy-Authorization", + Credentials.basic(username, password, Charsets.UTF_8), + ) + .build() + } + .connectTimeout(minOf(timeoutSeconds, 5), TimeUnit.SECONDS) + .readTimeout(timeoutSeconds, TimeUnit.SECONDS) + .callTimeout(timeoutSeconds, TimeUnit.SECONDS) + .followRedirects(false) + .followSslRedirects(false) + .retryOnConnectionFailure(false) + .build() +} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/NativeTrafficMonitor.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/NativeTrafficMonitor.kt new file mode 100644 index 00000000..96797f19 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/vpn/NativeTrafficMonitor.kt @@ -0,0 +1,115 @@ +package com.vauth.foxyvpn.vpn + +import android.os.SystemClock +import com.vauth.foxyvpn.data.AppLogger +import io.nekohasekai.libbox.CommandClient +import io.nekohasekai.libbox.CommandClientHandler +import io.nekohasekai.libbox.CommandClientOptions +import io.nekohasekai.libbox.ConnectionEvents +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.LogIterator +import io.nekohasekai.libbox.OutboundGroupItemIterator +import io.nekohasekai.libbox.OutboundGroupIterator +import io.nekohasekai.libbox.StatusMessage +import io.nekohasekai.libbox.StringIterator +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +internal class NativeTrafficMonitor( + private val scope: CoroutineScope, + private val publish: (VpnLiveStats) -> Unit, +) { + private var job: Job? = null + + fun start() { + stop() + job = + scope.launch { + while (isActive) { + val disconnected = CompletableDeferred() + val sampler = TrafficRateSampler() + val handler = + object : CommandClientHandler { + override fun connected() = Unit + + override fun disconnected(message: String?) { + disconnected.complete(message) + } + + override fun writeStatus(message: StatusMessage?) { + if (isActive && message?.trafficAvailable == true) { + publish( + sampler.sample( + message.uplinkTotal, + message.downlinkTotal, + SystemClock.elapsedRealtime(), + ) + ) + } + } + + override fun clearLogs() = Unit + + override fun setDefaultLogLevel(level: Int) = Unit + + override fun writeLogs(messages: LogIterator?) = Unit + + override fun writeGroups(groups: OutboundGroupIterator?) = Unit + + override fun writeOutbounds(outbounds: OutboundGroupItemIterator?) = + Unit + + override fun initializeClashMode( + modes: StringIterator?, + current: String?, + ) = Unit + + override fun updateClashMode(mode: String?) = Unit + + override fun writeConnectionEvents(events: ConnectionEvents?) = Unit + } + val options = + CommandClientOptions().apply { + addCommand(Libbox.CommandStatus) + statusInterval = TimeUnit.SECONDS.toNanos(2) + } + val client = CommandClient(handler, options) + try { + client.connect() + val reason = disconnected.await() + if (isActive) + AppLogger.w("TrafficMonitor", reason ?: "Status stream disconnected") + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + AppLogger.w( + "TrafficMonitor", + "Native statistics are temporarily unavailable", + error, + ) + } finally { + runCatching { client.disconnect() } + .onFailure { + AppLogger.w( + "TrafficMonitor", + "Could not close status subscription", + it, + ) + } + } + delay(5_000L) + } + } + } + + fun stop() { + job?.cancel() + job = null + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/PingUtil.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/PingUtil.kt index a297367a..2e051240 100644 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/PingUtil.kt +++ b/app/src/main/java/com/vauth/foxyvpn/vpn/PingUtil.kt @@ -1,27 +1,69 @@ package com.vauth.foxyvpn.vpn +import com.vauth.foxyvpn.data.AppLogger +import com.vauth.foxyvpn.data.ControlPlaneHttp +import com.vauth.foxyvpn.data.SettingsStore +import com.vauth.foxyvpn.data.awaitResponse +import java.net.InetSocketAddress +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import java.net.InetSocketAddress -import java.net.Socket +import okhttp3.Request object PingUtil { - private const val TIMEOUT_MS = 1_500 - suspend fun measureTcpLatencyMs(host: String, port: Int): Int? = withContext(Dispatchers.IO) { - withTimeoutOrNull(TIMEOUT_MS.toLong()) { - runCatching { + suspend fun measureTcpLatencyMs(host: String, port: Int): Int? = + withContext(Dispatchers.IO) { + try { + val start = System.nanoTime() + val address = + ControlPlaneHttp.client.dns.lookup(host).firstOrNull() + ?: return@withContext null + ControlPlaneHttp.client.socketFactory.createSocket().use { socket -> + socket.connect(InetSocketAddress(address, port), 1_500) + } + ((System.nanoTime() - start) / 1_000_000L) + .coerceIn(1, Int.MAX_VALUE.toLong()) + .toInt() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + null + } + } + + suspend fun measureProxyLatencyMs( + socksPort: Int, + probeUrl: String, + username: String = "", + password: String = "", + ): Int? = + withContext(Dispatchers.IO) { + require(socksPort in 1..65_535) { "Invalid local proxy port" } + require(SettingsStore.isValidProbeUrl(probeUrl)) { "Latency endpoint must be HTTPS" } + + val client = localProxyClient(socksPort, username, password) + try { + val request = + Request.Builder() + .url(probeUrl) + .header("Cache-Control", "no-cache, no-store") + .build() val start = System.nanoTime() - Socket().use { socket -> - socket.bind(InetSocketAddress(0)) - com.vauth.foxyvpn.data.ControlPlaneHttp.socketProtector?.invoke(socket) - socket.connect(InetSocketAddress(host, port), TIMEOUT_MS) + client.newCall(request).awaitResponse().use { response -> + check(response.isSuccessful) { "Latency probe failed: HTTP ${response.code}" } + ((System.nanoTime() - start) / 1_000_000L) + .coerceIn(1, Int.MAX_VALUE.toLong()) + .toInt() } - ((System.nanoTime() - start) / 1_000_000L).toInt() - }.onFailure { - com.vauth.foxyvpn.data.AppLogger.d("PingUtil", "ping failed to $host:$port: ${it.message}") - }.getOrNull() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + AppLogger.d("PingUtil", "HTTPS latency probe failed: ${error.message}") + null + } finally { + client.dispatcher.executorService.shutdown() + client.connectionPool.evictAll() + } } - } } diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/RecoveryBudget.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/RecoveryBudget.kt new file mode 100644 index 00000000..9628de51 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/vpn/RecoveryBudget.kt @@ -0,0 +1,21 @@ +package com.vauth.foxyvpn.vpn + +internal class RecoveryBudget { + private val attempts = java.util.ArrayDeque() + + @Synchronized + fun tryAcquire(now: Long): Boolean { + while (attempts.isNotEmpty() && now - attempts.first >= 15 * 60_000L) attempts.removeFirst() + if (attempts.size >= 2) return false + attempts.addLast(now) + return true + } +} + +enum class TunnelHealth { + UNKNOWN, + CHECKING, + HEALTHY, + NO_NETWORK, + UNREACHABLE, +} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/RelayDispatchers.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/RelayDispatchers.kt deleted file mode 100644 index 2fc216ae..00000000 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/RelayDispatchers.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.vauth.foxyvpn.vpn - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.asCoroutineDispatcher -import java.util.concurrent.SynchronousQueue -import java.util.concurrent.ThreadFactory -import java.util.concurrent.ThreadPoolExecutor -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicLong - -object RelayDispatchers { - - private const val RELAY_THREAD_STACK_BYTES = 256L * 1024L - - private const val IDLE_THREAD_KEEPALIVE_SECONDS = 30L - - private val threadCounter = AtomicLong(0) - - private val threadFactory = ThreadFactory { runnable -> - Thread(null, runnable, "foxy-relay-${threadCounter.incrementAndGet()}", RELAY_THREAD_STACK_BYTES).apply { - - isDaemon = true - priority = Thread.NORM_PRIORITY - } - } - - private val executor = ThreadPoolExecutor( - 0, - Int.MAX_VALUE, - IDLE_THREAD_KEEPALIVE_SECONDS, - TimeUnit.SECONDS, - SynchronousQueue(), - threadFactory, - ) - - val relay: CoroutineDispatcher = executor.asCoroutineDispatcher() -} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/SingBoxConfigBuilder.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/SingBoxConfigBuilder.kt new file mode 100644 index 00000000..ba72285c --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/vpn/SingBoxConfigBuilder.kt @@ -0,0 +1,441 @@ +package com.vauth.foxyvpn.vpn + +import com.vauth.foxyvpn.data.SettingsStore +import com.vauth.foxyvpn.data.TunnelSettings +import com.vauth.foxyvpn.data.model.ProxyCandidate +import java.net.URI +import org.json.JSONArray +import org.json.JSONObject + +object SingBoxConfigBuilder { + + private fun proxyUsers(settings: TunnelSettings) = + JSONArray() + .put( + JSONObject().apply { + put("username", settings.localProxyUsername) + put("password", settings.localProxyPassword) + } + ) + + private val localNetworkAddresses = + listOf( + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", + "fc00::/7", + "fe80::/10", + ) + + private const val DEFAULT_TUN_IPV4 = "172.19.0.1/30" + private const val DEFAULT_TUN_IPV6 = "fdfe:dcba:9876::1/126" + private const val DEFAULT_TUN_DNS_IPV4 = "172.19.0.2" + private const val DEFAULT_TUN_DNS_IPV6 = "fdfe:dcba:9876::2" + + fun build( + primaryCandidate: ProxyCandidate, + settings: TunnelSettings, + bearerToken: String = "", + appPackageName: String? = null, + controllerSecret: String, + ): String { + require( + primaryCandidate.port in 1..65_535 && + SettingsStore.isValidEdgeHost(primaryCandidate.host) + ) { + "Invalid MASQUE server address" + } + require( + settings.socksPort in 1..65_535 && settings.socksPort != SettingsStore.CONTROLLER_PORT + ) { + "Local proxy port must be valid and must not use controller port ${SettingsStore.CONTROLLER_PORT}" + } + require( + !settings.upstreamProxyEnabled || + (SettingsStore.isValidProxyHost(settings.upstreamProxyHost) && + settings.upstreamProxyPort in 1..65_535) + ) { + "Set the upstream proxy address before connecting" + } + require( + !settings.upstreamProxyEnabled || + SettingsStore.isValidUpstreamProxyCredentials( + settings.upstreamProxyType, + settings.upstreamProxyUsername, + settings.upstreamProxyPassword, + ) + ) { + "Invalid upstream proxy credentials" + } + require( + !settings.upstreamProxyEnabled || + !SettingsStore.isLocalProxyLoop( + settings.upstreamProxyHost, + settings.upstreamProxyPort, + settings.socksPort, + ) + ) { + "Upstream proxy must not point to a FoxyVPN listener" + } + require(!settings.ipv6Enabled || settings.mtu >= 1280) { + "IPv6 requires a tunnel MTU of at least 1280" + } + require( + settings.dnsProvider == SettingsStore.DnsProvider.DIRECT_SYSTEM || + SettingsStore.isValidDohUrl(settings.effectiveDohUrl) + ) { + "DoH requires an HTTPS URL without credentials, query or fragment" + } + require( + !settings.localProxyAuthEnabled || + SettingsStore.isValidLocalProxyCredentials( + settings.localProxyUsername, + settings.localProxyPassword, + ) + ) { + "Local proxy credentials require 1–255 UTF-8 bytes each and no colon in the username" + } + require(controllerSecret.isNotBlank()) { "Controller authentication is required" } + require(settings.socksBindAddress == "127.0.0.1") { + "Unauthenticated LAN proxy listeners are disabled" + } + require(bearerToken.isNotBlank()) { "A proxy pass is required" } + val root = JSONObject() + + val log = + JSONObject().apply { + put("level", "info") + put("timestamp", true) + } + root.put("log", log) + + val certificate = JSONObject().apply { put("store", "mozilla") } + root.put("certificate", certificate) + + val dns = JSONObject() + val dnsServers = JSONArray() + + dnsServers.put( + JSONObject().apply { + put("type", "local") + put("tag", "local-dns") + } + ) + + val finalDnsTag: String = + when (settings.dnsProvider) { + SettingsStore.DnsProvider.DIRECT_SYSTEM -> { + "local-dns" + } + else -> { + val remoteDohUri = URI(settings.effectiveDohUrl) + val remoteDnsServer = remoteDohUri.host.removeSurrounding("[", "]") + val remoteDnsPath = remoteDohUri.rawPath.takeIf { it.isNotBlank() } + val remoteDnsPort = remoteDohUri.port.takeIf { it > 0 } + + dnsServers.put( + JSONObject().apply { + put("type", "https") + put("tag", "remote-dns") + put("server", remoteDnsServer) + if (remoteDnsPort != null && remoteDnsPort != 443) { + put("server_port", remoteDnsPort) + } + if (remoteDnsPath != null && remoteDnsPath != "/dns-query") { + put("path", remoteDnsPath) + } + if (remoteDnsServer.any { it.isLetter() }) { + put("domain_resolver", "local-dns") + } + put("detour", "proxy-out") + } + ) + "remote-dns" + } + } + + dns.put("servers", dnsServers) + dns.put("final", finalDnsTag) + dns.put("strategy", if (settings.ipv6Enabled) "prefer_ipv4" else "ipv4_only") + dns.put("disable_cache", false) + root.put("dns", dns) + + val utlsFingerprint = settings.utlsFingerprint.identifier + val httpClients = + JSONArray().apply { + put( + JSONObject().apply { + put("tag", "default-http-client") + put("engine", "go") + put("version", 2) + put( + "tls", + JSONObject().apply { + put( + "utls", + JSONObject().apply { + put("enabled", true) + put("fingerprint", utlsFingerprint) + }, + ) + }, + ) + } + ) + } + root.put("http_clients", httpClients) + + val inbounds = JSONArray() + + if (!settings.proxyOnlyMode) { + val hasTunIpv6 = settings.ipv6Enabled || settings.mtu >= 1280 + val tunInbound = + JSONObject().apply { + put("type", "tun") + put("tag", "tun-in") + put("mtu", settings.mtu) + put( + "address", + JSONArray().apply { + put(DEFAULT_TUN_IPV4) + if (hasTunIpv6) { + put(DEFAULT_TUN_IPV6) + } + }, + ) + put( + "dns_address", + JSONArray().apply { + put(DEFAULT_TUN_DNS_IPV4) + if (hasTunIpv6) { + put(DEFAULT_TUN_DNS_IPV6) + } + }, + ) + put("auto_route", true) + put("strict_route", true) + if (settings.bypassLocalNetwork) { + put("route_exclude_address", JSONArray(localNetworkAddresses)) + } + if (settings.splitTunnelMode == SettingsStore.SplitTunnelMode.INCLUDE) { + val included = + settings.includedApps.filter { it.isNotBlank() && it != appPackageName } + require(included.isNotEmpty()) { + "Select at least one app to include in the VPN" + } + put("include_package", JSONArray(included.sorted())) + } else { + val excluded = + settings.excludedApps.filter { it.isNotBlank() }.toMutableSet() + if (!appPackageName.isNullOrBlank()) excluded.add(appPackageName) + if (excluded.isNotEmpty()) + put("exclude_package", JSONArray(excluded.sorted())) + } + put( + "platform", + JSONObject().apply { + put("http_proxy", JSONObject().apply { put("enabled", false) }) + }, + ) + } + inbounds.put(tunInbound) + } + + val mixedInbound = + JSONObject().apply { + put("type", "mixed") + put("tag", "mixed-in") + put("listen", settings.socksBindAddress) + put("listen_port", settings.socksPort) + if (settings.localProxyAuthEnabled) put("users", proxyUsers(settings)) + } + inbounds.put(mixedInbound) + + root.put("inbounds", inbounds) + + val outbounds = JSONArray() + + val direct = + JSONObject().apply { + put("type", "direct") + put("tag", "direct-out") + } + outbounds.put(direct) + + val hasUpstreamDetour = + settings.upstreamProxyEnabled && settings.upstreamProxyHost.isNotBlank() + val targetVersion = settings.httpVersion.versionNumber + + run { + val candidate = primaryCandidate + val tag = "proxy-out" + + val customEdge = settings.effectiveCustomEdgeAddress + val serverAddress = customEdge ?: candidate.host + + val nodeOutbound = + JSONObject().apply { + put("type", "http") + put("tag", tag) + put("server", serverAddress) + put("server_port", candidate.port) + put("domain_resolver", "local-dns") + if (settings.tcpFastOpen) { + put("tcp_fast_open", true) + } + put("dynamic_bearer_token", true) + put("version", targetVersion) + if (targetVersion == 2) { + put("max_concurrent_streams", settings.maxConcurrentStreams) + put("disable_version_fallback", true) + put("stream_receive_window", "16 MB") + put("connection_receive_window", "32 MB") + put("idle_timeout", "30s") + put("keep_alive_period", "15s") + } else { + put("disable_version_fallback", false) + } + + val tls = + JSONObject().apply { + put("enabled", true) + put("server_name", candidate.host) + put( + "utls", + JSONObject().apply { + put("enabled", true) + put("fingerprint", settings.utlsFingerprint.identifier) + }, + ) + when (settings.tlsFragmentMode) { + SettingsStore.TlsFragmentMode.OFF -> {} + SettingsStore.TlsFragmentMode.RECORD -> { + put("record_fragment", true) + } + SettingsStore.TlsFragmentMode.PACKET -> { + put("fragment", true) + put( + "fragment_fallback_delay", + "${settings.fragmentFallbackDelay}ms", + ) + } + SettingsStore.TlsFragmentMode.COMBINED -> { + put("fragment", true) + put("record_fragment", true) + put( + "fragment_fallback_delay", + "${settings.fragmentFallbackDelay}ms", + ) + } + } + } + put("tls", tls) + + if (hasUpstreamDetour) { + put("detour", "upstream-detour") + } + } + outbounds.put(nodeOutbound) + } + + if (hasUpstreamDetour) { + val detour = + JSONObject().apply { + if (settings.upstreamProxyType == SettingsStore.UpstreamProxyType.HTTP) { + put("dynamic_bearer_token", false) + + put("version", 1) + } + put( + "type", + if (settings.upstreamProxyType == SettingsStore.UpstreamProxyType.HTTP) + "http" + else "socks", + ) + put("tag", "upstream-detour") + put("server", settings.upstreamProxyHost) + put("server_port", settings.upstreamProxyPort) + put("domain_resolver", "local-dns") + if (settings.upstreamProxyType == SettingsStore.UpstreamProxyType.SOCKS5) + put("version", "5") + if (settings.upstreamProxyUsername.isNotEmpty()) { + put("username", settings.upstreamProxyUsername) + put("password", settings.upstreamProxyPassword) + } + } + outbounds.put(detour) + } + + root.put("outbounds", outbounds) + + val route = + JSONObject().apply { + put( + "rules", + JSONArray().apply { + put( + JSONObject().apply { + put("protocol", "dns") + put("action", "hijack-dns") + } + ) + put( + JSONObject().apply { + put("port", 53) + put("action", "hijack-dns") + } + ) + put( + JSONObject().apply { + put("port", 853) + put("action", "reject") + } + ) + if (settings.bypassLocalNetwork) { + put( + JSONObject().apply { + put("ip_cidr", JSONArray(localNetworkAddresses)) + put("outbound", "direct-out") + } + ) + } + if (!settings.ipv6Enabled) { + put( + JSONObject().apply { + put("ip_version", 6) + put("action", "reject") + } + ) + } + put(JSONObject().apply { put("outbound", "proxy-out") }) + }, + ) + put("final", "proxy-out") + put("auto_detect_interface", true) + put("find_process", false) + put("default_domain_resolver", "local-dns") + put("default_http_client", "default-http-client") + } + root.put("route", route) + + val experimental = + JSONObject().apply { + put("cache_file", JSONObject().apply { put("enabled", false) }) + put( + "clash_api", + JSONObject().apply { + put( + "external_controller", + "${SettingsStore.DEFAULT_SOCKS_BIND_ADDRESS}:${SettingsStore.CONTROLLER_PORT}", + ) + put("secret", controllerSecret) + put("default_mode", "Rule") + }, + ) + } + root.put("experimental", experimental) + + return root.toString(2) + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/TrafficRateSampler.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/TrafficRateSampler.kt new file mode 100644 index 00000000..e625b73c --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/vpn/TrafficRateSampler.kt @@ -0,0 +1,27 @@ +package com.vauth.foxyvpn.vpn + +internal class TrafficRateSampler { + private var previousUpload = 0L + private var previousDownload = 0L + private var previousTime: Long? = null + + @Synchronized + fun sample(upload: Long, download: Long, nowMillis: Long): VpnLiveStats { + val up = upload.coerceAtLeast(0) + val down = download.coerceAtLeast(0) + val elapsed = previousTime?.let { nowMillis - it } + val (tx, rx) = + if (elapsed != null && elapsed > 0 && up >= previousUpload && down >= previousDownload) { + val txRate = ((up - previousUpload).toDouble() * 1000 / elapsed).toLong() + val rxRate = ((down - previousDownload).toDouble() * 1000 / elapsed).toLong() + txRate to rxRate + } else { + 0L to 0L + } + previousUpload = up + previousDownload = down + previousTime = nowMillis + val total = if (up > Long.MAX_VALUE - down) Long.MAX_VALUE else up + down + return VpnLiveStats(tx, rx, total) + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/socks/DohDnsClient.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/socks/DohDnsClient.kt deleted file mode 100644 index dd5f21ca..00000000 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/socks/DohDnsClient.kt +++ /dev/null @@ -1,249 +0,0 @@ -package com.vauth.foxyvpn.vpn.socks - -import com.vauth.foxyvpn.data.AppLogger -import okhttp3.ConnectionPool -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import java.io.InputStream -import java.net.InetAddress -import java.net.InetSocketAddress -import java.net.Proxy -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.TimeUnit - -private const val TAG = "DohDnsClient" - -private const val DNS_MESSAGE_CONTENT_TYPE = "application/dns-message" -private val DNS_MESSAGE_MEDIA_TYPE = DNS_MESSAGE_CONTENT_TYPE.toMediaType() - -private const val DOH_PATH = "/dns-query" - -private const val CONNECT_TIMEOUT_SECONDS = 5L -private const val CALL_TIMEOUT_SECONDS = 6L - -private const val MAX_IDLE_CONNECTIONS = 2 -private const val KEEP_ALIVE_MINUTES = 5L - -private const val FAILURE_SUPPRESSION_THRESHOLD = 3 -private const val FAILURE_SUPPRESSION_MS = 5 * 60_000L - -internal object DohEndpoints { - private class Operator(val addresses: List) - - private val OPERATORS = listOf( - - Operator( - listOf( - "1.1.1.1", - "1.0.0.1", - "2606:4700:4700::1111", - "2606:4700:4700::1001", - ), - ), - - Operator( - listOf( - "8.8.8.8", - "8.8.4.4", - "2001:4860:4860::8888", - "2001:4860:4860::8844", - ), - ), - - Operator( - listOf( - "9.9.9.9", - "149.112.112.112", - "2620:fe::fe", - "2620:fe::9", - ), - ), - ) - - private val endpoints: Map = buildMap { - for (operator in OPERATORS) { - for (literal in operator.addresses) { - val normalized = normalize(literal) ?: continue - put(normalized, endpointUrl(literal)) - } - } - } - - private val operatorEndpoints: Map> = buildMap { - for (operator in OPERATORS) { - val urls = operator.addresses.mapNotNull { literal -> - normalize(literal)?.let { endpointUrl(literal) } - } - for (literal in operator.addresses) { - val normalized = normalize(literal) ?: continue - val own = endpointUrl(literal) - put(normalized, listOf(own) + urls.filter { it != own }) - } - } - } - - fun forResolver(host: String): String? { - val normalized = normalize(host) ?: return null - return endpoints[normalized] - } - - fun operatorEndpointsFor(host: String): List { - val normalized = normalize(host) ?: return emptyList() - return operatorEndpoints[normalized] ?: emptyList() - } - - private fun endpointUrl(literal: String): String { - val authority = if (literal.contains(':')) "[$literal]" else literal - return "https://$authority$DOH_PATH" - } - - private fun normalize(host: String): String? { - if (!isAddressLiteral(host)) return null - return runCatching { InetAddress.getByName(host).hostAddress }.getOrNull() - } - - private fun isAddressLiteral(host: String): Boolean { - if (host.isEmpty()) return false - if (host.contains(':')) { - return host.all { it == ':' || it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } - } - val parts = host.split('.') - if (parts.size != 4) return false - return parts.all { part -> - part.isNotEmpty() && part.length <= 3 && part.all { it in '0'..'9' } && - (part.toIntOrNull() ?: -1) in 0..255 - } - } -} - -internal object DohDnsClient { - private class Transport(val proxyAddress: InetSocketAddress, val client: OkHttpClient) - - private class EndpointHealth { - - @Volatile var consecutiveFailures: Int = 0 - @Volatile var suppressedUntil: Long = 0L - } - - @Volatile private var transport: Transport? = null - - private val health = ConcurrentHashMap() - - const val DEFAULT_DOH_URL = "https://1.1.1.1/dns-query" - - fun endpointFor(resolverHost: String): String { - val preferred = DohEndpoints.forResolver(resolverHost) - if (preferred != null) { - if (!isSuppressed(preferred)) return preferred - val alt = DohEndpoints.operatorEndpointsFor(resolverHost).firstOrNull { !isSuppressed(it) } - if (alt != null) return alt - } - return DEFAULT_DOH_URL - } - - fun query(socksProxy: InetSocketAddress, endpointUrl: String, query: ByteArray): ByteArray? { - val request = Request.Builder() - .url(endpointUrl) - .header("accept", DNS_MESSAGE_CONTENT_TYPE) - - .post(query.toRequestBody(DNS_MESSAGE_MEDIA_TYPE)) - .build() - val answer = runCatching { - clientFor(socksProxy).newCall(request).execute().use { readAnswer(endpointUrl, it) } - }.getOrElse { throwable -> - AppLogger.d(TAG, "DoH query to $endpointUrl failed: ${throwable.message}") - null - } - if (answer == null) recordFailure(endpointUrl) else recordSuccess(endpointUrl) - return answer - } - - fun shutdown() { - val existing = synchronized(this) { transport.also { transport = null } } - health.clear() - existing?.let { close(it.client) } - } - - private fun isSuppressed(endpointUrl: String): Boolean { - val state = health[endpointUrl] ?: return false - return state.suppressedUntil > System.currentTimeMillis() - } - - private fun readAnswer(endpointUrl: String, response: Response): ByteArray? { - if (!response.isSuccessful) { - AppLogger.d(TAG, "DoH endpoint $endpointUrl answered HTTP ${response.code}") - return null - } - val body = response.body ?: return null - - val mediaType = body.contentType() - if (mediaType != null && "${mediaType.type}/${mediaType.subtype}" != DNS_MESSAGE_CONTENT_TYPE) { - AppLogger.d(TAG, "DoH endpoint $endpointUrl answered content-type $mediaType") - return null - } - if (body.contentLength() > MAX_DNS_MESSAGE_BYTES) return null - return readBounded(body.byteStream(), MAX_DNS_MESSAGE_BYTES) - } - - private fun readBounded(stream: InputStream, limit: Int): ByteArray? { - val buffer = ByteArray(limit + 1) - var total = 0 - while (total < buffer.size) { - val read = stream.read(buffer, total, buffer.size - total) - if (read < 0) break - total += read - } - if (total == 0 || total > limit) return null - return buffer.copyOf(total) - } - - private fun clientFor(socksProxy: InetSocketAddress): OkHttpClient { - transport?.takeIf { it.proxyAddress == socksProxy }?.let { return it.client } - return synchronized(this) { - val existing = transport - if (existing != null && existing.proxyAddress == socksProxy) { - existing.client - } else { - - existing?.let { close(it.client) } - val client = OkHttpClient.Builder() - .proxy(Proxy(Proxy.Type.SOCKS, socksProxy)) - .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .connectionPool( - ConnectionPool(MAX_IDLE_CONNECTIONS, KEEP_ALIVE_MINUTES, TimeUnit.MINUTES), - ) - .build() - transport = Transport(socksProxy, client) - client - } - } - } - - private fun close(client: OkHttpClient) { - runCatching { client.dispatcher.executorService.shutdown() } - runCatching { client.connectionPool.evictAll() } - } - - private fun recordSuccess(endpointUrl: String) { - val state = health[endpointUrl] ?: return - state.consecutiveFailures = 0 - state.suppressedUntil = 0L - } - - private fun recordFailure(endpointUrl: String) { - val state = health.computeIfAbsent(endpointUrl) { EndpointHealth() } - val failures = state.consecutiveFailures + 1 - state.consecutiveFailures = failures - if (failures < FAILURE_SUPPRESSION_THRESHOLD || state.suppressedUntil > System.currentTimeMillis()) return - state.suppressedUntil = System.currentTimeMillis() + FAILURE_SUPPRESSION_MS - AppLogger.w( - TAG, - "$endpointUrl failed $failures times in a row; leaving it alone for the next " + - "${FAILURE_SUPPRESSION_MS / 60_000} minutes and using the same resolver's other addresses", - ) - } -} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/socks/LocalHttpProxyServer.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/socks/LocalHttpProxyServer.kt deleted file mode 100644 index 49e70453..00000000 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/socks/LocalHttpProxyServer.kt +++ /dev/null @@ -1,509 +0,0 @@ -package com.vauth.foxyvpn.vpn.socks - -import android.net.InetAddresses -import android.os.Build -import com.vauth.foxyvpn.data.AppLogger -import com.vauth.foxyvpn.vpn.RelayDispatchers -import com.vauth.foxyvpn.vpn.tun.HevSocks5TunnelConfig -import com.vauth.foxyvpn.vpn.upstream.UpstreamConnectRejectedException -import com.vauth.foxyvpn.vpn.upstream.UpstreamHealthTracker -import com.vauth.foxyvpn.vpn.upstream.UpstreamSession -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.withTimeoutOrNull -import java.io.ByteArrayOutputStream -import java.io.EOFException -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.net.InetAddress -import java.net.InetSocketAddress -import java.net.ServerSocket -import java.net.Socket -import java.net.SocketException -import java.net.URI - -private const val TAG = "LocalHttpProxyServer" - -private const val SESSION_WAIT_TIMEOUT_MS = 4_000L -private const val SESSION_WAIT_POLL_INTERVAL_MS = 100L -private const val OPEN_STREAM_TIMEOUT_MS = 30_000L -private const val HALF_CLOSE_DRAIN_TIMEOUT_MS = 2 * 60_000L -private const val MAX_CONCURRENT_CLIENT_CONNECTIONS = 512 -private const val ACCEPT_BACKLOG = 256 -private const val ACCEPT_ERROR_BACKOFF_MS = 100L -private const val RELAY_BUFFER_BYTES = 32 * 1024 -private const val MAX_HEADER_SIZE_BYTES = 64 * 1024 -private const val UNREACHABLE_TARGET_TTL_MS = 30_000L -private const val UNREACHABLE_TARGET_TTL_CAP_MS = 10 * 60_000L -private const val REFUSALS_BEFORE_TREATING_AS_POLICY = 3 -private const val UNREACHABLE_TARGET_CACHE_CAP = 256 - -class LocalHttpProxyServer( - private val bindAddress: String, - private val port: Int, - private val sessionProvider: () -> UpstreamSession?, - private val onSessionUnhealthy: () -> Unit = {}, -) { - private var serverSocket: ServerSocket? = null - - private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - AppLogger.w( - TAG, - "unhandled exception in an HTTP proxy connection coroutine", - throwable, - ) - } - private val scope = CoroutineScope(RelayDispatchers.relay + SupervisorJob() + exceptionHandler) - private val connectionSlots = Semaphore(MAX_CONCURRENT_CLIENT_CONNECTIONS) - private val health = UpstreamHealthTracker() - @Volatile private var unauthenticatedSession: UpstreamSession? = null - - private class RefusalRecord { - var strikes: Int = 0 - var expiresAt: Long = 0L - } - - private val unreachableTargets = object : LinkedHashMap(64, 0.75f, false) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean = - size > UNREACHABLE_TARGET_CACHE_CAP - } - - fun start() { - val server = ServerSocket() - runCatching { server.reuseAddress = true } - server.bind(InetSocketAddress(InetAddress.getByName(bindAddress), port), ACCEPT_BACKLOG) - serverSocket = server - AppLogger.i(TAG, "started local HTTP proxy on $bindAddress:$port") - - scope.launch { - while (!server.isClosed) { - val accepted = runCatching { server.accept() } - val client = accepted.getOrNull() - if (client == null) { - if (server.isClosed) break - AppLogger.d(TAG, "accept() failed on HTTP listening socket: ${accepted.exceptionOrNull()?.message}") - delay(ACCEPT_ERROR_BACKOFF_MS) - continue - } - - runCatching { client.tcpNoDelay = true } - if (!connectionSlots.tryAcquire()) { - AppLogger.w(TAG, "rejecting HTTP proxy client: connection limit reached") - runCatching { client.close() } - continue - } - scope.launch { - try { - handleClient(client) - } finally { - connectionSlots.release() - } - } - } - } - } - - fun stop() { - runCatching { serverSocket?.close() } - scope.cancel() - synchronized(unreachableTargets) { unreachableTargets.clear() } - health.reset() - unauthenticatedSession = null - AppLogger.i(TAG, "stopped local HTTP proxy on $bindAddress:$port") - } - - private suspend fun handleClient(client: Socket) { - try { - handleClientOrThrow(client) - } catch (e: EOFException) { - AppLogger.d(TAG, "HTTP proxy client disconnected before completing its request (EOF)") - } catch (e: SocketException) { - AppLogger.d(TAG, "HTTP proxy client socket closed: ${e.message}") - } catch (e: IOException) { - AppLogger.d(TAG, "I/O error handling HTTP proxy client: ${e.message}") - } - } - - private suspend fun handleClientOrThrow(socket: Socket) { - socket.use { client -> - client.soTimeout = 15_000 - val input = client.getInputStream() - val output = client.getOutputStream() - - val requestLine = readLine(input) ?: return - if (requestLine.isBlank()) return - - val parts = requestLine.trim().split(" ") - if (parts.size < 2) { - sendHttpError(output, 400, "Bad Request") - return - } - - val method = parts[0].uppercase() - val uriStr = parts[1] - val httpVersion = if (parts.size >= 3) parts[2] else "HTTP/1.1" - - if (method == "CONNECT") { - handleConnect(client, input, output, uriStr, httpVersion) - } else { - handleForward(client, input, output, method, uriStr, httpVersion) - } - } - } - - private fun isIpLiteral(host: String): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - return InetAddresses.isNumericAddress(host) - } - if (host.contains(':')) { - return host.all { it == ':' || it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } - } - val parts = host.split('.') - if (parts.size != 4) return false - return parts.all { part -> - part.isNotEmpty() && part.length <= 3 && part.all { it in '0'..'9' } && - (part.toIntOrNull() ?: -1) in 0..255 - } - } - - private fun isLocalNetwork(host: String): Boolean { - if (host.equals("localhost", ignoreCase = true)) return true - if (!isIpLiteral(host)) return false - val inet = runCatching { InetAddress.getByName(host) }.getOrNull() ?: return false - val raw = inet.address - if (inet.isAnyLocalAddress || inet.isLoopbackAddress || inet.isLinkLocalAddress || inet.isSiteLocalAddress || inet.isMulticastAddress) return true - if (raw.size == 16) return (raw[0].toInt() and 0xFE) == 0xFC - if (raw.size == 4) return raw.all { it.toInt() and 0xFF == 0xFF } - return false - } - - private suspend fun handleConnect( - socket: Socket, - input: InputStream, - output: OutputStream, - targetAuthority: String, - httpVersion: String, - ) { - while (true) { - val line = readLine(input) ?: break - if (line.isEmpty()) break - } - - val targetHost: String - val targetPort: Int - if (targetAuthority.startsWith("[")) { - targetHost = targetAuthority.substringAfter("[").substringBefore("]") - targetPort = targetAuthority.substringAfter("]:", "443").toIntOrNull() ?: 443 - } else if (targetAuthority.contains(":")) { - targetHost = targetAuthority.substringBeforeLast(":") - targetPort = targetAuthority.substringAfterLast(":").toIntOrNull() ?: 443 - } else { - targetHost = targetAuthority - targetPort = 443 - } - - if (HevSocks5TunnelConfig.isFakeDnsAddress(targetHost)) { - sendHttpError(output, 404, "Not Found", "Synthetic DNS address with no live hostname mapping") - return - } - - if (isLocalNetwork(targetHost)) { - sendHttpError(output, 403, "Forbidden", "Local network destinations are not carried by the tunnel") - return - } - - val targetKey = "$targetHost:$targetPort" - if (isKnownUnreachable(targetKey)) { - AppLogger.d(TAG, "refusing $targetKey: the edge refused this destination moments ago") - sendHttpError(output, 502, "Bad Gateway", "Destination recently refused by upstream edge") - return - } - - val session = awaitUsableSession() - if (session == null) { - sendHttpError(output, 502, "Bad Gateway", "No VPN session available") - return - } - - if (session === unauthenticatedSession) { - sendHttpError(output, 502, "Bad Gateway", "Proxy session unauthenticated") - return - } - - val tunnelResult = runCatching { - withTimeoutOrNull(OPEN_STREAM_TIMEOUT_MS) { session.openStream(targetHost, targetPort) } - } - val tunneled = tunnelResult.getOrNull() - - if (tunneled == null) { - val cause = tunnelResult.exceptionOrNull() - if (shouldRememberRefusal(cause)) rememberUnreachable(targetKey, targetPort) - when (health.observeFailure(targetKey, cause)) { - UpstreamHealthTracker.Verdict.TARGET_FAILURE -> Unit - UpstreamHealthTracker.Verdict.SESSION_UNHEALTHY -> { - AppLogger.w( - TAG, - "upstream session looks unhealthy: multiple destinations failed; asking for redial", - ) - runCatching { onSessionUnhealthy() } - } - UpstreamHealthTracker.Verdict.SESSION_UNAUTHENTICATED -> { - val alreadyLatched = session === unauthenticatedSession - unauthenticatedSession = session - if (!alreadyLatched) { - AppLogger.w( - TAG, - "the edge rejected this session's proxy pass (${cause?.message}); asking for a redial", - ) - } - runCatching { onSessionUnhealthy() } - } - } - sendHttpError(output, 504, "Gateway Timeout", "Failed to connect to $targetKey: ${cause?.message ?: "timeout"}") - return - } - - health.observeSuccess() - forgetUnreachable(targetKey) - if (unauthenticatedSession != null && session !== unauthenticatedSession) { - unauthenticatedSession = null - } - - val response = "HTTP/1.1 200 Connection Established\r\nProxy-Agent: FoxyVPN\r\n\r\n" - output.write(response.toByteArray(Charsets.US_ASCII)) - output.flush() - - socket.soTimeout = 0 - relayBidirectional(socket, input, output, tunneled) - } - - private suspend fun handleForward( - socket: Socket, - input: InputStream, - output: OutputStream, - method: String, - uriStr: String, - httpVersion: String, - ) { - val headers = mutableListOf() - var hostHeader: String? = null - while (true) { - val line = readLine(input) ?: break - if (line.isEmpty()) break - headers.add(line) - if (line.startsWith("Host:", ignoreCase = true)) { - hostHeader = line.substringAfter(":").trim() - } - } - - val parsedUri = runCatching { URI(uriStr) }.getOrNull() - val targetHost = parsedUri?.host ?: hostHeader?.substringBefore(":") ?: "" - val targetPort = if (parsedUri?.port != null && parsedUri.port != -1) { - parsedUri.port - } else if (hostHeader != null && hostHeader.contains(":")) { - hostHeader.substringAfter(":").toIntOrNull() ?: 80 - } else { - 80 - } - - if (targetHost.isBlank()) { - sendHttpError(output, 400, "Bad Request", "Missing destination host") - return - } - - if (HevSocks5TunnelConfig.isFakeDnsAddress(targetHost)) { - sendHttpError(output, 404, "Not Found", "Synthetic DNS address with no live hostname mapping") - return - } - - if (isLocalNetwork(targetHost)) { - sendHttpError(output, 403, "Forbidden", "Local network destinations are not carried by the tunnel") - return - } - - val targetKey = "$targetHost:$targetPort" - if (isKnownUnreachable(targetKey)) { - AppLogger.d(TAG, "refusing $targetKey: the edge refused this destination moments ago") - sendHttpError(output, 502, "Bad Gateway", "Destination recently refused by upstream edge") - return - } - - val session = awaitUsableSession() - if (session == null) { - sendHttpError(output, 502, "Bad Gateway", "No VPN session available") - return - } - - if (session === unauthenticatedSession) { - sendHttpError(output, 502, "Bad Gateway", "Proxy session unauthenticated") - return - } - - val tunnelResult = runCatching { - withTimeoutOrNull(OPEN_STREAM_TIMEOUT_MS) { session.openStream(targetHost, targetPort) } - } - val tunneled = tunnelResult.getOrNull() - - if (tunneled == null) { - val cause = tunnelResult.exceptionOrNull() - if (shouldRememberRefusal(cause)) rememberUnreachable(targetKey, targetPort) - when (health.observeFailure(targetKey, cause)) { - UpstreamHealthTracker.Verdict.TARGET_FAILURE -> Unit - UpstreamHealthTracker.Verdict.SESSION_UNHEALTHY -> { - AppLogger.w( - TAG, - "upstream session looks unhealthy: multiple destinations failed; asking for redial", - ) - runCatching { onSessionUnhealthy() } - } - UpstreamHealthTracker.Verdict.SESSION_UNAUTHENTICATED -> { - val alreadyLatched = session === unauthenticatedSession - unauthenticatedSession = session - if (!alreadyLatched) { - AppLogger.w( - TAG, - "the edge rejected this session's proxy pass (${cause?.message}); asking for a redial", - ) - } - runCatching { onSessionUnhealthy() } - } - } - sendHttpError(output, 504, "Gateway Timeout", "Failed to connect to $targetKey: ${cause?.message ?: "timeout"}") - return - } - - health.observeSuccess() - forgetUnreachable(targetKey) - if (unauthenticatedSession != null && session !== unauthenticatedSession) { - unauthenticatedSession = null - } - - val path = if (parsedUri?.rawPath.isNullOrBlank()) "/" else parsedUri?.rawPath - val query = if (parsedUri?.rawQuery.isNullOrBlank()) "" else "?${parsedUri?.rawQuery}" - val rewrittenRequestLine = "$method $path$query $httpVersion\r\n" - - val upstreamOut = tunneled.output - upstreamOut.write(rewrittenRequestLine.toByteArray(Charsets.US_ASCII)) - for (h in headers) { - if (!h.startsWith("Proxy-Connection:", ignoreCase = true)) { - upstreamOut.write("$h\r\n".toByteArray(Charsets.US_ASCII)) - } - } - upstreamOut.write("\r\n".toByteArray(Charsets.US_ASCII)) - upstreamOut.flush() - - socket.soTimeout = 0 - relayBidirectional(socket, input, output, tunneled) - } - - private suspend fun relayBidirectional( - socket: Socket, - input: InputStream, - output: OutputStream, - tunneled: com.vauth.foxyvpn.vpn.upstream.TunneledStream, - ) { - val clientToUpstream = scope.launch { - runCatching { input.copyTo(tunneled.output, RELAY_BUFFER_BYTES) } - runCatching { tunneled.output.close() } - } - - runCatching { tunneled.input.copyTo(output, RELAY_BUFFER_BYTES) } - runCatching { socket.shutdownOutput() } - - if (clientToUpstream.isActive) { - val drained = withTimeoutOrNull(HALF_CLOSE_DRAIN_TIMEOUT_MS) { clientToUpstream.join() } - if (drained == null) { - AppLogger.d(TAG, "closing half-closed HTTP tunnel after drain timeout") - runCatching { socket.close() } - runCatching { tunneled.close() } - clientToUpstream.join() - } - } - tunneled.close() - } - - private suspend fun awaitUsableSession(): UpstreamSession? { - val immediate = sessionProvider() - if (immediate != null && immediate.isConnected) return immediate - - val deadline = System.currentTimeMillis() + SESSION_WAIT_TIMEOUT_MS - while (System.currentTimeMillis() < deadline) { - delay(SESSION_WAIT_POLL_INTERVAL_MS) - val session = sessionProvider() - if (session != null && session.isConnected) return session - } - return null - } - - private fun readLine(input: InputStream): String? { - val baos = ByteArrayOutputStream() - while (true) { - val b = input.read() - if (b == -1) { - if (baos.size() == 0) return null - break - } - if (b == '\n'.code) { - break - } else if (b != '\r'.code) { - baos.write(b) - if (baos.size() > MAX_HEADER_SIZE_BYTES) error("HTTP header too long") - } - } - return baos.toString("US-ASCII") - } - - private fun sendHttpError(output: OutputStream, statusCode: Int, statusText: String, body: String = "") { - runCatching { - val bodyBytes = body.toByteArray(Charsets.UTF_8) - val response = "HTTP/1.1 $statusCode $statusText\r\n" + - "Content-Type: text/plain; charset=utf-8\r\n" + - "Content-Length: ${bodyBytes.size}\r\n" + - "Connection: close\r\n\r\n" - output.write(response.toByteArray(Charsets.US_ASCII)) - if (bodyBytes.isNotEmpty()) { - output.write(bodyBytes) - } - output.flush() - } - } - - private fun shouldRememberRefusal(cause: Throwable?): Boolean { - val status = (cause as? UpstreamConnectRejectedException)?.statusCode ?: return false - return status in UpstreamHealthTracker.TARGET_UNREACHABLE_STATUS_CODES - } - - private fun isKnownUnreachable(key: String): Boolean = synchronized(unreachableTargets) { - val record = unreachableTargets[key] ?: return false - return System.currentTimeMillis() < record.expiresAt - } - - private fun rememberUnreachable(key: String, targetPort: Int): Int = synchronized(unreachableTargets) { - val record = unreachableTargets.getOrPut(key) { RefusalRecord() } - record.strikes += 1 - val backoffMs = (UNREACHABLE_TARGET_TTL_MS shl (record.strikes - 1).coerceAtMost(16)) - .coerceAtMost(UNREACHABLE_TARGET_TTL_CAP_MS) - record.expiresAt = System.currentTimeMillis() + backoffMs - if (record.strikes == REFUSALS_BEFORE_TREATING_AS_POLICY) { - AppLogger.d( - TAG, - "the edge has refused $key ${record.strikes} times in a row; further attempts will be refused on-device with backoff", - ) - } - return record.strikes - } - - private fun forgetUnreachable(key: String) { - synchronized(unreachableTargets) { - if (unreachableTargets.remove(key) != null) { - AppLogger.d(TAG, "$key succeeded; clearing its refusal backoff") - } - } - } -} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/socks/LocalSocks5Server.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/socks/LocalSocks5Server.kt deleted file mode 100644 index e3faaac2..00000000 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/socks/LocalSocks5Server.kt +++ /dev/null @@ -1,627 +0,0 @@ -package com.vauth.foxyvpn.vpn.socks - -import com.vauth.foxyvpn.data.AppLogger -import com.vauth.foxyvpn.vpn.RelayDispatchers -import com.vauth.foxyvpn.vpn.tun.HevSocks5TunnelConfig -import com.vauth.foxyvpn.vpn.upstream.UpstreamConnectRejectedException -import com.vauth.foxyvpn.vpn.upstream.UpstreamConnectTimeoutException -import com.vauth.foxyvpn.vpn.upstream.UpstreamHealthTracker -import com.vauth.foxyvpn.vpn.upstream.UpstreamSession -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.withTimeoutOrNull -import java.io.DataInputStream -import java.io.EOFException -import java.io.IOException -import java.io.OutputStream -import java.net.InetAddress -import java.net.InetSocketAddress -import java.net.ServerSocket -import java.net.Socket -import java.net.SocketException -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicLong - -private const val TAG = "LocalSocks5Server" - -private const val SESSION_WAIT_TIMEOUT_MS = 4_000L -private const val SESSION_WAIT_POLL_INTERVAL_MS = 100L - -private const val OPEN_STREAM_TIMEOUT_MS = 30_000L - -private const val HANDSHAKE_TIMEOUT_MS = 10_000 - -private const val HALF_CLOSE_DRAIN_TIMEOUT_MS = 2 * 60_000L - -private const val MAX_CONCURRENT_CLIENT_CONNECTIONS = 512 - -private const val ACCEPT_BACKLOG = 256 - -private const val ACCEPT_ERROR_BACKOFF_MS = 100L - -private const val RELAY_BUFFER_BYTES = 32 * 1024 - -private const val FAILURE_SUMMARY_INTERVAL_MS = 15_000L - -private const val UNREACHABLE_TARGET_TTL_MS = 30_000L - -private const val UNREACHABLE_TARGET_TTL_CAP_MS = 10 * 60_000L - -private const val REFUSALS_BEFORE_TREATING_AS_POLICY = 3 - -private const val UNREACHABLE_TARGET_CACHE_CAP = 256 - -class LocalSocks5Server( - private val bindAddress: String, - private val port: Int, - private val rejectFakeDnsAddresses: Boolean, - private val dohUrl: String? = null, - private val sessionProvider: () -> UpstreamSession?, - private val onSessionUnhealthy: () -> Unit = {}, -) { - private var serverSocket: ServerSocket? = null - - private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - AppLogger.w( - TAG, - "unhandled exception in a SOCKS5 connection coroutine (that connection was dropped; app keeps running)", - throwable, - ) - } - private val scope = CoroutineScope(RelayDispatchers.relay + SupervisorJob() + exceptionHandler) - - private val connectionSlots = Semaphore(MAX_CONCURRENT_CLIENT_CONNECTIONS) - - private val upstreamFailures = AtomicInteger(0) - private val lastFailureSummaryAt = AtomicLong(System.currentTimeMillis()) - - private val staleFakeIpRejections = AtomicInteger(0) - - private val localNetworkRejections = AtomicInteger(0) - private val cachedRefusalRejections = AtomicInteger(0) - - private val declinedDestinations = AtomicInteger(0) - - private val sessionAuthRejections = AtomicInteger(0) - - @Volatile private var unauthenticatedSession: UpstreamSession? = null - - private val health = UpstreamHealthTracker() - - private class RefusalRecord { - - var strikes: Int = 0 - - var expiresAt: Long = 0L - } - - private val unreachableTargets = object : LinkedHashMap(64, 0.75f, false) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean = - size > UNREACHABLE_TARGET_CACHE_CAP - } - - fun start() { - - val server = ServerSocket() - - runCatching { server.reuseAddress = true } - server.bind(InetSocketAddress(InetAddress.getByName(bindAddress), port), ACCEPT_BACKLOG) - serverSocket = server - AppLogger.i(TAG, "started local SOCKS5 proxy on $bindAddress:$port") - scope.launch { - while (!server.isClosed) { - val accepted = runCatching { server.accept() } - val client = accepted.getOrNull() - if (client == null) { - - if (server.isClosed) break - AppLogger.d( - TAG, - "accept() failed on an open listening socket: " + - "${accepted.exceptionOrNull()?.message}", - ) - delay(ACCEPT_ERROR_BACKOFF_MS) - continue - } - - runCatching { client.tcpNoDelay = true } - if (!connectionSlots.tryAcquire()) { - AppLogger.w(TAG, "rejecting SOCKS5 client: connection limit ($MAX_CONCURRENT_CLIENT_CONNECTIONS) reached") - runCatching { client.close() } - continue - } - scope.launch { - try { - handleClient(client) - } finally { - connectionSlots.release() - } - } - } - } - } - - fun stop() { - runCatching { serverSocket?.close() } - AppLogger.i(TAG, "stopped local SOCKS5 proxy on $bindAddress:$port") - - DohDnsClient.shutdown() - scope.cancel() - synchronized(unreachableTargets) { unreachableTargets.clear() } - health.reset() - unauthenticatedSession = null - val stale = staleFakeIpRejections.getAndSet(0) - if (stale > 0) { - AppLogger.i( - TAG, - "$stale connection(s) were refused during this session because they targeted a " + - "synthetic DNS address with no live mapping", - ) - } - val local = localNetworkRejections.getAndSet(0) - if (local > 0) { - AppLogger.i( - TAG, - "$local connection(s) to local-network addresses were refused on-device during this " + - "session (they were never sent through the tunnel)", - ) - } - - val cached = cachedRefusalRejections.getAndSet(0) - if (cached > 0) { - AppLogger.d( - TAG, - "$cached connection(s) were refused immediately during this session because the edge had " + - "recently refused the same destination (saving one round trip each)", - ) - } - val declined = declinedDestinations.getAndSet(0) - if (declined > 0) { - AppLogger.d( - TAG, - "$declined connection(s) were declined by the edge during this session (destinations it " + - "does not proxy, such as non-web ports; an edge policy, not a tunnel fault)", - ) - } - val unauthenticated = sessionAuthRejections.getAndSet(0) - if (unauthenticated > 0) { - AppLogger.i( - TAG, - "$unauthenticated connection(s) were refused on-device during this session while the " + - "tunnel's proxy pass was being rejected by the edge (each one saved a round trip " + - "that could only have returned another 401)", - ) - } - - val failures = upstreamFailures.getAndSet(0) - if (failures > 0) { - AppLogger.w(TAG, "$failures connection(s) failed upstream before this session ended") - } - } - - private suspend fun awaitUsableSession(): UpstreamSession? { - val deadline = System.currentTimeMillis() + SESSION_WAIT_TIMEOUT_MS - while (true) { - val session = sessionProvider() - if (session != null && session.isConnected) return session - if (System.currentTimeMillis() >= deadline) return null - delay(SESSION_WAIT_POLL_INTERVAL_MS) - } - } - - private fun recordUpstreamFailure(target: String, cause: Throwable?, declinedByEdge: Boolean = false) { - AppLogger.d(TAG, "opening upstream stream failed for $target: ${cause?.message ?: "timed out"}") - - if (declinedByEdge) { - declinedDestinations.incrementAndGet() - return - } - upstreamFailures.incrementAndGet() - val now = System.currentTimeMillis() - val since = lastFailureSummaryAt.get() - if (now - since < FAILURE_SUMMARY_INTERVAL_MS) return - if (!lastFailureSummaryAt.compareAndSet(since, now)) return - val total = upstreamFailures.getAndSet(0) - if (total <= 0) return - AppLogger.w( - TAG, - "$total connection(s) failed upstream in the last ${(now - since) / 1_000}s " + - "(most recent: $target -- ${cause?.message ?: "timed out"})", - ) - } - - private suspend fun handleClient(client: Socket) { - try { - handleClientOrThrow(client) - } catch (e: EOFException) { - - AppLogger.d(TAG, "SOCKS5 client disconnected before completing its request (EOF)") - } catch (e: SocketException) { - AppLogger.d(TAG, "SOCKS5 client socket closed unexpectedly: ${e.message}") - } catch (e: IOException) { - AppLogger.d(TAG, "I/O error handling SOCKS5 client: ${e.message}") - } - } - - private suspend fun handleClientOrThrow(client: Socket) { - client.use { socket -> - - socket.soTimeout = HANDSHAKE_TIMEOUT_MS - - val input = DataInputStream(socket.getInputStream()) - val output = socket.getOutputStream() - - val ver = input.readUnsignedByte() - if (ver != 0x05) { - - AppLogger.d(TAG, "rejecting client: unsupported SOCKS version $ver in greeting") - return - } - val nMethods = input.readUnsignedByte() - - val methods = ByteArray(nMethods) - if (nMethods > 0) input.readFully(methods) - - if (methods.none { it.toInt() == 0x00 }) { - output.write(byteArrayOf(0x05, 0xFF.toByte())) - output.flush() - return - } - output.write(byteArrayOf(0x05, 0x00)) - output.flush() - - val reqVer = input.readUnsignedByte() - - if (reqVer != 0x05) { - AppLogger.d(TAG, "rejecting request: unsupported SOCKS version $reqVer") - output.write(socksReply(0x07)) - output.flush() - return - } - val cmd = input.readUnsignedByte() - input.readUnsignedByte() - - when (cmd) { - 0x01 -> handleConnect(socket, input, output) - 0x03 -> handleUdpAssociate(socket, input, output) - else -> { - output.write(socksReply(0x07)) - output.flush() - } - } - } - } - - private suspend fun handleConnect(socket: Socket, input: DataInputStream, output: OutputStream) { - val target = runCatching { readTargetAddress(input) }.getOrNull() - if (target == null) { - output.write(socksReply(0x08)) - output.flush() - return - } - - socket.soTimeout = 0 - val targetHost = target.host - val targetPort = target.port - val targetKey = "$targetHost:$targetPort" - - if (rejectFakeDnsAddresses && HevSocks5TunnelConfig.isFakeDnsAddress(targetHost)) { - staleFakeIpRejections.incrementAndGet() - AppLogger.d( - TAG, - "refusing $targetKey: synthetic DNS address with no live hostname mapping " + - "(stale cached DNS answer from before the tunnel restarted)", - ) - output.write(socksReply(0x04)) - output.flush() - return - } - - val literal = target.literal - if (literal != null && literal.isLocalNetworkDestination()) { - localNetworkRejections.incrementAndGet() - AppLogger.d(TAG, "refusing $targetKey: local-network address, not carried by the tunnel") - output.write(socksReply(0x02)) - output.flush() - return - } - - if (isKnownUnreachable(targetKey)) { - cachedRefusalRejections.incrementAndGet() - AppLogger.d(TAG, "refusing $targetKey: the edge refused this destination moments ago") - output.write(socksReply(0x04)) - output.flush() - return - } - - val session = awaitUsableSession() - if (session == null) { - output.write(socksReply(0x05)) - output.flush() - return - } - - if (session === unauthenticatedSession) { - sessionAuthRejections.incrementAndGet() - AppLogger.d( - TAG, - "refusing $targetKey: the edge is rejecting this session's proxy pass, so no destination " + - "can succeed until the tunnel is redialed", - ) - output.write(socksReply(0x01)) - output.flush() - return - } - - val tunnelResult = runCatching { - withTimeoutOrNull(OPEN_STREAM_TIMEOUT_MS) { session.openStream(targetHost, targetPort) } - } - val tunneled = tunnelResult.getOrNull() - if (tunneled == null) { - - val cause = tunnelResult.exceptionOrNull() - - val declinedByEdge = shouldRememberRefusal(cause) - if (declinedByEdge) rememberUnreachable(targetKey, targetPort) - - recordUpstreamFailure(targetKey, cause, declinedByEdge = declinedByEdge) - - when (health.observeFailure(targetKey, cause)) { - UpstreamHealthTracker.Verdict.TARGET_FAILURE -> Unit - - UpstreamHealthTracker.Verdict.SESSION_UNHEALTHY -> { - AppLogger.w( - TAG, - "upstream session looks unhealthy: several unrelated destinations failed without a " + - "single success between them, so the tunnel is the likelier cause than any one " + - "destination; asking for a redial", - ) - runCatching { onSessionUnhealthy() } - } - - UpstreamHealthTracker.Verdict.SESSION_UNAUTHENTICATED -> { - - val alreadyLatched = session === unauthenticatedSession - unauthenticatedSession = session - if (!alreadyLatched) { - AppLogger.w( - TAG, - "the edge rejected this session's proxy pass (${cause?.message}); refusing further " + - "flows on this session locally and asking for a redial with a fresh pass", - ) - } - runCatching { onSessionUnhealthy() } - } - } - - output.write(socksReply(replyCodeFor(cause))) - output.flush() - return - } - - health.observeSuccess() - - forgetUnreachable(targetKey) - - if (unauthenticatedSession != null && session !== unauthenticatedSession) { - unauthenticatedSession = null - } - - output.write(socksReply(0x00, socket.localAddress, socket.localPort)) - output.flush() - - val clientToUpstream = scope.launch { - runCatching { socket.getInputStream().copyTo(tunneled.output, RELAY_BUFFER_BYTES) } - - runCatching { tunneled.output.close() } - } - - runCatching { tunneled.input.copyTo(output, RELAY_BUFFER_BYTES) } - - runCatching { socket.shutdownOutput() } - - if (clientToUpstream.isActive) { - val drained = withTimeoutOrNull(HALF_CLOSE_DRAIN_TIMEOUT_MS) { clientToUpstream.join() } - if (drained == null) { - AppLogger.d(TAG, "closing half-closed tunnel after drain timeout=${HALF_CLOSE_DRAIN_TIMEOUT_MS}ms") - runCatching { socket.close() } - runCatching { tunneled.close() } - clientToUpstream.join() - } - } - tunneled.close() - } - - private suspend fun handleUdpAssociate(socket: Socket, input: DataInputStream, output: OutputStream) { - val target = runCatching { readTargetAddress(input) }.getOrNull() - if (target == null) { - output.write(socksReply(0x08)) - output.flush() - return - } - val requestedPort = target.port - - if (requestedPort != DNS_PORT && requestedPort != 0) { - output.write(socksReply(0x02)) - output.flush() - return - } - - val session = awaitUsableSession() - if (session == null) { - output.write(socksReply(0x05)) - output.flush() - return - } - - if (session === unauthenticatedSession) { - sessionAuthRejections.incrementAndGet() - output.write(socksReply(0x01)) - output.flush() - return - } - - val relay = runCatching { - Socks5UdpDnsRelay( - socket.localAddress, - - socket.inetAddress, - InetSocketAddress(socket.localAddress, port), - dohUrl, - sessionProvider, - ) { - - AppLogger.d( - TAG, - "a UDP association carried traffic this tunnel cannot relay; the association was kept " + - "open for DNS and the unsupported datagrams were dropped", - ) - } - }.getOrNull() - if (relay == null) { - output.write(socksReply(0x01)) - output.flush() - return - } - - try { - relay.start() - output.write(socksReply(0x00, relay.boundAddress, relay.boundPort)) - output.flush() - - socket.soTimeout = 0 - val drain = ByteArray(64) - while (true) { - val read = runCatching { input.read(drain) }.getOrDefault(-1) - if (read < 0) break - } - } finally { - relay.stop() - } - } - - private data class Socks5Target( - val host: String, - val port: Int, - val literal: InetAddress?, - ) - - private fun readTargetAddress(input: DataInputStream): Socks5Target { - val addrType = input.readUnsignedByte() - var literal: InetAddress? = null - val host = when (addrType) { - 0x01 -> { - val bytes = ByteArray(4) - input.readFully(bytes) - - literal = InetAddress.getByAddress(bytes) - bytes.joinToString(".") { (it.toInt() and 0xFF).toString() } - } - 0x03 -> { - val len = input.readUnsignedByte() - - if (len == 0) error("empty SOCKS5 domain name") - val bytes = ByteArray(len) - input.readFully(bytes) - String(bytes, Charsets.US_ASCII) - } - 0x04 -> { - val bytes = ByteArray(16) - input.readFully(bytes) - val parsed = InetAddress.getByAddress(bytes) - literal = parsed - parsed.hostAddress ?: "" - } - else -> error("unsupported SOCKS5 address type $addrType") - } - val port = input.readUnsignedShort() - return Socks5Target(host, port, literal) - } - - private fun InetAddress.isLocalNetworkDestination(): Boolean { - if (isAnyLocalAddress || - isLoopbackAddress || - isLinkLocalAddress || - isMulticastAddress || - isSiteLocalAddress - ) { - return true - } - val raw = address - - if (raw.size == 16) return (raw[0].toInt() and 0xFE) == 0xFC - - if (raw.size == 4) return raw.all { it.toInt() and 0xFF == 0xFF } - return false - } - - private fun replyCodeFor(cause: Throwable?): Int { - - if (cause == null || cause is UpstreamConnectTimeoutException) { - return 0x06 - } - val rejection = cause as? UpstreamConnectRejectedException ?: return 0x01 - val status = rejection.statusCode ?: return 0x01 - return when (status) { - - in UpstreamHealthTracker.TARGET_UNREACHABLE_STATUS_CODES -> 0x04 - - in UpstreamHealthTracker.SESSION_FATAL_STATUS_CODES -> 0x01 - else -> 0x01 - } - } - - private fun shouldRememberRefusal(cause: Throwable?): Boolean { - val status = (cause as? UpstreamConnectRejectedException)?.statusCode ?: return false - return status in UpstreamHealthTracker.TARGET_UNREACHABLE_STATUS_CODES - } - - private fun isKnownUnreachable(key: String): Boolean = synchronized(unreachableTargets) { - - val record = unreachableTargets[key] ?: return false - return System.currentTimeMillis() < record.expiresAt - } - - private fun rememberUnreachable(key: String, targetPort: Int): Int = synchronized(unreachableTargets) { - val record = unreachableTargets.getOrPut(key) { RefusalRecord() } - record.strikes += 1 - - val backoffMs = (UNREACHABLE_TARGET_TTL_MS shl (record.strikes - 1).coerceAtMost(16)) - .coerceAtMost(UNREACHABLE_TARGET_TTL_CAP_MS) - record.expiresAt = System.currentTimeMillis() + backoffMs - if (record.strikes == REFUSALS_BEFORE_TREATING_AS_POLICY) { - AppLogger.d( - TAG, - "the edge has refused $key ${record.strikes} times in a row; it does not appear to proxy " + - "port $targetPort, so further attempts will be refused on-device with an increasing " + - "backoff (up to ${UNREACHABLE_TARGET_TTL_CAP_MS / 1_000}s) instead of costing a round trip", - ) - } - return record.strikes - } - - private fun forgetUnreachable(key: String) { - synchronized(unreachableTargets) { - if (unreachableTargets.remove(key) != null) { - AppLogger.d(TAG, "$key succeeded; clearing its refusal backoff") - } - } - } - - private fun socksReply( - replyCode: Int, - boundAddress: InetAddress? = null, - boundPort: Int = 0, - ): ByteArray { - val addressBytes = boundAddress?.address ?: ByteArray(4) - val addressType = if (addressBytes.size == 16) 0x04 else 0x01 - return byteArrayOf(0x05, replyCode.toByte(), 0x00, addressType.toByte()) + - addressBytes + - byteArrayOf((boundPort ushr 8).toByte(), boundPort.toByte()) - } -} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/socks/Socks5UdpDnsRelay.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/socks/Socks5UdpDnsRelay.kt deleted file mode 100644 index 17a87bbf..00000000 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/socks/Socks5UdpDnsRelay.kt +++ /dev/null @@ -1,233 +0,0 @@ -package com.vauth.foxyvpn.vpn.socks - -import com.vauth.foxyvpn.data.AppLogger -import com.vauth.foxyvpn.vpn.RelayDispatchers -import com.vauth.foxyvpn.vpn.upstream.UpstreamSession -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import java.io.IOException -import java.net.DatagramPacket -import java.net.DatagramSocket -import java.net.InetAddress -import java.net.InetSocketAddress -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger - -private const val TAG = "Socks5UdpDnsRelay" - -internal const val DNS_PORT = 53 - -internal const val MAX_DNS_MESSAGE_BYTES = 4096 - -private const val SOCKS5_UDP_HEADER_MIN_BYTES = 10 - -private const val MAX_INFLIGHT_QUERIES = 32 - -internal class Socks5UdpDnsRelay( - private val bindAddress: InetAddress, - private val clientAddress: InetAddress, - private val socksProxy: InetSocketAddress?, - private val dohUrl: String? = null, - private val sessionProvider: () -> UpstreamSession?, - private val onUnsupportedFlow: () -> Unit = {}, -) { - - private val socket = DatagramSocket(0, bindAddress) - - private val inflight = Semaphore(MAX_INFLIGHT_QUERIES) - - private val queriesOverHttps = AtomicInteger(0) - private val nonDnsDropped = AtomicInteger(0) - private val foreignSourceDropped = AtomicInteger(0) - private val otherDropped = AtomicInteger(0) - private val unsupportedFlowReported = AtomicBoolean(false) - - private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - AppLogger.d(TAG, "dropped a DNS datagram: ${throwable.message}") - otherDropped.incrementAndGet() - } - private val scope = CoroutineScope(RelayDispatchers.relay + SupervisorJob() + exceptionHandler) - - val boundAddress: InetAddress get() = bindAddress - - val boundPort: Int get() = socket.localPort - - fun start() { - AppLogger.d(TAG, "started UDP DNS relay on $bindAddress:${socket.localPort}; upstream DoH=${dohUrl ?: ""}") - scope.launch { - val buffer = ByteArray(MAX_DNS_MESSAGE_BYTES + SOCKS5_UDP_HEADER_MIN_BYTES + 256) - while (!socket.isClosed) { - val packet = DatagramPacket(buffer, buffer.size) - try { - socket.receive(packet) - } catch (e: IOException) { - break - } - - if (packet.address != clientAddress) { - if (foreignSourceDropped.getAndIncrement() == 0) { - AppLogger.w( - TAG, - "dropping a datagram from ${packet.address}: this association belongs to " + - "$clientAddress, and relaying for anyone else would make this an open resolver", - ) - } - continue - } - - val targetPort = extractTargetPort(packet.data, packet.offset, packet.length) - if (targetPort != DNS_PORT) { - nonDnsDropped.incrementAndGet() - if (unsupportedFlowReported.compareAndSet(false, true)) { - AppLogger.d( - TAG, - "dropping a datagram for port $targetPort: this tunnel carries TCP only, so general " + - "UDP (QUIC included) cannot be relayed and the sender has to fall back to TCP. " + - "The DNS association stays up.", - ) - runCatching { onUnsupportedFlow() } - } - continue - } - - val datagram = packet.data.copyOfRange(packet.offset, packet.offset + packet.length) - val replyTo = InetSocketAddress(packet.address, packet.port) - - if (!inflight.tryAcquire()) { - otherDropped.incrementAndGet() - continue - } - scope.launch { - try { - relay(datagram, replyTo) - } finally { - inflight.release() - } - } - } - } - } - - fun stop() { - runCatching { socket.close() } - scope.cancel() - val https = queriesOverHttps.get() - val nonDns = nonDnsDropped.get() - val foreign = foreignSourceDropped.get() - val other = otherDropped.get() - - if (https > 0 || nonDns > 0 || foreign > 0 || other > 0) { - AppLogger.d( - TAG, - "UDP association closed: $https DNS queries over HTTPS, $nonDns non-DNS datagrams dropped, " + - "$foreign from unassociated sources, $other other drops", - ) - } - } - - private suspend fun relay(datagram: ByteArray, replyTo: InetSocketAddress) { - val request = parseRequest(datagram) - if (request == null) { - otherDropped.incrementAndGet() - return - } - - val query = datagram.copyOfRange(request.payloadOffset, datagram.size) - if (query.isEmpty() || query.size > MAX_DNS_MESSAGE_BYTES) { - otherDropped.incrementAndGet() - return - } - - val session = sessionProvider()?.takeIf { it.isConnected } - if (session == null) { - otherDropped.incrementAndGet() - return - } - - val answer = resolve(request.host, query) - if (answer == null) { - otherDropped.incrementAndGet() - return - } - - val response = datagram.copyOfRange(0, request.payloadOffset) + answer - runCatching { socket.send(DatagramPacket(response, response.size, replyTo)) } - .onFailure { otherDropped.incrementAndGet() } - } - - private fun resolve( - resolverHost: String, - query: ByteArray, - ): ByteArray? { - if (socksProxy == null) return null - val endpoint = dohUrl ?: DohDnsClient.endpointFor(resolverHost) - val answer = DohDnsClient.query(socksProxy, endpoint, query) - if (answer != null) { - queriesOverHttps.incrementAndGet() - return answer - } - return null - } - - private class Request(val payloadOffset: Int, val host: String, val port: Int) - - private fun parseRequest(datagram: ByteArray): Request? { - if (datagram.size < SOCKS5_UDP_HEADER_MIN_BYTES) return null - - if (datagram[0].toInt() != 0 || datagram[1].toInt() != 0) return null - - if (datagram[2].toInt() != 0) return null - - var offset = 3 - val addressType = datagram[offset++].toInt() and 0xFF - val host = when (addressType) { - 0x01 -> { - if (datagram.size < offset + 4 + 2) return null - val bytes = datagram.copyOfRange(offset, offset + 4) - offset += 4 - bytes.joinToString(".") { (it.toInt() and 0xFF).toString() } - } - 0x03 -> { - val length = datagram[offset++].toInt() and 0xFF - if (length == 0) return null - if (datagram.size < offset + length + 2) return null - val name = String(datagram, offset, length, Charsets.US_ASCII) - offset += length - name - } - 0x04 -> { - if (datagram.size < offset + 16 + 2) return null - val bytes = datagram.copyOfRange(offset, offset + 16) - offset += 16 - InetAddress.getByAddress(bytes).hostAddress ?: return null - } - else -> return null - } - val port = ((datagram[offset].toInt() and 0xFF) shl 8) or (datagram[offset + 1].toInt() and 0xFF) - offset += 2 - return Request(offset, host, port) - } - - private fun extractTargetPort(data: ByteArray, offset: Int, length: Int): Int { - if (length < SOCKS5_UDP_HEADER_MIN_BYTES) return -1 - if (data[offset].toInt() != 0 || data[offset + 1].toInt() != 0 || data[offset + 2].toInt() != 0) return -1 - var pos = offset + 3 - val addressType = data[pos++].toInt() and 0xFF - when (addressType) { - 0x01 -> pos += 4 - 0x03 -> { - if (pos >= offset + length) return -1 - val domainLen = data[pos++].toInt() and 0xFF - pos += domainLen - } - 0x04 -> pos += 16 - else -> return -1 - } - if (pos + 2 > offset + length) return -1 - return ((data[pos].toInt() and 0xFF) shl 8) or (data[pos + 1].toInt() and 0xFF) - } -} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/tun/HevSocks5Tunnel.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/tun/HevSocks5Tunnel.kt deleted file mode 100644 index a4f869c2..00000000 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/tun/HevSocks5Tunnel.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.vauth.foxyvpn.vpn.tun - -import com.vauth.foxyvpn.data.AppLogger - -private const val TAG = "HevSocks5Tunnel" - -object HevSocks5Tunnel { - private external fun TProxyStartService(config_path: String, fd: Int): Boolean - private external fun TProxyStopService(): Boolean - private external fun TProxyIsRunning(): Boolean - private external fun TProxyGetStats(): LongArray - - init { - System.loadLibrary("hev-socks5-tunnel") - } - - fun start(configPath: String, tunFd: Int): Boolean = - runCatching { TProxyStartService(configPath, tunFd) } - .onFailure { AppLogger.e(TAG, "failed to start hev-socks5-tunnel", it) } - .getOrDefault(false) - - fun stop(): Boolean = - runCatching { TProxyStopService() } - .onFailure { AppLogger.w(TAG, "failed to stop hev-socks5-tunnel", it) } - .getOrDefault(false) - - fun isRunning(): Boolean = runCatching { TProxyIsRunning() }.getOrDefault(false) - - fun stats(): LongArray = runCatching { TProxyGetStats() }.getOrDefault(LongArray(4)) -} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/tun/HevSocks5TunnelConfig.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/tun/HevSocks5TunnelConfig.kt deleted file mode 100644 index 83d9c036..00000000 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/tun/HevSocks5TunnelConfig.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.vauth.foxyvpn.vpn.tun - -import android.content.Context -import java.io.File - -object HevSocks5TunnelConfig { - const val FILE_NAME = "hev-socks5-tunnel.yaml" - - const val TUN_ADDRESS = "10.8.0.2" - - const val TUN_MTU = 1400 - - private const val TUNNEL_ICMP_MODE = "reply" - - const val MAPDNS_ADDRESS = "198.18.0.2" - private const val MAPDNS_PORT = 53 - - private const val MAPDNS_NETWORK = "100.64.0.0" - private const val MAPDNS_NETMASK = "255.192.0.0" - - private const val MAPDNS_NETWORK_BITS = 100L shl 24 or (64L shl 16) - private const val MAPDNS_NETMASK_BITS = 255L shl 24 or (192L shl 16) - - private const val MAPDNS_CACHE_SIZE = 10_000 - - private const val CONNECT_TIMEOUT_MS = 20_000 - - private const val TCP_READ_WRITE_TIMEOUT_MS = 300_000 - - private const val UDP_READ_WRITE_TIMEOUT_MS = 60_000 - - private const val TCP_BUFFER_SIZE_BYTES = 65_536 - - private const val TASK_STACK_SIZE_BYTES = 86_016 - - private const val UDP_RECV_BUFFER_SIZE_BYTES = 524_288 - - private const val UDP_COPY_BUFFER_NUMS = 16 - - private const val MAX_SESSION_COUNT = 0 - - private const val LIMIT_NOFILE = 65_535 - - fun isFakeDnsAddress(host: String): Boolean { - val parts = host.split('.') - if (parts.size != 4) return false - var packed = 0L - for (part in parts) { - if (part.isEmpty() || part.length > 3) return false - val octet = part.toIntOrNull() ?: return false - if (octet !in 0..255) return false - packed = (packed shl 8) or octet.toLong() - } - return (packed and MAPDNS_NETMASK_BITS) == MAPDNS_NETWORK_BITS - } - - fun write(context: Context, socksPort: Int, useMapDns: Boolean = true, mtu: Int = TUN_MTU): String { - - val mapdnsBlock = if (useMapDns) { - """ - mapdns: - address: '$MAPDNS_ADDRESS' - port: $MAPDNS_PORT - network: '$MAPDNS_NETWORK' - netmask: '$MAPDNS_NETMASK' - cache-size: $MAPDNS_CACHE_SIZE - """.trimIndent() + "\n" - } else { - "" - } - - val yaml = """ - tunnel: - mtu: $mtu - ipv4: '$TUN_ADDRESS' - icmp: '$TUNNEL_ICMP_MODE' - socks5: - port: $socksPort - address: '127.0.0.1' - udp: 'udp' - """.trimIndent() + "\n" + mapdnsBlock + - """ - misc: - task-stack-size: $TASK_STACK_SIZE_BYTES - connect-timeout: $CONNECT_TIMEOUT_MS - tcp-read-write-timeout: $TCP_READ_WRITE_TIMEOUT_MS - udp-read-write-timeout: $UDP_READ_WRITE_TIMEOUT_MS - tcp-buffer-size: $TCP_BUFFER_SIZE_BYTES - udp-recv-buffer-size: $UDP_RECV_BUFFER_SIZE_BYTES - udp-copy-buffer-nums: $UDP_COPY_BUFFER_NUMS - max-session-count: $MAX_SESSION_COUNT - limit-nofile: $LIMIT_NOFILE - log-level: 'warn' - """.trimIndent() + "\n" - - val file = File(context.filesDir, FILE_NAME) - file.writeText(yaml) - return file.absolutePath - } -} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/EdgeAddressResolver.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/EdgeAddressResolver.kt deleted file mode 100644 index e0d5da1e..00000000 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/EdgeAddressResolver.kt +++ /dev/null @@ -1,313 +0,0 @@ -package com.vauth.foxyvpn.vpn.upstream - -import com.vauth.foxyvpn.data.AppLogger -import com.vauth.foxyvpn.data.ControlPlaneHttp -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import okhttp3.Call -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.io.ByteArrayOutputStream -import java.io.InputStream -import java.net.InetAddress -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger - -private const val TAG = "EdgeAddressResolver" - -private const val DNS_MESSAGE_CONTENT_TYPE = "application/dns-message" -private val dnsMessageMediaType = DNS_MESSAGE_CONTENT_TYPE.toMediaType() - -private const val DOH_PATH = "/dns-query" - -private const val DNS_QUERY_ID = 0 -private const val DNS_FLAGS_RECURSION_DESIRED = 0x0100 -private const val DNS_HEADER_BYTES = 12 -private const val DNS_QUESTION_TRAILER_BYTES = 4 -private const val DNS_RECORD_HEADER_BYTES = 10 -private const val DNS_TYPE_A = 1 -private const val DNS_TYPE_AAAA = 28 -private const val DNS_CLASS_IN = 1 -private const val DNS_RCODE_MASK = 0x0F -private const val DNS_LABEL_MAX_BYTES = 63 -private const val DNS_NAME_MAX_BYTES = 255 -private const val DNS_COMPRESSION_POINTER_MASK = 0xC0 -private const val IPV4_ADDRESS_BYTES = 4 -private const val IPV6_ADDRESS_BYTES = 16 -private const val ASCII_LIMIT = 128 - -private const val CONNECT_TIMEOUT_SECONDS = 3L -private const val CALL_TIMEOUT_SECONDS = 3L - -private const val MAX_RESPONSE_BYTES = 4096 -private const val READ_CHUNK_BYTES = 2048 - -private const val CACHE_TTL_MS = 5 * 60_000L - -private const val FAILURE_BACKOFF_MS = 30 * 60_000L - -internal object EdgeAddressResolver { - - private class CacheEntry(val addresses: List, val resolvedAtMs: Long) - - private val cache = ConcurrentHashMap() - - private val suppressedUntilMs = ConcurrentHashMap() - - private val client: OkHttpClient by lazy { - ControlPlaneHttp.client.newBuilder() - .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .readTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .build() - } - - suspend fun resolve(hostname: String, endpointAddresses: List): String? { - if (endpointAddresses.isEmpty()) return null - - val host = hostname.trim().lowercase() - if (host.isEmpty()) return null - - if (host.any { it.code >= ASCII_LIMIT }) return null - - if (isAddressLiteral(host)) return null - - cache[host]?.let { cached -> - if (System.currentTimeMillis() - cached.resolvedAtMs < CACHE_TTL_MS) { - return cached.addresses.firstOrNull() - } - } - - val endpoints = endpointAddresses - .mapNotNull { endpointUrlFor(it) } - .distinct() - .filterNot { isSuppressed(it) } - if (endpoints.isEmpty()) { - - AppLogger.d(TAG, "skipping DNS-over-HTTPS for $host; every configured endpoint is in backoff") - return null - } - - val addresses = queryEndpoints(host, endpoints) - if (addresses.isEmpty()) { - AppLogger.i( - TAG, - "could not resolve $host over DNS-over-HTTPS (tried ${endpoints.size} endpoint(s)); " + - "dialling by hostname via the system resolver", - ) - return null - } - cache[host] = CacheEntry(addresses, System.currentTimeMillis()) - return addresses.first() - } - - fun invalidate() { - cache.clear() - } - - private suspend fun queryEndpoints(host: String, endpoints: List): List = coroutineScope { - val queryV4 = buildQuery(host, DNS_TYPE_A) ?: return@coroutineScope emptyList() - val queryV6 = buildQuery(host, DNS_TYPE_AAAA) ?: return@coroutineScope emptyList() - - val inFlight = ConcurrentHashMap() - - val winner = CompletableDeferred>?>() - val outstanding = AtomicInteger(endpoints.size) - - val attempts = endpoints.map { endpoint -> - launch(Dispatchers.IO) { - val addresses = queryOne(endpoint, queryV4, queryV6, inFlight) - if (addresses == null) { - - suppressedUntilMs[endpoint] = System.currentTimeMillis() + FAILURE_BACKOFF_MS - } else if (addresses.isNotEmpty()) { - suppressedUntilMs.remove(endpoint) - - winner.complete(endpoint to addresses) - return@launch - } - - if (outstanding.decrementAndGet() == 0) winner.complete(null) - } - } - - try { - val result = winner.await() - if (result == null) { - emptyList() - } else { - val (endpoint, addresses) = result - AppLogger.d( - TAG, - "resolved $host over DNS-over-HTTPS via $endpoint (${addresses.size} address(es))", - ) - addresses - } - } finally { - - inFlight.values.forEach { runCatching { it.cancel() } } - attempts.forEach { it.cancel() } - } - } - - private fun queryOne( - endpoint: String, - queryV4: ByteArray, - queryV6: ByteArray, - inFlight: ConcurrentHashMap, - ): List? { - val v4Response = runCatching { post(endpoint, queryV4, inFlight) }.getOrElse { throwable -> - AppLogger.d(TAG, "DoH endpoint $endpoint unusable: ${throwable.message}") - return null - } - val v4 = parseAddresses(v4Response, DNS_TYPE_A) - if (v4.isNotEmpty()) return v4 - - val v6Response = runCatching { post(endpoint, queryV6, inFlight) }.getOrNull() ?: return emptyList() - return parseAddresses(v6Response, DNS_TYPE_AAAA) - } - - private fun post(endpoint: String, query: ByteArray, inFlight: ConcurrentHashMap): ByteArray { - val request = Request.Builder() - .url(endpoint) - .header("Accept", DNS_MESSAGE_CONTENT_TYPE) - .post(query.toRequestBody(dnsMessageMediaType)) - .build() - val call = client.newCall(request) - inFlight[endpoint] = call - try { - call.execute().use { response -> - if (!response.isSuccessful) error("HTTP ${response.code}") - val body = response.body ?: error("DoH response had no body") - return body.byteStream().readBounded(MAX_RESPONSE_BYTES) - } - } finally { - inFlight.remove(endpoint, call) - } - } - - private fun isSuppressed(endpoint: String): Boolean = - (suppressedUntilMs[endpoint] ?: 0L) > System.currentTimeMillis() - - private fun endpointUrlFor(address: String): String? { - val host = address.trim().lowercase() - if (!isAddressLiteral(host)) return null - - val authority = if (host.contains(':')) "[$host]" else host - return "https://$authority$DOH_PATH" - } -} - -private fun buildQuery(hostname: String, type: Int): ByteArray? { - val labels = hostname.split('.').filter { it.isNotEmpty() } - if (labels.isEmpty()) return null - - var encodedNameBytes = 1 - for (label in labels) { - val bytes = label.toByteArray(Charsets.US_ASCII) - if (bytes.isEmpty() || bytes.size > DNS_LABEL_MAX_BYTES) return null - encodedNameBytes += 1 + bytes.size - } - if (encodedNameBytes > DNS_NAME_MAX_BYTES) return null - - val out = ByteArrayOutputStream(DNS_HEADER_BYTES + encodedNameBytes + DNS_QUESTION_TRAILER_BYTES) - fun writeShort(value: Int) { - out.write((value ushr 8) and 0xFF) - out.write(value and 0xFF) - } - writeShort(DNS_QUERY_ID) - writeShort(DNS_FLAGS_RECURSION_DESIRED) - writeShort(1) - writeShort(0) - writeShort(0) - writeShort(0) - for (label in labels) { - val bytes = label.toByteArray(Charsets.US_ASCII) - out.write(bytes.size) - out.write(bytes, 0, bytes.size) - } - out.write(0) - writeShort(type) - writeShort(DNS_CLASS_IN) - return out.toByteArray() -} - -private fun parseAddresses(response: ByteArray, wantType: Int): List { - if (response.size < DNS_HEADER_BYTES) return emptyList() - fun u8(index: Int): Int = response[index].toInt() and 0xFF - fun u16(index: Int): Int = (u8(index) shl 8) or u8(index + 1) - - if (u8(3) and DNS_RCODE_MASK != 0) return emptyList() - val questionCount = u16(4) - val answerCount = u16(6) - if (answerCount == 0) return emptyList() - - var offset = DNS_HEADER_BYTES - for (i in 0 until questionCount) { - offset = skipName(response, offset) ?: return emptyList() - offset += DNS_QUESTION_TRAILER_BYTES - } - - val expectedRdLength = if (wantType == DNS_TYPE_A) IPV4_ADDRESS_BYTES else IPV6_ADDRESS_BYTES - val addresses = mutableListOf() - for (i in 0 until answerCount) { - offset = skipName(response, offset) ?: return addresses - if (offset + DNS_RECORD_HEADER_BYTES > response.size) return addresses - val type = u16(offset) - val rdLength = u16(offset + 8) - offset += DNS_RECORD_HEADER_BYTES - if (offset + rdLength > response.size) return addresses - if (type == wantType && rdLength == expectedRdLength) { - - runCatching { InetAddress.getByAddress(response.copyOfRange(offset, offset + rdLength)) } - .getOrNull() - ?.hostAddress - ?.let { addresses.add(it) } - } - offset += rdLength - } - return addresses -} - -private fun skipName(message: ByteArray, start: Int): Int? { - var offset = start - var consumed = 0 - while (offset < message.size) { - val length = message[offset].toInt() and 0xFF - if (length == 0) return offset + 1 - if (length and DNS_COMPRESSION_POINTER_MASK == DNS_COMPRESSION_POINTER_MASK) { - return if (offset + 2 <= message.size) offset + 2 else null - } - if (length > DNS_LABEL_MAX_BYTES) return null - offset += 1 + length - consumed += 1 + length - if (consumed > DNS_NAME_MAX_BYTES) return null - } - return null -} - -private fun isAddressLiteral(host: String): Boolean { - if (host.contains(':')) return true - val parts = host.split('.') - if (parts.size != IPV4_ADDRESS_BYTES) return false - return parts.all { part -> - part.isNotEmpty() && part.length <= 3 && part.all { it.isDigit() } && part.toInt() <= 255 - } -} - -private fun InputStream.readBounded(limit: Int): ByteArray { - val out = ByteArrayOutputStream() - val buffer = ByteArray(READ_CHUNK_BYTES) - while (out.size() <= limit) { - val read = read(buffer) - if (read < 0) break - out.write(buffer, 0, read) - } - if (out.size() > limit) error("DoH response exceeded $limit bytes") - return out.toByteArray() -} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/H2UpstreamSession.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/H2UpstreamSession.kt deleted file mode 100644 index dd702ce6..00000000 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/H2UpstreamSession.kt +++ /dev/null @@ -1,878 +0,0 @@ -package com.vauth.foxyvpn.vpn.upstream - -import android.os.Build -import com.vauth.foxyvpn.BuildConfig -import com.vauth.foxyvpn.data.AppLogger -import com.vauth.foxyvpn.data.SettingsStore -import io.netty.bootstrap.Bootstrap -import io.netty.buffer.ByteBuf -import io.netty.channel.Channel -import io.netty.channel.ChannelFuture -import io.netty.channel.ChannelHandlerContext -import io.netty.channel.ChannelInboundHandlerAdapter -import io.netty.channel.ChannelInitializer -import io.netty.channel.ChannelOption -import io.netty.channel.ChannelOutboundHandlerAdapter -import io.netty.channel.ChannelPromise -import io.netty.channel.SimpleChannelInboundHandler -import io.netty.channel.WriteBufferWaterMark -import io.netty.channel.nio.NioEventLoopGroup -import io.netty.handler.codec.http2.DefaultHttp2DataFrame -import io.netty.handler.codec.http2.DefaultHttp2Headers -import io.netty.handler.codec.http2.DefaultHttp2HeadersFrame -import io.netty.handler.codec.http2.DefaultHttp2PingFrame -import io.netty.handler.codec.http2.DefaultHttp2WindowUpdateFrame -import io.netty.handler.codec.http2.Http2CodecUtil -import io.netty.handler.codec.http2.Http2DataFrame -import io.netty.handler.codec.http2.Http2FrameCodecBuilder -import io.netty.handler.codec.http2.Http2FrameLogger -import io.netty.handler.codec.http2.Http2GoAwayFrame -import io.netty.handler.codec.http2.Http2HeadersFrame -import io.netty.handler.codec.http2.Http2MultiplexHandler -import io.netty.handler.codec.http2.Http2PingFrame -import io.netty.handler.codec.http2.Http2ResetFrame -import io.netty.handler.codec.http2.Http2Settings -import io.netty.handler.codec.http2.Http2SettingsFrame -import io.netty.handler.codec.http2.Http2StreamChannel -import io.netty.handler.codec.http2.Http2StreamChannelBootstrap -import io.netty.handler.codec.http2.Http2StreamFrame -import io.netty.handler.logging.LogLevel -import io.netty.handler.proxy.HttpProxyHandler -import io.netty.handler.proxy.ProxyHandler -import io.netty.handler.proxy.Socks5ProxyHandler -import io.netty.handler.ssl.ApplicationProtocolConfig -import io.netty.handler.ssl.ApplicationProtocolNames -import io.netty.resolver.NoopAddressResolverGroup -import io.netty.handler.ssl.SslContext -import io.netty.handler.ssl.SslContextBuilder -import io.netty.handler.ssl.SslHandshakeCompletionEvent -import io.netty.handler.ssl.SslProvider -import io.netty.handler.ssl.SupportedCipherSuiteFilter -import io.netty.util.ReferenceCountUtil -import io.netty.util.concurrent.Future -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.withTimeoutOrNull -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.net.InetSocketAddress -import java.security.Provider -import java.security.Security -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -private const val TAG = "H2UpstreamSession" - -private const val KEEPALIVE_CHECK_INTERVAL_MS = 3_000L -private const val KEEPALIVE_IDLE_THRESHOLD_MS = 15_000L -private const val KEEPALIVE_PING_TIMEOUT_MS = 25_000L - -private const val KEEPALIVE_PING_PAYLOAD = 0x466F78795650_4EL - -private const val OPEN_STREAM_TIMEOUT_MS = 20_000L - -private const val TCP_CONNECT_TIMEOUT_MS = 15_000 - -private const val TLS_ENDPOINT_IDENTIFICATION_HTTPS = "HTTPS" - -private const val CONSCRYPT_PROVIDER_NAME = "Conscrypt" - -private val TLS_PROVIDER: Provider? = runCatching { Security.getProvider(CONSCRYPT_PROVIDER_NAME) }.getOrNull() - -private val TLS_PROTOCOLS: Array = - if (TLS_PROVIDER != null || Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - arrayOf("TLSv1.3", "TLSv1.2") - } else { - arrayOf("TLSv1.2") - } - -private const val HTTP2_FLOW_CONTROL_WINDOW_BYTES = 16 * 1024 * 1024 - -private const val HTTP2_MAX_FRAME_SIZE_BYTES = 256 * 1024 - -private val STREAM_WRITE_WATER_MARK = WriteBufferWaterMark(512 * 1024, 2 * 1024 * 1024) - -private val PARENT_WRITE_WATER_MARK = WriteBufferWaterMark(1024 * 1024, 4 * 1024 * 1024) - -private const val WRITE_BACKPRESSURE_TIMEOUT_MS = 60_000L - -private const val STREAM_SLOT_WAIT_TIMEOUT_MS = 15_000L - -private const val STREAM_SLOT_POLL_INTERVAL_MS = 20L - -private const val DRAIN_IDLE_TIMEOUT_MS = 120_000L - -private const val DRAIN_POLL_INTERVAL_MS = 250L - -data class UpstreamProxyConfig( - val type: Type, - val host: String, - val port: Int, - val username: String? = null, - val password: String? = null, -) { - enum class Type { SOCKS5, HTTP } -} - -class H2UpstreamSession( - private val upstreamHost: String, - private val upstreamPort: Int, - bearerToken: String, - private val upstreamProxy: UpstreamProxyConfig? = null, - private val edgeAddress: String? = null, - private val tlsSplitMode: SettingsStore.TlsSplitMode = SettingsStore.TlsSplitMode.OFF, -) : UpstreamSession { - - private val group = NioEventLoopGroup(1) - - @Volatile private var currentBearerToken: String = bearerToken - - private val connectHost: String = edgeAddress?.takeIf { it.isNotBlank() } ?: upstreamHost - - private val keepaliveScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private var parentChannel: Channel? = null - - @Volatile private var lastActivityAt = System.currentTimeMillis() - - @Volatile private var lastStreamDataAt = System.currentTimeMillis() - @Volatile private var awaitingPingAck = false - @Volatile private var pingSentAt = 0L - @Volatile private var acceptingNewStreams = true - private val activeStreams = AtomicInteger(0) - - @Volatile private var maxConcurrentStreams = Int.MAX_VALUE - - private val closed = AtomicBoolean(false) - - @Volatile private var closing = false - - private val drainWatcherArmed = AtomicBoolean(false) - - override val isConnected: Boolean - get() = parentChannel?.isActive == true && acceptingNewStreams - - override fun updateBearerToken(token: String) { - if (token.isBlank() || token == currentBearerToken) return - currentBearerToken = token - AppLogger.i( - TAG, - "proxy pass swapped into the live session; the existing HTTP/2 tunnel keeps running and " + - "subsequent flows authenticate with the new pass", - ) - } - - override fun disableNewStreamsAndCloseWhenIdle() { - acceptingNewStreams = false - if (activeStreams.get() == 0) { - close() - return - } - - if (!drainWatcherArmed.compareAndSet(false, true)) return - - lastStreamDataAt = System.currentTimeMillis() - keepaliveScope.launch { - - while (activeStreams.get() > 0 && - System.currentTimeMillis() - lastStreamDataAt < DRAIN_IDLE_TIMEOUT_MS - ) { - delay(DRAIN_POLL_INTERVAL_MS) - } - val stranded = activeStreams.get() - if (stranded > 0) { - AppLogger.w( - TAG, - "a swapped-out upstream session has moved no data for " + - "${DRAIN_IDLE_TIMEOUT_MS / 1_000}s with $stranded flow(s) still open; closing it so its " + - "socket and event loop are not held for the lifetime of the tunnel", - ) - } - close() - } - } - - private fun noteInboundActivity() { - lastActivityAt = System.currentTimeMillis() - if (awaitingPingAck) awaitingPingAck = false - } - - private fun noteStreamData() { - val now = System.currentTimeMillis() - lastActivityAt = now - - lastStreamDataAt = now - } - - override suspend fun connect() = withContext(Dispatchers.IO) { - - val sslContextBuilder = SslContextBuilder.forClient() - .sslProvider(SslProvider.JDK) - .protocols(*TLS_PROTOCOLS) - .applicationProtocolConfig( - ApplicationProtocolConfig( - ApplicationProtocolConfig.Protocol.ALPN, - - ApplicationProtocolConfig.SelectorFailureBehavior.FATAL_ALERT, - ApplicationProtocolConfig.SelectedListenerFailureBehavior.FATAL_ALERT, - ApplicationProtocolNames.HTTP_2, - ), - ) - .ciphers(null, SupportedCipherSuiteFilter.INSTANCE) - - val tlsProvider = TLS_PROVIDER - if (tlsProvider != null) { - sslContextBuilder.sslContextProvider(tlsProvider) - } else { - AppLogger.w( - TAG, - "Conscrypt is unavailable; falling back to the platform TLS stack " + - "(protocols=${TLS_PROTOCOLS.joinToString()})", - ) - } - - UpstreamTrust.trustManagerFactory()?.let { sslContextBuilder.trustManager(it) } - val sslContext: SslContext = sslContextBuilder.build() - - if (connectHost != upstreamHost) { - AppLogger.i( - TAG, - "custom edge address in use: TCP to $connectHost:$upstreamPort, still presenting and " + - "verifying \"$upstreamHost\"", - ) - } - - val handshakeDone = CompletableDeferred() - val bootstrap = Bootstrap() - .group(group) - .channel(FoxyNioSocketChannel::class.java) - .apply { - if (upstreamProxy != null) resolver(NoopAddressResolverGroup.INSTANCE) - } - - .option(ChannelOption.TCP_NODELAY, true) - - .option(ChannelOption.SO_KEEPALIVE, true) - .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, TCP_CONNECT_TIMEOUT_MS) - .option(ChannelOption.WRITE_BUFFER_WATER_MARK, PARENT_WRITE_WATER_MARK) - .handler(object : ChannelInitializer() { - override fun initChannel(ch: FoxyNioSocketChannel) { - runCatching { - com.vauth.foxyvpn.data.ControlPlaneHttp.socketProtector?.invoke(ch.publicJavaChannel().socket()) - }.onFailure { - com.vauth.foxyvpn.data.AppLogger.e(TAG, "failed to protect netty socket", it) - } - - upstreamProxy?.let { proxy -> ch.pipeline().addLast(buildProxyHandler(proxy)) } - - if (tlsSplitMode != SettingsStore.TlsSplitMode.OFF) { - ch.pipeline().addLast("tlsSplit", TlsSplitHandler(tlsSplitMode)) - } - - val sslHandler = sslContext.newHandler(ch.alloc(), upstreamHost, upstreamPort) - - val engine = sslHandler.engine() - engine.sslParameters = engine.sslParameters.apply { - endpointIdentificationAlgorithm = TLS_ENDPOINT_IDENTIFICATION_HTTPS - } - - val frameCodecBuilder = Http2FrameCodecBuilder.forClient() - - .initialSettings( - Http2Settings.defaultSettings() - .initialWindowSize(HTTP2_FLOW_CONTROL_WINDOW_BYTES) - .maxFrameSize(HTTP2_MAX_FRAME_SIZE_BYTES) - .pushEnabled(false), - ) - - .autoAckPingFrame(false) - if (BuildConfig.DEBUG) { - - frameCodecBuilder.frameLogger(Http2FrameLogger(LogLevel.DEBUG)) - } - val frameCodec = frameCodecBuilder.build() - ch.pipeline().addLast(sslHandler) - ch.pipeline().addLast(frameCodec) - - ch.pipeline().addLast(Http2MultiplexHandler(RejectPushStreamHandler())) - - ch.pipeline().addLast(object : ChannelInboundHandlerAdapter() { - override fun channelRead(ctx: ChannelHandlerContext, msg: Any) { - noteInboundActivity() - when (msg) { - is Http2GoAwayFrame -> { - acceptingNewStreams = false - val reason = msg.content().toString(Charsets.UTF_8).ifBlank { "" } - AppLogger.w( - TAG, - "upstream sent GOAWAY: errorCode=${msg.errorCode()} lastStreamId=${msg.lastStreamId()} reason=\"$reason\"", - ) - msg.release() - } - is Http2SettingsFrame -> { - - val announced = msg.settings().maxConcurrentStreams() - if (announced != null) { - - val limit = announced.coerceIn(1L, Int.MAX_VALUE.toLong()).toInt() - if (limit != maxConcurrentStreams) { - maxConcurrentStreams = limit - AppLogger.i( - TAG, - "upstream allows $limit concurrent stream(s); new flows will wait " + - "for a free slot instead of being refused", - ) - } - } - } - is Http2PingFrame -> { - - if (msg.ack()) { - if (msg.content() == KEEPALIVE_PING_PAYLOAD) awaitingPingAck = false - } else { - ctx.writeAndFlush(DefaultHttp2PingFrame(msg.content(), true)) - } - } - else -> { - - ReferenceCountUtil.release(msg) - } - } - } - - override fun channelInactive(ctx: ChannelHandlerContext) { - acceptingNewStreams = false - val detail = "upstream channel to $upstreamHost:$upstreamPort became inactive " + - "(local=${ctx.channel().localAddress()})" - - if (closing) AppLogger.d(TAG, detail) else AppLogger.w(TAG, detail) - if (!handshakeDone.isCompleted) { - handshakeDone.completeExceptionally( - IOException("Upstream connection closed before completing the TLS/ALPN handshake"), - ) - } - super.channelInactive(ctx) - } - - override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) { - - if (closing || handshakeDone.isCompleted) { - - AppLogger.w( - TAG, - "upstream connection to $upstreamHost:$upstreamPort dropped: ${cause.message} " + - "(usually the network changed underneath it)", - ) - } else { - AppLogger.e( - TAG, - "upstream channel error to $upstreamHost:$upstreamPort (often a TLS/ALPN handshake or, " + - "if an upstream proxy is configured, a proxy CONNECT failure)", - cause, - ) - } - if (!handshakeDone.isCompleted) handshakeDone.completeExceptionally(cause) - ctx.close() - } - - override fun userEventTriggered(ctx: ChannelHandlerContext, evt: Any) { - if (evt is SslHandshakeCompletionEvent) { - if (evt.isSuccess) { - val negotiated = sslHandler.applicationProtocol() - AppLogger.i( - TAG, - "TLS handshake to $connectHost:$upstreamPort complete, " + - "verified name=$upstreamHost, negotiated ALPN protocol=$negotiated, " + - "local=${ctx.channel().localAddress()}", - ) - if (negotiated == ApplicationProtocolNames.HTTP_2) { - handshakeDone.complete(Unit) - } else { - val err = IllegalStateException( - "Upstream did not negotiate HTTP/2 over ALPN (got '$negotiated')", - ) - handshakeDone.completeExceptionally(err) - ctx.close() - } - } else { - val cause = evt.cause() ?: IllegalStateException("TLS handshake failed with no cause") - AppLogger.e(TAG, "TLS/ALPN handshake to $connectHost:$upstreamPort failed", cause) - handshakeDone.completeExceptionally(cause) - } - } - super.userEventTriggered(ctx, evt) - } - }) - } - }) - - parentChannel = if (upstreamProxy != null) { - bootstrap.connect(InetSocketAddress.createUnresolved(connectHost, upstreamPort)).awaitChannel() - } else { - bootstrap.connect(connectHost, upstreamPort).awaitChannel() - } - handshakeDone.await() - lastActivityAt = System.currentTimeMillis() - lastStreamDataAt = System.currentTimeMillis() - - parentChannel?.writeAndFlush( - DefaultHttp2WindowUpdateFrame(HTTP2_FLOW_CONTROL_WINDOW_BYTES - Http2CodecUtil.DEFAULT_WINDOW_SIZE), - ) - startKeepalive() - } - - private fun buildProxyHandler(proxy: UpstreamProxyConfig): ProxyHandler { - val proxyAddress = InetSocketAddress(proxy.host, proxy.port) - val hasCredentials = !proxy.username.isNullOrBlank() - AppLogger.i( - TAG, - "chaining upstream connection to $connectHost:$upstreamPort through ${proxy.type} proxy $proxyAddress", - ) - return when (proxy.type) { - UpstreamProxyConfig.Type.SOCKS5 -> if (hasCredentials) { - Socks5ProxyHandler(proxyAddress, proxy.username, proxy.password.orEmpty()) - } else { - Socks5ProxyHandler(proxyAddress) - } - UpstreamProxyConfig.Type.HTTP -> if (hasCredentials) { - HttpProxyHandler(proxyAddress, proxy.username, proxy.password.orEmpty()) - } else { - HttpProxyHandler(proxyAddress) - } - } - } - - private fun startKeepalive() { - keepaliveScope.launch { - - while (isActive && parentChannel?.isActive == true && !closed.get()) { - delay(KEEPALIVE_CHECK_INTERVAL_MS) - val now = System.currentTimeMillis() - val idleFor = now - lastActivityAt - val ch = parentChannel ?: break - when { - - awaitingPingAck && now - pingSentAt >= KEEPALIVE_PING_TIMEOUT_MS -> { - AppLogger.w( - TAG, - "keepalive PING went unanswered for ${now - pingSentAt}ms; closing dead upstream session", - ) - close() - break - } - !awaitingPingAck && idleFor >= KEEPALIVE_IDLE_THRESHOLD_MS -> { - awaitingPingAck = true - pingSentAt = now - ch.writeAndFlush(DefaultHttp2PingFrame(KEEPALIVE_PING_PAYLOAD, false)) - } - } - } - } - } - - private fun releaseStreamSlot() { - if (activeStreams.decrementAndGet() == 0 && !acceptingNewStreams) { - close() - } - } - - private fun tryReserveStreamSlot(): Boolean { - while (true) { - val inFlight = activeStreams.get() - if (inFlight >= maxConcurrentStreams) return false - - if (activeStreams.compareAndSet(inFlight, inFlight + 1)) return true - } - } - - private suspend fun reserveStreamSlot(authority: String) { - if (tryReserveStreamSlot()) return - - val acquired = withTimeoutOrNull(STREAM_SLOT_WAIT_TIMEOUT_MS) { - while (!tryReserveStreamSlot()) { - if (!isConnected) { - throw IOException("H2UpstreamSession closed while waiting for a free stream slot") - } - delay(STREAM_SLOT_POLL_INTERVAL_MS) - } - true - } - if (acquired == null) { - throw UpstreamConnectTimeoutException( - authority = authority, - message = "Timed out waiting for one of the upstream's $maxConcurrentStreams concurrent " + - "stream slots to free up", - cause = IOException("upstream concurrent stream limit reached"), - ) - } - } - - override suspend fun openStream(targetHost: String, targetPort: Int): TunneledStream { - - val parent = parentChannel ?: throw IOException("H2UpstreamSession is not connected") - if (!parent.isActive) throw IOException("H2UpstreamSession channel is not active") - if (!acceptingNewStreams) throw IOException("H2UpstreamSession is shutting down (GOAWAY received)") - - val authority = if (targetHost.contains(":")) "[$targetHost]:$targetPort" else "$targetHost:$targetPort" - val responseHeaders = CompletableDeferred() - val streamInput = NettyStreamInput() - - reserveStreamSlot(authority) - - var handedOff = false - try { - val streamChannel = run { - val streamChannelFuture = Http2StreamChannelBootstrap(parent) - - .option(ChannelOption.WRITE_BUFFER_WATER_MARK, STREAM_WRITE_WATER_MARK) - .handler(object : SimpleChannelInboundHandler() { - override fun channelActive(ctx: ChannelHandlerContext) { - streamInput.attachChannel(ctx.channel() as Http2StreamChannel) - super.channelActive(ctx) - } - - override fun channelRead0(ctx: ChannelHandlerContext, frame: Http2StreamFrame) { - noteInboundActivity() - when (frame) { - is Http2HeadersFrame -> { - - val statusText = frame.headers().status()?.toString() - - val statusCode = statusText?.trim()?.toIntOrNull() - if (statusCode != null && statusCode in 200..299) { - if (!responseHeaders.isCompleted) responseHeaders.complete(Unit) - } else { - val cause = UpstreamConnectRejectedException( - statusCode = statusCode, - authority = authority, - message = "Upstream rejected CONNECT to $authority: status=${statusText ?: ""}", - ) - if (!responseHeaders.isCompleted) responseHeaders.completeExceptionally(cause) - ctx.close() - } - } - is Http2DataFrame -> { - - noteStreamData() - val bytes = ByteArray(frame.content().readableBytes()) - frame.content().readBytes(bytes) - streamInput.offerData(bytes) - if (frame.isEndStream) streamInput.offerEnd() - } - is Http2ResetFrame -> { - val streamId = (ctx.channel() as Http2StreamChannel).stream().id() - val cause = IOException("Upstream reset stream $streamId (errorCode=${frame.errorCode()})") - AppLogger.d(TAG, "upstream reset stream $streamId: errorCode=${frame.errorCode()}") - if (!responseHeaders.isCompleted) responseHeaders.completeExceptionally(cause) - streamInput.offerError(cause) - } - else -> Unit - } - } - - override fun channelInactive(ctx: ChannelHandlerContext) { - - if (!responseHeaders.isCompleted) { - responseHeaders.completeExceptionally( - IOException("Upstream stream closed before CONNECT completed"), - ) - } - streamInput.offerEnd() - super.channelInactive(ctx) - } - }) - .open() - - val ch = try { - withTimeout(OPEN_STREAM_TIMEOUT_MS) { awaitStreamChannel(streamChannelFuture) } - } catch (e: TimeoutCancellationException) { - - throw UpstreamConnectTimeoutException( - authority = authority, - message = "Timed out opening HTTP/2 stream channel to $upstreamHost:$upstreamPort", - cause = e, - ) - } - - val headers = DefaultHttp2Headers().apply { - method("CONNECT") - authority(authority) - - add("proxy-authorization", "Bearer $currentBearerToken") - } - ch.writeAndFlush(DefaultHttp2HeadersFrame(headers, false)) - - try { - withTimeout(OPEN_STREAM_TIMEOUT_MS) { responseHeaders.await() } - } catch (e: TimeoutCancellationException) { - ch.close() - throw UpstreamConnectTimeoutException( - authority = authority, - message = "Timed out waiting for CONNECT response from $upstreamHost:$upstreamPort ($authority)", - cause = e, - ) - } - - ch - } - - streamChannel.closeFuture().addListener { - releaseStreamSlot() - } - handedOff = true - - return TunneledStream( - input = streamInput, - output = NettyStreamOutput(streamChannel), - close = { streamChannel.close() }, - ) - } finally { - - if (!handedOff) releaseStreamSlot() - } - } - - override fun close() { - - if (!closed.compareAndSet(false, true)) return - closing = true - keepaliveScope.cancel() - parentChannel?.close() - - group.shutdownGracefully(0, 1, TimeUnit.SECONDS) - } - - private class RejectPushStreamHandler : ChannelInitializer() { - override fun initChannel(ch: Http2StreamChannel) { - AppLogger.w(TAG, "upstream opened an unexpected server-initiated stream ${ch.stream().id()}; closing it") - ch.close() - } - } - - private inner class NettyStreamOutput( - private val streamChannel: Http2StreamChannel, - ) : OutputStream() { - override fun write(b: Int) = write(byteArrayOf(b.toByte())) - - override fun write(b: ByteArray, off: Int, len: Int) { - if (len <= 0) return - if (!streamChannel.isActive) throw IOException("Upstream stream is closed") - - noteStreamData() - val buf = streamChannel.alloc().buffer(len).writeBytes(b, off, len) - val future = streamChannel.writeAndFlush(DefaultHttp2DataFrame(buf, false)) - - if (!streamChannel.isWritable) { - if (!future.await(WRITE_BACKPRESSURE_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { - future.cancel(false) - throw IOException("Upstream stream stopped accepting data (write timed out)") - } - if (!future.isSuccess) { - throw IOException("Failed writing to the upstream stream", future.cause()) - } - } else if (future.isDone && !future.isSuccess) { - - throw IOException("Failed writing to the upstream stream", future.cause()) - } - } - - override fun close() { - - if (streamChannel.isActive) streamChannel.writeAndFlush(DefaultHttp2DataFrame(true)) - } - } - - private class NettyStreamInput : InputStream() { - private sealed class Chunk { - class Data(val bytes: ByteArray) : Chunk() - object End : Chunk() - class Error(val cause: Throwable) : Chunk() - } - - private val queue = LinkedBlockingQueue() - private val pendingBytes = AtomicInteger(0) - private var current: ByteArray? = null - private var currentPos = 0 - - private var pendingTerminal: Chunk? = null - @Volatile private var finished = false - @Volatile private var channel: Http2StreamChannel? = null - - fun attachChannel(ch: Http2StreamChannel) { - channel = ch - } - - fun offerData(bytes: ByteArray) { - if (bytes.isEmpty()) return - queue.put(Chunk.Data(bytes)) - if (pendingBytes.addAndGet(bytes.size) >= HIGH_WATERMARK_BYTES) { - channel?.config()?.isAutoRead = false - } - } - - fun offerEnd() = queue.put(Chunk.End) - - fun offerError(cause: Throwable) = queue.put(Chunk.Error(cause)) - - override fun read(): Int { - val single = ByteArray(1) - val n = read(single, 0, 1) - return if (n <= 0) -1 else single[0].toInt() and 0xFF - } - - override fun read(b: ByteArray, off: Int, len: Int): Int { - if (len == 0) return 0 - if (finished && current == null) return -1 - var totalRead = 0 - while (totalRead < len) { - if (current == null || currentPos >= current!!.size) { - val chunk = pendingTerminal?.also { pendingTerminal = null } - ?: if (totalRead == 0) queue.take() else (queue.poll() ?: break) - when (chunk) { - is Chunk.Data -> { - current = chunk.bytes - currentPos = 0 - val remaining = pendingBytes.addAndGet(-chunk.bytes.size) - val ch = channel - if (ch != null && remaining <= LOW_WATERMARK_BYTES) { - ch.eventLoop().execute { ch.config().isAutoRead = true } - } - } - is Chunk.End -> { - if (totalRead > 0) { - - pendingTerminal = Chunk.End - return totalRead - } - finished = true - return -1 - } - is Chunk.Error -> { - if (totalRead > 0) { - pendingTerminal = chunk - return totalRead - } - finished = true - throw IOException("Upstream stream failed", chunk.cause) - } - } - } - val data = current ?: break - val available = data.size - currentPos - val toCopy = minOf(len - totalRead, available) - System.arraycopy(data, currentPos, b, off + totalRead, toCopy) - currentPos += toCopy - totalRead += toCopy - if (currentPos >= data.size) current = null - } - return totalRead - } - - companion object { - - private const val HIGH_WATERMARK_BYTES = 128 * 1024 - private const val LOW_WATERMARK_BYTES = 32 * 1024 - } - } -} - -private suspend fun awaitStreamChannel(future: Future): Http2StreamChannel = - suspendCancellableCoroutine { cont -> - future.addListener { - if (future.isSuccess) { - cont.resume(future.now) - } else { - cont.resumeWithException(future.cause() ?: IllegalStateException("Netty future failed with no cause")) - } - } - cont.invokeOnCancellation { future.cancel(false) } - } - -private suspend fun ChannelFuture.awaitChannel(): Channel = - suspendCancellableCoroutine { cont -> - addListener { completed -> - if (completed.isSuccess) { - cont.resume(channel()) - } else { - cont.resumeWithException( - completed.cause() ?: IOException("Upstream TCP connect failed with no cause"), - ) - } - } - cont.invokeOnCancellation { - runCatching { channel().close() } - } - } - -@androidx.annotation.Keep -internal class FoxyNioSocketChannel : io.netty.channel.socket.nio.NioSocketChannel() { - fun publicJavaChannel(): java.nio.channels.SocketChannel = super.javaChannel() as java.nio.channels.SocketChannel -} - -private class TlsSplitHandler( - private val mode: SettingsStore.TlsSplitMode, -) : ChannelOutboundHandlerAdapter() { - private var splitDone = false - - override fun write( - ctx: ChannelHandlerContext, - msg: Any, - promise: ChannelPromise, - ) { - if (splitDone || msg !is ByteBuf) { - super.write(ctx, msg, promise) - return - } - - if (!TlsClientHelloSplitter.looksLikeClientHello(msg)) { - super.write(ctx, msg, promise) - return - } - - splitDone = true - val segments = try { - TlsClientHelloSplitter.split(msg, mode) - } catch (e: Throwable) { - AppLogger.w(TAG, "failed to split TLS ClientHello, falling back to direct write", e) - null - } - - if (segments.isNullOrEmpty()) { - super.write(ctx, msg, promise) - return - } - - AppLogger.i(TAG, "TLS ClientHello fragmented ($mode) into ${segments.size} TCP segment(s)") - val total = segments.size - for (i in segments.indices) { - val seg = segments[i] - if (i == total - 1) { - ctx.writeAndFlush(seg, promise) - } else { - val subPromise = ctx.newPromise() - subPromise.addListener { f -> - if (!f.isSuccess) { - promise.tryFailure(f.cause()) - } - } - ctx.writeAndFlush(seg, subPromise) - } - } - ReferenceCountUtil.release(msg) - } -} - diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/NettyLoggingBridge.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/NettyLoggingBridge.kt deleted file mode 100644 index 71a73438..00000000 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/NettyLoggingBridge.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.vauth.foxyvpn.vpn.upstream - -import com.vauth.foxyvpn.data.AppLogger -import io.netty.util.internal.logging.AbstractInternalLogger -import io.netty.util.internal.logging.InternalLogger -import io.netty.util.internal.logging.InternalLoggerFactory - -object NettyLoggingBridge { - private const val TAG = "Netty" - - private val BEARER_TOKEN_PATTERN = Regex("Bearer\\s+[A-Za-z0-9._-]+") - - private fun redactSecrets(message: String): String = - if (message.contains("Bearer ", ignoreCase = true)) { - BEARER_TOKEN_PATTERN.replace(message, "Bearer ") - } else { - message - } - - fun install() { - InternalLoggerFactory.setDefaultFactory(Factory) - } - - private object Factory : InternalLoggerFactory() { - override fun newInstance(name: String): InternalLogger = BridgedLogger(name) - } - - private class BridgedLogger(loggerName: String) : AbstractInternalLogger(loggerName) { - private val shortTag = "$TAG/${loggerName.substringAfterLast('.')}" - - override fun isTraceEnabled() = false - override fun isDebugEnabled() = false - override fun isInfoEnabled() = false - override fun isWarnEnabled() = true - override fun isErrorEnabled() = true - - override fun trace(msg: String) = AppLogger.d(shortTag, redactSecrets(msg)) - override fun trace(format: String, arg: Any?) = AppLogger.d(shortTag, redactSecrets(format(format, arg))) - override fun trace(format: String, argA: Any?, argB: Any?) = AppLogger.d(shortTag, redactSecrets(format(format, argA, argB))) - override fun trace(format: String, vararg arguments: Any?) = AppLogger.d(shortTag, redactSecrets(format(format, *arguments))) - override fun trace(msg: String, t: Throwable?) = AppLogger.d(shortTag, redactSecrets(withCause(msg, t))) - - override fun debug(msg: String) = AppLogger.d(shortTag, redactSecrets(msg)) - override fun debug(format: String, arg: Any?) = AppLogger.d(shortTag, redactSecrets(format(format, arg))) - override fun debug(format: String, argA: Any?, argB: Any?) = AppLogger.d(shortTag, redactSecrets(format(format, argA, argB))) - override fun debug(format: String, vararg arguments: Any?) = AppLogger.d(shortTag, redactSecrets(format(format, *arguments))) - override fun debug(msg: String, t: Throwable?) = AppLogger.d(shortTag, redactSecrets(withCause(msg, t))) - - override fun info(msg: String) = AppLogger.d(shortTag, redactSecrets(msg)) - override fun info(format: String, arg: Any?) = AppLogger.d(shortTag, redactSecrets(format(format, arg))) - override fun info(format: String, argA: Any?, argB: Any?) = AppLogger.d(shortTag, redactSecrets(format(format, argA, argB))) - override fun info(format: String, vararg arguments: Any?) = AppLogger.d(shortTag, redactSecrets(format(format, *arguments))) - override fun info(msg: String, t: Throwable?) = AppLogger.d(shortTag, redactSecrets(withCause(msg, t))) - - override fun warn(msg: String) = AppLogger.w(shortTag, redactSecrets(msg)) - override fun warn(format: String, arg: Any?) = AppLogger.w(shortTag, redactSecrets(format(format, arg))) - override fun warn(format: String, argA: Any?, argB: Any?) = AppLogger.w(shortTag, redactSecrets(format(format, argA, argB))) - override fun warn(format: String, vararg arguments: Any?) = AppLogger.w(shortTag, redactSecrets(format(format, *arguments))) - override fun warn(msg: String, t: Throwable?) = AppLogger.w(shortTag, redactSecrets(msg), t) - - override fun error(msg: String) = AppLogger.e(shortTag, redactSecrets(msg)) - override fun error(format: String, arg: Any?) = AppLogger.e(shortTag, redactSecrets(format(format, arg))) - override fun error(format: String, argA: Any?, argB: Any?) = AppLogger.e(shortTag, redactSecrets(format(format, argA, argB))) - override fun error(format: String, vararg arguments: Any?) = AppLogger.e(shortTag, redactSecrets(format(format, *arguments))) - override fun error(msg: String, t: Throwable?) = AppLogger.e(shortTag, redactSecrets(msg), t) - - private fun withCause(msg: String, t: Throwable?): String = - if (t != null) "$msg: ${t.message}" else msg - - private fun format(fmt: String, vararg args: Any?): String { - if (args.isEmpty()) return fmt - val builder = StringBuilder(fmt.length + 16) - var argIndex = 0 - var i = 0 - while (i < fmt.length) { - if (i < fmt.length - 1 && fmt[i] == '{' && fmt[i + 1] == '}' && argIndex < args.size) { - builder.append(args[argIndex]) - argIndex++ - i += 2 - } else { - builder.append(fmt[i]) - i++ - } - } - return builder.toString() - } - } -} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/TlsClientHelloSplitter.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/TlsClientHelloSplitter.kt deleted file mode 100644 index a570969a..00000000 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/TlsClientHelloSplitter.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.vauth.foxyvpn.vpn.upstream - -import com.vauth.foxyvpn.data.SettingsStore -import io.netty.buffer.ByteBuf - -object TlsClientHelloSplitter { - - fun looksLikeClientHello(buf: ByteBuf): Boolean { - if (buf.readableBytes() < 6) return false - val rIdx = buf.readerIndex() - return buf.getByte(rIdx) == 0x16.toByte() && - buf.getByte(rIdx + 1) == 0x03.toByte() && - buf.getByte(rIdx + 5) == 0x01.toByte() - } - - fun looksLikeClientHello(b: ByteArray, off: Int = 0, len: Int = b.size): Boolean { - if (len < 6 || off + len > b.size) return false - return b[off] == 0x16.toByte() && - b[off + 1] == 0x03.toByte() && - b[off + 5] == 0x01.toByte() - } - - fun findSniOffset(b: ByteArray): Pair? { - if (b.size < 44 || b[0] != 0x16.toByte()) { - return null - } - var curr = 5 - if (b[curr] != 0x01.toByte()) { - return null - } - curr += 4 - curr += 2 + 32 - if (curr >= b.size) return null - - val sessionIdLen = u8(b[curr]) - curr += 1 + sessionIdLen - if (curr + 2 > b.size) return null - - val cipherSuitesLen = u16(b[curr], b[curr + 1]) - curr += 2 + cipherSuitesLen - if (curr >= b.size) return null - - val compressionMethodsLen = u8(b[curr]) - curr += 1 + compressionMethodsLen - if (curr + 2 > b.size) return null - - val extensionsLen = u16(b[curr], b[curr + 1]) - var extEnd = curr + 2 + extensionsLen - curr += 2 - if (extEnd > b.size) { - extEnd = b.size - } - - while (curr + 4 <= extEnd) { - val extType = u16(b[curr], b[curr + 1]) - val extLen = u16(b[curr + 2], b[curr + 3]) - val body = curr + 4 - if (body + extLen > extEnd) { - return null - } - if (extType == 0) { - if (extLen < 2) return null - val listLen = u16(b[body], b[body + 1]) - val list = body + 2 - if (list + listLen > body + extLen) return null - if (listLen < 3 || b[list] != 0.toByte()) return null - val nameLen = u16(b[list + 1], b[list + 2]) - val start = list + 3 - val end = start + nameLen - if (end > list + listLen || end > b.size) return null - return Pair(start, end) - } - curr = body + extLen - } - return null - } - - fun split(buf: ByteBuf, mode: SettingsStore.TlsSplitMode): List? { - val totalLen = buf.readableBytes() - val rIdx = buf.readerIndex() - - return when (mode) { - SettingsStore.TlsSplitMode.OFF -> null - SettingsStore.TlsSplitMode.FIRST_BYTE -> { - if (totalLen < 2) return null - listOf( - buf.retainedSlice(rIdx, 1), - buf.retainedSlice(rIdx + 1, totalLen - 1), - ) - } - SettingsStore.TlsSplitMode.SNI -> { - val bytes = ByteArray(totalLen) - buf.getBytes(rIdx, bytes) - val offset = findSniOffset(bytes) ?: return null - val start = offset.first - if (start <= 0 || start >= totalLen) return null - listOf( - buf.retainedSlice(rIdx, start), - buf.retainedSlice(rIdx + start, 1), - buf.retainedSlice(rIdx + start + 1, totalLen - start - 1), - ) - } - } - } - - fun splitBytes(b: ByteArray, off: Int = 0, len: Int = b.size, mode: SettingsStore.TlsSplitMode): List? { - if (len < 2 || off + len > b.size) return null - return when (mode) { - SettingsStore.TlsSplitMode.OFF -> null - SettingsStore.TlsSplitMode.FIRST_BYTE -> { - listOf( - b.copyOfRange(off, off + 1), - b.copyOfRange(off + 1, off + len), - ) - } - SettingsStore.TlsSplitMode.SNI -> { - val slice = if (off == 0 && len == b.size) b else b.copyOfRange(off, off + len) - val offset = findSniOffset(slice) ?: return null - val start = offset.first - if (start <= 0 || start >= len) return null - listOf( - b.copyOfRange(off, off + start), - b.copyOfRange(off + start, off + start + 1), - b.copyOfRange(off + start + 1, off + len), - ) - } - } - } - - private fun u8(b: Byte): Int = b.toInt() and 0xFF - - private fun u16(b1: Byte, b2: Byte): Int = - ((b1.toInt() and 0xFF) shl 8) or (b2.toInt() and 0xFF) -} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/UpstreamHealthTracker.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/UpstreamHealthTracker.kt deleted file mode 100644 index a19d43bb..00000000 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/UpstreamHealthTracker.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.vauth.foxyvpn.vpn.upstream - -import java.io.InterruptedIOException -import java.net.SocketTimeoutException - -class UpstreamHealthTracker { - - enum class Verdict { - - TARGET_FAILURE, - - SESSION_UNHEALTHY, - - SESSION_UNAUTHENTICATED, - } - - private val timeoutTargets = LinkedHashSet() - - @Synchronized - fun observeSuccess() { - timeoutTargets.clear() - } - - @Synchronized - fun reset() { - timeoutTargets.clear() - } - - @Synchronized - fun observeFailure(target: String, cause: Throwable?): Verdict { - - if (invalidatesSession(cause)) { - timeoutTargets.clear() - return Verdict.SESSION_UNAUTHENTICATED - } - - if (!isSilence(cause)) return Verdict.TARGET_FAILURE - - timeoutTargets.add(target) - if (timeoutTargets.size < MAX_DISTINCT_TIMEOUT_TARGETS) return Verdict.TARGET_FAILURE - - timeoutTargets.clear() - return Verdict.SESSION_UNHEALTHY - } - - private fun isSilence(cause: Throwable?): Boolean = when (cause) { - null -> true - is UpstreamConnectTimeoutException -> true - is SocketTimeoutException -> true - is InterruptedIOException -> true - else -> false - } - - private fun invalidatesSession(cause: Throwable?): Boolean { - val rejection = cause as? UpstreamConnectRejectedException ?: return false - return rejection.statusCode in SESSION_FATAL_STATUS_CODES - } - - companion object { - - const val MAX_DISTINCT_TIMEOUT_TARGETS = 10 - - val SESSION_FATAL_STATUS_CODES = setOf(401, 403, 407) - - val TARGET_UNREACHABLE_STATUS_CODES = setOf(502, 503, 504) - } -} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/UpstreamSession.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/UpstreamSession.kt deleted file mode 100644 index af581698..00000000 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/UpstreamSession.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.vauth.foxyvpn.vpn.upstream - -import java.io.Closeable -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream - -interface UpstreamSession : Closeable { - - suspend fun connect() - - suspend fun openStream(targetHost: String, targetPort: Int): TunneledStream - - fun disableNewStreamsAndCloseWhenIdle() - - fun updateBearerToken(token: String) - - val isConnected: Boolean -} - -data class TunneledStream( - val input: InputStream, - val output: OutputStream, - val close: () -> Unit, -) - -class UpstreamConnectRejectedException( - val statusCode: Int?, - val authority: String, - message: String, -) : IOException(message) - -class UpstreamConnectTimeoutException( - val authority: String, - message: String, - cause: Throwable? = null, -) : IOException(message, cause) diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/UpstreamTrust.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/UpstreamTrust.kt deleted file mode 100644 index fe674d8d..00000000 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/upstream/UpstreamTrust.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.vauth.foxyvpn.vpn.upstream - -import android.content.Context -import com.vauth.foxyvpn.R -import com.vauth.foxyvpn.data.AppLogger -import java.io.ByteArrayInputStream -import java.security.KeyStore -import java.security.cert.CertificateFactory -import java.security.cert.X509Certificate -import javax.net.ssl.TrustManagerFactory - -private const val TAG = "UpstreamTrust" - -private val PEM_BLOCK = Regex("-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", RegexOption.DOT_MATCHES_ALL) - -object UpstreamTrust { - - @Volatile private var appContext: Context? = null - @Volatile private var cached: TrustManagerFactory? = null - @Volatile private var built = false - private val lock = Any() - - fun init(context: Context) { - appContext = context.applicationContext - } - - fun trustManagerFactory(): TrustManagerFactory? { - if (built) return cached - synchronized(lock) { - if (!built) { - cached = runCatching { build() } - .onFailure { AppLogger.e(TAG, "could not build the merged trust store; using the system default", it) } - .getOrNull() - built = true - } - return cached - } - } - - private fun build(): TrustManagerFactory? { - val merged = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) } - - var systemCount = 0 - runCatching { - val system = KeyStore.getInstance("AndroidCAStore").apply { load(null, null) } - for (alias in system.aliases()) { - val cert = system.getCertificate(alias) as? X509Certificate ?: continue - merged.setCertificateEntry("system:$alias", cert) - systemCount++ - } - }.onFailure { - AppLogger.w(TAG, "could not read the system CA store; relying on the bundled roots only", it) - } - - var bundledCount = 0 - val context = appContext - if (context == null) { - AppLogger.w(TAG, "no application context; the bundled roots were not loaded") - } else { - runCatching { - val pem = context.resources.openRawResource(R.raw.globalsign_roots).use { it.readBytes() } - .toString(Charsets.US_ASCII) - val factory = CertificateFactory.getInstance("X.509") - for (block in PEM_BLOCK.findAll(pem)) { - runCatching { - val cert = factory.generateCertificate( - ByteArrayInputStream(block.value.toByteArray(Charsets.US_ASCII)), - ) as X509Certificate - merged.setCertificateEntry("bundled:$bundledCount", cert) - bundledCount++ - } - } - }.onFailure { - AppLogger.w(TAG, "could not load the bundled GlobalSign roots", it) - } - } - - if (systemCount == 0 && bundledCount == 0) return null - - AppLogger.i(TAG, "upstream TLS trust: $systemCount system root(s) + $bundledCount bundled root(s)") - return TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply { init(merged) } - } -} diff --git a/app/src/main/java/com/vauth/foxyvpn/widget/QuickConnectWidgetProvider.kt b/app/src/main/java/com/vauth/foxyvpn/widget/QuickConnectWidgetProvider.kt index d2b9ef69..3f182c0e 100644 --- a/app/src/main/java/com/vauth/foxyvpn/widget/QuickConnectWidgetProvider.kt +++ b/app/src/main/java/com/vauth/foxyvpn/widget/QuickConnectWidgetProvider.kt @@ -13,14 +13,22 @@ import com.vauth.foxyvpn.FoxyVpnApp import com.vauth.foxyvpn.MainActivity import com.vauth.foxyvpn.R import com.vauth.foxyvpn.data.SettingsStore +import com.vauth.foxyvpn.data.ThemeMode import com.vauth.foxyvpn.data.TokenStore import com.vauth.foxyvpn.data.model.ConnectionState -import com.vauth.foxyvpn.ui.theme.ThemeMode import com.vauth.foxyvpn.vpn.FoxyVpnService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class QuickConnectWidgetProvider : AppWidgetProvider() { - override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { for (appWidgetId in appWidgetIds) { updateWidget(context, appWidgetManager, appWidgetId, FoxyVpnService.state.value) } @@ -29,19 +37,40 @@ class QuickConnectWidgetProvider : AppWidgetProvider() { override fun onReceive(context: Context, intent: Intent) { super.onReceive(context, intent) if (intent.action == ACTION_TOGGLE_VPN) { - handleToggle(context) + if (FoxyVpnService.state.value != ConnectionState.DISCONNECTED) { + FoxyVpnService.stop(context) + return + } + val pending = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + val app = context.applicationContext as? FoxyVpnApp + val tokenStore = app?.tokenStore ?: TokenStore(context) + val signedIn = tokenStore.hasStoredSession() + withContext(Dispatchers.Main) { handleToggle(context, signedIn) } + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (error: Exception) { + com.vauth.foxyvpn.data.AppLogger.w( + "QuickConnectWidget", + "Quick connect failed", + error, + ) + withContext(Dispatchers.Main) { launchMainActivity(context) } + } finally { + pending.finish() + } + } } } - private fun handleToggle(context: Context) { + private fun handleToggle(context: Context, signedIn: Boolean) { when (FoxyVpnService.state.value) { - ConnectionState.CONNECTED, ConnectionState.CONNECTING -> { - FoxyVpnService.stop(context) - } + ConnectionState.CONNECTED, + ConnectionState.CONNECTING -> return ConnectionState.DISCONNECTED -> { val app = context.applicationContext as? FoxyVpnApp - val tokenStore = app?.tokenStore ?: TokenStore(context) - if (!tokenStore.hasStoredSession()) { + if (!signedIn) { launchMainActivity(context) return } @@ -63,9 +92,10 @@ class QuickConnectWidgetProvider : AppWidgetProvider() { } private fun launchMainActivity(context: Context) { - val intent = Intent(context, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - } + val intent = + Intent(context, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } context.startActivity(intent) } @@ -75,7 +105,9 @@ class QuickConnectWidgetProvider : AppWidgetProvider() { fun updateAll(context: Context, state: ConnectionState) { val appWidgetManager = AppWidgetManager.getInstance(context) ?: return val componentName = ComponentName(context, QuickConnectWidgetProvider::class.java) - val appWidgetIds = runCatching { appWidgetManager.getAppWidgetIds(componentName) }.getOrNull() ?: return + val appWidgetIds = + runCatching { appWidgetManager.getAppWidgetIds(componentName) }.getOrNull() + ?: return if (appWidgetIds.isEmpty()) return for (appWidgetId in appWidgetIds) { updateWidget(context, appWidgetManager, appWidgetId, state) @@ -90,42 +122,56 @@ class QuickConnectWidgetProvider : AppWidgetProvider() { ) { val views = RemoteViews(context.packageName, R.layout.widget_quick_connect) - val toggleIntent = Intent(context, QuickConnectWidgetProvider::class.java).apply { - action = ACTION_TOGGLE_VPN - } - val pendingIntent = PendingIntent.getBroadcast( - context, - 0, - toggleIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) + val toggleIntent = + Intent(context, QuickConnectWidgetProvider::class.java).apply { + action = ACTION_TOGGLE_VPN + } + val pendingIntent = + PendingIntent.getBroadcast( + context, + 0, + toggleIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) views.setOnClickPendingIntent(R.id.widget_root, pendingIntent) views.setOnClickPendingIntent(R.id.widget_button, pendingIntent) - val settingsStore = (context.applicationContext as? FoxyVpnApp)?.settingsStore ?: SettingsStore(context) - val systemDark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES - val isDark = when (settingsStore.themeMode) { - ThemeMode.SYSTEM -> systemDark - ThemeMode.LIGHT -> false - ThemeMode.DARK -> true - } + val settingsStore = + (context.applicationContext as? FoxyVpnApp)?.settingsStore ?: SettingsStore(context) + val systemDark = + (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == + Configuration.UI_MODE_NIGHT_YES + val isDark = + when (settingsStore.themeMode) { + ThemeMode.SYSTEM -> systemDark + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + } - val bgRes = when (state) { - ConnectionState.CONNECTED, ConnectionState.DISCONNECTED -> - if (isDark) R.drawable.widget_bg_disconnected_dark else R.drawable.widget_bg_disconnected_light - ConnectionState.CONNECTING -> - if (isDark) R.drawable.widget_bg_connecting_dark else R.drawable.widget_bg_connecting_light - } + val bgRes = + when (state) { + ConnectionState.CONNECTED, + ConnectionState.DISCONNECTED -> + if (isDark) R.drawable.widget_bg_disconnected_dark + else R.drawable.widget_bg_disconnected_light + ConnectionState.CONNECTING -> + if (isDark) R.drawable.widget_bg_connecting_dark + else R.drawable.widget_bg_connecting_light + } - val iconColor = when (state) { - ConnectionState.CONNECTED -> if (isDark) 0xFF7BDF92.toInt() else 0xFF1B6E2F.toInt() - ConnectionState.CONNECTING -> if (isDark) 0xFFFFC46B.toInt() else 0xFF8A5A00.toInt() - ConnectionState.DISCONNECTED -> if (isDark) 0xFFE9E0E4.toInt() else 0xFF1E1A1D.toInt() - } + val iconColor = + when (state) { + ConnectionState.CONNECTED -> + if (isDark) 0xFF7BDF92.toInt() else 0xFF1B6E2F.toInt() + ConnectionState.CONNECTING -> + if (isDark) 0xFFFFC46B.toInt() else 0xFF8A5A00.toInt() + ConnectionState.DISCONNECTED -> + if (isDark) 0xFFE9E0E4.toInt() else 0xFF1E1A1D.toInt() + } views.setImageViewResource(R.id.widget_background, bgRes) - views.setImageViewResource(R.id.widget_button, android.R.drawable.ic_lock_lock) + views.setImageViewResource(R.id.widget_button, R.drawable.ic_power) views.setInt(R.id.widget_button, "setColorFilter", iconColor) appWidgetManager.updateAppWidget(appWidgetId, views) } diff --git a/app/src/main/res/drawable/ic_power.xml b/app/src/main/res/drawable/ic_power.xml new file mode 100644 index 00000000..8b136cd6 --- /dev/null +++ b/app/src/main/res/drawable/ic_power.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_preview.xml b/app/src/main/res/drawable/widget_preview.xml index d432cfe7..d7d17543 100644 --- a/app/src/main/res/drawable/widget_preview.xml +++ b/app/src/main/res/drawable/widget_preview.xml @@ -8,7 +8,7 @@ diff --git a/app/src/main/res/raw/globalsign_roots.pem b/app/src/main/res/raw/globalsign_roots.pem deleted file mode 100644 index 58f77025..00000000 --- a/app/src/main/res/raw/globalsign_roots.pem +++ /dev/null @@ -1,44 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFWjCCA0KgAwIBAgISEdK7udcjGJ5AXwqdLdDfJWfRMA0GCSqGSIb3DQEBDAUA -MEYxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYD -VQQDExNHbG9iYWxTaWduIFJvb3QgUjQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMy -MDAwMDAwMFowRjELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYt -c2ExHDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEB -AQUAA4ICDwAwggIKAoICAQCsrHQy6LNl5brtQyYdpokNRbopiLKkHWPd08EsCVeJ -OaFV6Wc0dwxu5FUdUiXSE2te4R2pt32JMl8Nnp8semNgQB+msLZ4j5lUlghYruQG -vGIFAha/r6gjA7aUD7xubMLL1aa7DOn2wQL7Id5m3RerdELv8HQvJfTqa1VbkNud -316HCkD7rRlr+/fKYIje2sGP1q7Vf9Q8g+7XFkyDRTNrJ9CG0Bwta/OrffGFqfUo -0q3v84RLHIf8E6M6cqJaESvWJ3En7YEtbWaBkoe0G1h6zD8K+kZPTXhc+CtI4wSE -y132tGqzZfxCnlEmIyDLPRT5ge1lFgBPGmSXZgjPjHvjK8Cd+RTyG/FWaha/LIWF -zXg4mutCagI0GIMXTpRW+LaCtfOW3T3zvn8gdz57GSNrLNRyc0NXfeD412lPFzYE -+cCQYDdF3uYM2HSNrpyibXRdQr4G9dlkbgIQrImwTDsHTUB+JMWKmIJ5jqSngiCN -I/onccnfxkF0oE32kRbcRoxfKWMxWXEM2G/CtjJ9++ZdU6Z+Ffy7dXxd7Pj2Fxzs -x2sZy/N78CsHpdlseVR2bJ0cpm4O6XkMqCNqo98bMDGfsVR7/mrLZqrcZdCinkqa -ByFrgY/bxFn63iLABJzjqls2k+g9vXqhnQt2sQvHnf3PmKgGwvgqo6GDoLclcqUC -4wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV -HQ4EFgQUA1yrc4GHqMywptWU4jaWSf8FmSwwDQYJKoZIhvcNAQEMBQADggIBAHx4 -7PYCLLtbfpIrXTncvtgdokIzTfnvpCo7RGkerNlFo048p9gkUbJUHJNOxO97k4Vg -JuoJSOD1u8fpaNK7ajFxzHmuEajwmf3lH7wvqMxX63bEIaZHU1VNaL8FpO7XJqti -2kM3S+LGteWygxk6x9PbTZ4IevPuzz5i+6zoYMzRx6Fcg0XERczzF2sUyQQCPtIk -pnnpHs6i58FZFZ8d4kuaPp92CC1r2LpXFNqD6v6MVenQTqnMdzGxRBF6XLE+0xRF -FRhiJBPSy03OXIPBNvIQtQ6IbbjhVp+J3pZmOUdkLG5NrmJ7v2B0GbhWrJKsFjLt -rWhV/pi60zTe9Mlhww6G9kuEYO4Ne7UyWHmRVSyBQ7N0H3qqJZ4d16GLuc1CLgSk -ZoNNiTW2bKg2SnkheCLQQrzRQDGQob4Ez8pn7fXwgNNgyYMqIgXQBztSvwyeqiv5 -u+YfjyW6hY0XHgL+XVAEV8/+LbzvXMAaq7afJMbfc2hIkCwU9D9SGuTSyxTDYWnP -4vkYxboznxSjBF25cfe1lNj2M8FawTSLfJvdkzrnE6JwYZ+vj+vYxXX4M2bUdGc6 -N3ec592kD3ZDZopD8p/7DEJ4Y9HiD2971KE9dJeFt0g5QdYg/NA6s/rob8SKunE3 -vouXsXgxT7PntgMTzlSdriVZzH81Xwj3QEUxeCp6 ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIICCzCCAZGgAwIBAgISEdK7ujNu1LzmJGjFDYQdmOhDMAoGCCqGSM49BAMDMEYx -CzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYDVQQD -ExNHbG9iYWxTaWduIFJvb3QgRTQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMyMDAw -MDAwMFowRjELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2Ex -HDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBFNDYwdjAQBgcqhkjOPQIBBgUrgQQA -IgNiAAScDrHPt+ieUnd1NPqlRqetMhkytAepJ8qUuwzSChDH2omwlwxwEwkBjtjq -R+q+soArzfwoDdusvKSGN+1wCAB16pMLey5SnCNoIwZD7JIvU4Tb+0cUB+hflGdd -yXqBPCCjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud -DgQWBBQxCpCPtsad0kRLgLWi5h+xEk8blTAKBggqhkjOPQQDAwNoADBlAjEA31SQ -7Zvvi5QCkxeCmb6zniz2C5GMn0oUsfZkvLtoURMMA/cVi4RguYv/Uo7njLwcAjA8 -+RHUjE7AwWHCFUyqqx0LMV87HOIAl0Qx5v5zli/altP+CAezNIm8BZ/3Hobui3A= ------END CERTIFICATE----- diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d2d20645..c91f4d91 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,4 +5,188 @@ Connecting\u2026 Disconnected FoxyVPN Quick Connect Widget + Proxy + Local proxy + Upstream proxy + HTTP / SOCKS5 · %1$s:%2$d + Local address: %1$s + HTTP / SOCKS5 port + The same port supports HTTP and SOCKS5. Controller port %1$d is reserved. + Authentication + These credentials protect both HTTP and SOCKS5. Both fields are required. + Proxy host + Proxy port + HTTP/3 needs UDP forwarding support from the upstream. Use HTTP/2 with an ordinary HTTP CONNECT proxy. + HTTP/3 requires SOCKS5 UDP forwarding. HTTP/2 uses a TCP connection through the upstream. + Leave both fields empty for no authentication. SOCKS5 requires both fields when authentication is used. + Username + Password + + Save + Cancel + Retry + No browser is available to open this link + Loading… + + %1$d stream + %1$d streams + + Settings + Always-on VPN + Configure always-on and block without VPN in Android + Latency test URL + Tunnel MTU + DNS provider + Proxy-only mode + Run local proxy servers without VPN + Custom edge address + Transport protocol + TLS fragmentation + Browser TLS fingerprint + Multiplex concurrency + TCP Fast Open + Reduce connection latency on supported networks + IPv6 tunnel + Tunnel health + Automatic connection recovery + Reconnect after repeated confirmed proxy-path failures; stay in the selected country. Applies on next connection. + View logs + Recent connection activity + Version + Check for updates + Signed releases from GitHub + Automatic update checks + Check once a day when the app is opened; installation always needs approval + Source code + Sign out + DoH URL + https://example.com/dns-query + HTTPS only; credentials, query parameters and fragments are not supported + Host or IP + MTU + Reset default + Fallback delay (ms) + Times an HTTPS response through the active proxy. Use a small endpoint returning HTTP 2xx; redirects fail. This is not ICMP ping. + HTTPS URL + Reset + Split tunneling + Clear selection + Search apps + No apps are available to select. + No apps match this search. + Select at least one app. + Android block-without-VPN may block apps outside the VPN list. + Proxy type + Proxy address + Proxy credentials + Username and password + Route traffic through another proxy + Upstream TLS has been removed. Review the proxy address before connecting. No credentials have been reset. + Not set + Bypass VPN for local network + Access private and link-local IP addresses directly; internet traffic stays in the VPN. Applies on next connection. Android block-without-VPN can block this access. + Logs copied + Could not copy logs. Try sharing them as a file. + Show all system apps + Could not load installed apps. + Include selected apps + Exclude selected apps + Only selected apps use the VPN. + Selected apps bypass the VPN. + + %1$d selected + %1$d selected + + FoxyVPN needs Android\'s VPN permission to tunnel this device\'s traffic. Proxy-only mode in Settings works without it. + Next connection: %1$s + Latency + Pinging… + Recommended + Back + Connection + DNS + Split tunneling + Advanced + Anti-Censorship & Performance + Connection health + About + Included apps + Excluded apps + None + + %1$d app + %1$d apps + + Disabled + IPv4 only + Dual-stack IPv4 and IPv6 + Not connected + Checking HTTPS through the proxy + HTTPS proxy path is responding + Waiting for a validated internet connection + Probe endpoints are unreachable through the proxy + + Choose location + Recommended + Automatically chosen for the best performance + No available servers in %1$s + No available servers for %1$s + No servers available + %1$s (+%2$d more) + %1$d ms + Unavailable + Checking… + Failed to load server list + + Logs (%1$d) + Logs (%1$d/%2$d) + All + Info + Problems + No log entries yet. + No warnings or errors. Nothing has gone wrong. + Nothing matches this filter. + Save or send FoxyVPN logs + Could not share logs. Try again. + Save/share logs as a file + Copy logs + Clear logs + + DNS server used inside the tunnel. + Direct via local network + HTTPS DoH endpoint + Leave empty to resolve the server hostname normally. + HTTP protocol version used to tunnel traffic. + Maximum packet size for the tunnel interface. + Fragments TLS packets to bypass censorship. + Emulates browser TLS signatures to bypass censorship. + Maximum concurrent streams per connection. + + App update + Check for a new signed release. + Checking for updates… + No newer compatible update is available. + Release %1$s is available. Download to verify its version, integrity and signing identity. + Downloading update: %1$d / %2$d MB + Version %1$s is verified and ready. Android will ask you to approve installation. If asked, allow updates from FoxyVPN, return here and tap Install again. + Download + Install + Check + Check again + Close + Android could not open the installer + + Review the proxy address before saving the configuration without TLS. + Enter credentials valid for the selected proxy type, or clear both fields. + + Sign in with Firefox + FoxyVPN runs on the free 50 GB of monthly VPN traffic Mozilla includes with a Firefox account. No subscription is required. + Sign in with Firefox + Opens the official Mozilla sign-in page in an in-app browser and returns automatically once you are signed in. + You will return to FoxyVPN automatically after signing in + + Theme: follows the system. Tap for light mode + Theme: follows the system. Tap for dark mode + Theme: light. Tap for dark mode, long press to follow the system + Theme: dark. Tap for light mode, long press to follow the system diff --git a/app/src/main/res/xml-v31/quick_connect_widget_info.xml b/app/src/main/res/xml-v31/quick_connect_widget_info.xml new file mode 100644 index 00000000..f88a5fab --- /dev/null +++ b/app/src/main/res/xml-v31/quick_connect_widget_info.xml @@ -0,0 +1,14 @@ + + + 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 00000000..b853a8d2 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 297ca921..3db8c420 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -1,4 +1,5 @@ + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..5ade7336 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + 127.0.0.1 + localhost + + diff --git a/app/src/main/res/xml/quick_connect_widget_info.xml b/app/src/main/res/xml/quick_connect_widget_info.xml index f88a5fab..34d5e905 100644 --- a/app/src/main/res/xml/quick_connect_widget_info.xml +++ b/app/src/main/res/xml/quick_connect_widget_info.xml @@ -2,11 +2,8 @@