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
@@ -9,37 +9,46 @@



+

-
-*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.*

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