From 4bf516356b00add3e2807ab960898d23737327ef Mon Sep 17 00:00:00 2001
From: Vauth <122077080+Vauth@users.noreply.github.com>
Date: Thu, 5 Nov 2026 12:47:21 +0330
Subject: [PATCH] v2.0.1
---
.github/workflows/android-ci.yml | 4 +-
README.md | 31 +-
app/build.gradle.kts | 4 +-
app/src/main/AndroidManifest.xml | 1 -
.../main/java/com/vauth/foxyvpn/FoxyVpnApp.kt | 23 +-
.../java/com/vauth/foxyvpn/MainActivity.kt | 2 -
.../java/com/vauth/foxyvpn/data/AppLinks.kt | 1 -
.../vauth/foxyvpn/data/ControlPlaneHttp.kt | 144 +++++-
.../foxyvpn/data/ProxySettingsRepository.kt | 7 +-
.../com/vauth/foxyvpn/data/SettingsStore.kt | 110 +++-
.../com/vauth/foxyvpn/data/TunnelSettings.kt | 14 +-
.../foxyvpn/ui/navigation/FoxyNavGraph.kt | 8 +-
.../foxyvpn/ui/screens/SettingsScreen.kt | 155 +++++-
.../foxyvpn/ui/settings/ConnectionDialogs.kt | 63 ++-
.../ui/settings/ProxySettingsDialogs.kt | 46 +-
.../com/vauth/foxyvpn/update/AppUpdater.kt | 489 +++++++-----------
.../com/vauth/foxyvpn/update/UpdatePolicy.kt | 145 ------
.../java/com/vauth/foxyvpn/update/UpdateUi.kt | 144 ------
.../foxyvpn/vpn/FoxyPlatformInterface.kt | 9 +-
.../com/vauth/foxyvpn/vpn/FoxyVpnService.kt | 9 +-
.../vauth/foxyvpn/vpn/ListenerAvailability.kt | 70 ++-
.../vauth/foxyvpn/vpn/SingBoxConfigBuilder.kt | 185 +++++--
.../res/drawable/ic_notification_update.xml | 10 +
app/src/main/res/values/strings.xml | 42 +-
app/src/main/res/xml/file_paths.xml | 1 -
25 files changed, 945 insertions(+), 772 deletions(-)
delete mode 100644 app/src/main/java/com/vauth/foxyvpn/update/UpdatePolicy.kt
delete mode 100644 app/src/main/java/com/vauth/foxyvpn/update/UpdateUi.kt
create mode 100644 app/src/main/res/drawable/ic_notification_update.xml
diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml
index 53fcf50b..3f55f143 100644
--- a/.github/workflows/android-ci.yml
+++ b/.github/workflows/android-ci.yml
@@ -14,8 +14,8 @@ concurrency:
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
+ LIBBOX_URL: https://github.com/Vauth/sing-box/releases/download/v1.0.0-masque/libbox.aar
+ LIBBOX_SHA256: a6a28c7f3cb53f4251d1578f4772f79831395503fdbf91920c6c1e33cc80281c
jobs:
build:
diff --git a/README.md b/README.md
index 356a1de9..8c4d6831 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@


-*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.*
+*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/1.1, HTTP/2, and HTTP/3 support.*
@@ -22,28 +22,31 @@
### Core & Privacy
- **Firefox Account Integration**
-- **System-Wide Tunneling**
-- **Split Tunneling**
-- **DNS-over-HTTPS (DoH)**
+- **System-Wide VPN Tunneling**
+- **Split Tunneling (Per-App Selection)**
+- **IP Protocol Modes (Dual-Stack, Prefer IPv4/IPv6, IPv4/IPv6-Only)**
+- **Local HTTP / SOCKS5 Proxy Server & LAN Sharing**
+- **Custom & Bootstrap DNS-over-HTTPS (DoH)**
- **Local Network Bypass**
- **Proxy-Only Mode**
### Performance & Censorship Resistance
-- **TLS Fragmentation**
+- **MASQUE over HTTP/1.1, HTTP/2, and HTTP/3 (QUIC)**
+- **TLS Fragmentation (Record & Packet)**
- **uTLS Fingerprint Emulation**
- **Upstream Proxy Chaining**
- **Custom Edge Override**
-- **TCP Fast Open (TFO)**
-- **Multiplexing Controls**
+- **Multiplexing & Concurrency Controls**
- **Custom Tunnel MTU**
+- **Automatic Connection Recovery**
### User Experience & System Integration
-- **Real-Time Diagnostics**
+- **Real-Time Traffic & Latency Diagnostics**
- **Public & Exit IP Detection**
-- **Global Locations**
-- **Quick Settings Tile & Widget**
-- **In-App Auto-Updates**
-- **Diagnostic Logging**
+- **Global Server Locations**
+- **Quick Settings Tile & Home Screen Widget**
+- **Release Update Checks & Notifications**
+- **Diagnostic Logging & Export**
@@ -86,8 +89,8 @@
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
+ curl -L -o app/libs/libbox.aar https://github.com/Vauth/sing-box/releases/download/v1.0.0-masque/libbox.aar
+ echo "a6a28c7f3cb53f4251d1578f4772f79831395503fdbf91920c6c1e33cc80281c app/libs/libbox.aar" | sha256sum --check --strict
```
3. Build the APKs:
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 3bccbe7e..da4c6bac 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -23,8 +23,8 @@ android {
applicationId = "com.vauth.foxyvpn"
minSdk = 26
targetSdk = 35
- versionCode = 46
- versionName = "2.0.0"
+ versionCode = 47
+ versionName = "2.0.1"
}
splits {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e9584b6e..b227e217 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,7 +6,6 @@
-
diff --git a/app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt b/app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt
index 7c19e9e6..ee1688a2 100644
--- a/app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt
+++ b/app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt
@@ -4,6 +4,7 @@ import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.res.Configuration
+import com.vauth.foxyvpn.data.ControlPlaneHttp
import com.vauth.foxyvpn.data.CrashReporter
import com.vauth.foxyvpn.data.FxaAuthRepository
import com.vauth.foxyvpn.data.GuardianClient
@@ -50,20 +51,33 @@ class FoxyVpnApp : Application(), AppContainer {
tokenStore = TokenStore(this)
proxyStateStore = ProxyStateStore(this)
settingsStore = SettingsStore(this)
+ ControlPlaneHttp.bootstrapDnsConfigProvider = {
+ settingsStore.bootstrapDnsProvider to settingsStore.effectiveBootstrapDohUrl
+ }
guardianClient = GuardianClient()
serverListClient = ServerListClient(this)
authRepository = FxaAuthRepository(tokenStore)
- createNotificationChannel()
+ createNotificationChannels()
}
- private fun createNotificationChannel() {
- val channel =
+ private fun createNotificationChannels() {
+ val manager = getSystemService(NotificationManager::class.java) ?: return
+ val vpnChannel =
NotificationChannel(
VPN_NOTIFICATION_CHANNEL_ID,
"VPN status",
NotificationManager.IMPORTANCE_LOW,
)
- getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
+ val updateChannel =
+ NotificationChannel(
+ UPDATE_NOTIFICATION_CHANNEL_ID,
+ getString(R.string.update_notification_channel_name),
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ).apply {
+ description = getString(R.string.update_notification_channel_desc)
+ }
+ manager.createNotificationChannel(vpnChannel)
+ manager.createNotificationChannel(updateChannel)
}
override fun onConfigurationChanged(newConfig: Configuration) {
@@ -73,5 +87,6 @@ class FoxyVpnApp : Application(), AppContainer {
companion object {
const val VPN_NOTIFICATION_CHANNEL_ID = "foxyvpn_status"
+ const val UPDATE_NOTIFICATION_CHANNEL_ID = "foxyvpn_updates"
}
}
diff --git a/app/src/main/java/com/vauth/foxyvpn/MainActivity.kt b/app/src/main/java/com/vauth/foxyvpn/MainActivity.kt
index af9a9e82..a2ecdbd3 100644
--- a/app/src/main/java/com/vauth/foxyvpn/MainActivity.kt
+++ b/app/src/main/java/com/vauth/foxyvpn/MainActivity.kt
@@ -17,7 +17,6 @@ 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() {
@@ -80,7 +79,6 @@ class MainActivity : ComponentActivity() {
FoxyVpnTheme(themeMode = themeController.mode) {
Surface(modifier = Modifier.fillMaxSize()) {
val navController = rememberNavController()
- UpdatePrompt(app.appUpdater)
FoxyNavGraph(
navController = navController,
container = app,
diff --git a/app/src/main/java/com/vauth/foxyvpn/data/AppLinks.kt b/app/src/main/java/com/vauth/foxyvpn/data/AppLinks.kt
index f3d86cb7..6045937f 100644
--- a/app/src/main/java/com/vauth/foxyvpn/data/AppLinks.kt
+++ b/app/src/main/java/com/vauth/foxyvpn/data/AppLinks.kt
@@ -4,5 +4,4 @@ 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/ControlPlaneHttp.kt b/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt
index 6c22d020..0f333572 100644
--- a/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt
+++ b/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt
@@ -2,18 +2,25 @@ package com.vauth.foxyvpn.data
import android.net.Network
import android.webkit.CookieManager
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.DataInputStream
+import java.io.DataOutputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
+import java.net.URI
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.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
fun Request.Builder.applyFirefoxHeaders(): Request.Builder = FirefoxAccountHeaders.apply(this)
@@ -148,7 +155,7 @@ private class ProtectingSocketFactory : SocketFactory() {
if (protector != null && !protector(socket)) {
throw java.io.IOException("VPN refused control-plane socket protection")
}
- ControlPlaneHttp.underlyingNetwork()?.bindSocket(socket)
+ runCatching { ControlPlaneHttp.underlyingNetwork()?.bindSocket(socket) }
if (remoteAddress != null) socket.connect(remoteAddress)
return socket
} catch (error: Exception) {
@@ -195,11 +202,134 @@ object ControlPlaneHttp {
@Volatile var networkProvider: (() -> Network?)? = null
+ @Volatile var bootstrapDnsConfigProvider: (() -> Pair)? = null
+
internal fun underlyingNetwork(): Network? {
val provider = networkProvider ?: return null
return provider() ?: throw java.io.IOException("No underlying network is available")
}
+ private val dohClient: OkHttpClient by lazy {
+ OkHttpClient.Builder()
+ .protocols(listOf(Protocol.HTTP_1_1))
+ .socketFactory(ProtectingSocketFactory())
+ .connectTimeout(5, TimeUnit.SECONDS)
+ .readTimeout(5, TimeUnit.SECONDS)
+ .callTimeout(5, TimeUnit.SECONDS)
+ .build()
+ }
+
+ private fun queryDoh(dohUrl: String, hostname: String): List {
+ val dohHost = runCatching { URI(dohUrl).host?.removeSurrounding("[", "]") }.getOrNull()
+ if (dohHost != null && dohHost.equals(hostname, ignoreCase = true)) {
+ return emptyList()
+ }
+
+ var httpReachable = false
+ fun sendDohQuery(type: Int): List {
+ val queryBytes = buildDnsQuery(hostname, type)
+ val request =
+ Request.Builder()
+ .url(dohUrl)
+ .post(queryBytes.toRequestBody("application/dns-message".toMediaType()))
+ .header("Accept", "application/dns-message")
+ .build()
+
+ return runCatching {
+ dohClient.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) return emptyList()
+ httpReachable = true
+ val responseBody = response.body?.bytes() ?: return emptyList()
+ parseDnsResponse(responseBody, hostname)
+ }
+ }.getOrDefault(emptyList())
+ }
+
+ val ipv4Results = sendDohQuery(type = 1)
+ if (ipv4Results.isNotEmpty() || !httpReachable) return ipv4Results
+ return sendDohQuery(type = 28)
+ }
+
+ private fun buildDnsQuery(hostname: String, type: Int): ByteArray {
+ val baos = ByteArrayOutputStream()
+ val dos = DataOutputStream(baos)
+ dos.writeShort(0x1a2b)
+ dos.writeShort(0x0100)
+ dos.writeShort(1)
+ dos.writeShort(0)
+ dos.writeShort(0)
+ dos.writeShort(0)
+
+ for (label in hostname.split('.')) {
+ if (label.isEmpty()) continue
+ val bytes = label.toByteArray(Charsets.US_ASCII)
+ dos.writeByte(bytes.size)
+ dos.write(bytes)
+ }
+ dos.writeByte(0)
+ dos.writeShort(type)
+ dos.writeShort(1)
+ return baos.toByteArray()
+ }
+
+ private fun parseDnsResponse(data: ByteArray, hostname: String): List {
+ if (data.size < 12) return emptyList()
+ val dis = DataInputStream(ByteArrayInputStream(data))
+ dis.readShort()
+ val flags = dis.readUnsignedShort()
+ val isResponse = (flags and 0x8000) != 0
+ val rcode = flags and 0x000F
+ if (!isResponse || rcode != 0) return emptyList()
+ val qdCount = dis.readUnsignedShort()
+ val anCount = dis.readUnsignedShort()
+ dis.readUnsignedShort()
+ dis.readUnsignedShort()
+
+ for (i in 0 until qdCount) {
+ skipDomainName(dis)
+ dis.readShort()
+ dis.readShort()
+ }
+
+ val results = mutableListOf()
+ for (i in 0 until anCount) {
+ skipDomainName(dis)
+ val type = dis.readUnsignedShort()
+ val clazz = dis.readUnsignedShort()
+ dis.readInt()
+ val rdLength = dis.readUnsignedShort()
+ val rdata = ByteArray(rdLength)
+ dis.readFully(rdata)
+
+ if (clazz == 1) {
+ if (type == 1 && rdLength == 4) {
+ runCatching { InetAddress.getByAddress(hostname, rdata) }
+ .getOrNull()
+ ?.let { results.add(it) }
+ } else if (type == 28 && rdLength == 16) {
+ runCatching { InetAddress.getByAddress(hostname, rdata) }
+ .getOrNull()
+ ?.let { results.add(it) }
+ }
+ }
+ }
+ return results
+ }
+
+ private fun skipDomainName(dis: DataInputStream) {
+ var steps = 0
+ while (steps++ < 128) {
+ val len = dis.readUnsignedByte()
+ if (len == 0) break
+ if ((len and 0xC0) == 0xC0) {
+ dis.readByte()
+ break
+ } else {
+ dis.skipBytes(len)
+ }
+ }
+ }
+
val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.protocols(listOf(Protocol.HTTP_1_1))
@@ -208,6 +338,18 @@ object ControlPlaneHttp {
.dns(
object : okhttp3.Dns {
override fun lookup(hostname: String): List {
+ if (SettingsStore.isValidIpAddress(hostname)) {
+ return listOf(InetAddress.getByName(hostname))
+ }
+ val config = bootstrapDnsConfigProvider?.invoke()
+ val provider = config?.first ?: SettingsStore.DnsProvider.DIRECT_SYSTEM
+ val dohUrl = config?.second.orEmpty()
+ if (provider != SettingsStore.DnsProvider.DIRECT_SYSTEM && dohUrl.isNotBlank()) {
+ val dohResults = runCatching { queryDoh(dohUrl, hostname) }.getOrNull()
+ if (!dohResults.isNullOrEmpty()) {
+ return dohResults
+ }
+ }
val network = runCatching { underlyingNetwork() }.getOrNull()
val fromNetwork =
if (network != null) {
diff --git a/app/src/main/java/com/vauth/foxyvpn/data/ProxySettingsRepository.kt b/app/src/main/java/com/vauth/foxyvpn/data/ProxySettingsRepository.kt
index 3ca1a492..5a0f98e0 100644
--- a/app/src/main/java/com/vauth/foxyvpn/data/ProxySettingsRepository.kt
+++ b/app/src/main/java/com/vauth/foxyvpn/data/ProxySettingsRepository.kt
@@ -4,12 +4,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class LocalProxyOptions(
+ val bindAddress: String = SettingsStore.DEFAULT_SOCKS_BIND_ADDRESS,
val port: Int,
val authentication: Boolean,
val username: String,
val password: String,
) {
- override fun toString() = "LocalProxyOptions(port=$port, authentication=$authentication)"
+ override fun toString() =
+ "LocalProxyOptions(bindAddress=$bindAddress, port=$port, authentication=$authentication)"
}
class UpstreamProxyOptions(
@@ -24,6 +26,7 @@ class UpstreamProxyOptions(
}
data class ProxySummary(
+ val localBindAddress: String,
val localPort: Int,
val upstreamEnabled: Boolean,
val upstreamType: SettingsStore.UpstreamProxyType,
@@ -37,6 +40,7 @@ class ProxySettingsRepository(private val store: SettingsStore) {
withContext(Dispatchers.IO) {
synchronized(store.proxySettingsLock) {
ProxySummary(
+ store.socksBindAddress,
store.socksPort,
store.upstreamProxyEnabled,
store.upstreamProxyType,
@@ -51,6 +55,7 @@ class ProxySettingsRepository(private val store: SettingsStore) {
withContext(Dispatchers.IO) {
synchronized(store.proxySettingsLock) {
LocalProxyOptions(
+ store.socksBindAddress,
store.socksPort,
store.localProxyAuthEnabled,
store.localProxyUsername,
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 edab21cc..baa8784f 100644
--- a/app/src/main/java/com/vauth/foxyvpn/data/SettingsStore.kt
+++ b/app/src/main/java/com/vauth/foxyvpn/data/SettingsStore.kt
@@ -179,7 +179,7 @@ class SettingsStore(context: Context) {
}
val socksBindAddress: String
- get() = DEFAULT_SOCKS_BIND_ADDRESS
+ get() = prefs.getString(KEY_SOCKS_BIND_ADDRESS, DEFAULT_SOCKS_BIND_ADDRESS) ?: DEFAULT_SOCKS_BIND_ADDRESS
val socksPort: Int
get() =
@@ -235,6 +235,47 @@ class SettingsStore(context: Context) {
else -> dnsProvider.dohUrl
}
+ var bootstrapDnsProvider: DnsProvider
+ get() {
+ val saved = prefs.getString(KEY_BOOTSTRAP_DNS_PROVIDER, null)
+ if (saved == null) return DnsProvider.DIRECT_SYSTEM
+ return DnsProvider.entries.firstOrNull { it.name == saved } ?: DnsProvider.DIRECT_SYSTEM
+ }
+ set(value) {
+ prefs.edit().putString(KEY_BOOTSTRAP_DNS_PROVIDER, value.name).apply()
+ }
+
+ var customBootstrapDohUrl: String
+ get() = prefs.getString(KEY_CUSTOM_BOOTSTRAP_DOH_URL, "") ?: ""
+ set(value) {
+ val trimmed = value.trim()
+ if (trimmed.isNotEmpty() && !isValidDohUrl(trimmed)) return
+ prefs.edit().putString(KEY_CUSTOM_BOOTSTRAP_DOH_URL, trimmed).apply()
+ }
+
+ fun saveBootstrapDns(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) }
+ ?: customBootstrapDohUrl.takeIf { isValidDohUrl(it) }.orEmpty()
+ prefs
+ .edit()
+ .putString(KEY_BOOTSTRAP_DNS_PROVIDER, provider.name)
+ .putString(KEY_CUSTOM_BOOTSTRAP_DOH_URL, savedUrl)
+ .apply()
+ }
+
+ val effectiveBootstrapDohUrl: String
+ get() =
+ when (bootstrapDnsProvider) {
+ DnsProvider.CUSTOM -> customBootstrapDohUrl
+ DnsProvider.DIRECT_SYSTEM -> ""
+ else -> bootstrapDnsProvider.dohUrl
+ }
+
var tlsFragmentMode: TlsFragmentMode
get() =
TlsFragmentMode.entries.firstOrNull {
@@ -259,22 +300,33 @@ class SettingsStore(context: Context) {
prefs.edit().putString(KEY_UTLS_FINGERPRINT, value.name).apply()
}
- var tcpFastOpen: Boolean
- get() = prefs.getBoolean(KEY_TCP_FAST_OPEN, false)
- set(value) {
- prefs.edit().putBoolean(KEY_TCP_FAST_OPEN, value).apply()
- }
-
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 ipMode: IpMode
+ get() {
+ val raw = prefs.getString(KEY_IP_MODE, null)
+ if (raw != null) {
+ return IpMode.entries.firstOrNull { it.id == raw || it.name == raw }
+ ?: IpMode.DUAL_STACK
+ }
+ if (prefs.contains(KEY_IPV6_ENABLED)) {
+ return if (prefs.getBoolean(KEY_IPV6_ENABLED, true)) IpMode.DUAL_STACK
+ else IpMode.IPV4_ONLY
+ }
+ return IpMode.DUAL_STACK
+ }
+ set(value) {
+ prefs.edit().putString(KEY_IP_MODE, value.id).apply()
+ }
+
var ipv6Enabled: Boolean
- get() = prefs.getBoolean(KEY_IPV6_ENABLED, false)
+ get() = ipMode.hasIpv6
set(value) {
- prefs.edit().putBoolean(KEY_IPV6_ENABLED, value).apply()
+ ipMode = if (value) IpMode.DUAL_STACK else IpMode.IPV4_ONLY
}
var proxyOnlyMode: Boolean
@@ -332,6 +384,12 @@ class SettingsStore(context: Context) {
require(isValidLocalPort(options.port)) {
"Choose a valid listener port, excluding controller port $CONTROLLER_PORT"
}
+ require(
+ options.bindAddress == DEFAULT_SOCKS_BIND_ADDRESS ||
+ options.bindAddress == ALL_INTERFACES_BIND_ADDRESS,
+ ) {
+ "Choose a valid listen address ($DEFAULT_SOCKS_BIND_ADDRESS or $ALL_INTERFACES_BIND_ADDRESS)"
+ }
require(
!options.authentication ||
isValidLocalProxyCredentials(options.username, options.password)
@@ -355,6 +413,7 @@ class SettingsStore(context: Context) {
check(
prefs
.edit()
+ .putString(KEY_SOCKS_BIND_ADDRESS, options.bindAddress)
.putInt(KEY_SOCKS_PORT, options.port)
.putBoolean("local_proxy_auth_enabled", options.authentication)
.commit()
@@ -466,8 +525,32 @@ class SettingsStore(context: Context) {
.putStringSet(KEY_INCLUDED_APPS, HashSet(included))
.apply()
}
+ enum class IpMode(val id: String) {
+ DUAL_STACK("dual_stack"),
+ PREFER_IPV4("prefer_ipv4"),
+ PREFER_IPV6("prefer_ipv6"),
+ IPV4_ONLY("ipv4_only"),
+ IPV6_ONLY("ipv6_only");
+
+ val hasIpv4: Boolean
+ get() = this != IPV6_ONLY
+
+ val hasIpv6: Boolean
+ get() = this != IPV4_ONLY
+
+ val dnsStrategy: String?
+ get() =
+ when (this) {
+ DUAL_STACK -> null
+ PREFER_IPV4 -> "prefer_ipv4"
+ PREFER_IPV6 -> "prefer_ipv6"
+ IPV4_ONLY -> "ipv4_only"
+ IPV6_ONLY -> "ipv6_only"
+ }
+ }
enum class HttpVersion(val versionNumber: Int, val label: String, val description: String) {
+ HTTP_1(versionNumber = 1, label = "HTTP/1.1", description = "Classic HTTP proxy over TLS"),
HTTP_2(versionNumber = 2, label = "HTTP/2", description = "Multiplexed streams over TCP"),
HTTP_3(versionNumber = 3, label = "HTTP/3", description = "QUIC transport over UDP"),
}
@@ -540,11 +623,13 @@ class SettingsStore(context: Context) {
private const val KEY_SOCKS_PORT = "socks_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_DOH_URL = "custom_bootstrap_doh_url"
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_IP_MODE = "ip_mode"
private const val KEY_IPV6_ENABLED = "ipv6_enabled"
const val DEFAULT_FRAGMENT_FALLBACK_DELAY = 300
const val DEFAULT_MAX_CONCURRENT_STREAMS = 32
@@ -562,6 +647,8 @@ class SettingsStore(context: Context) {
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 ALL_INTERFACES_BIND_ADDRESS = "0.0.0.0"
+ private const val KEY_SOCKS_BIND_ADDRESS = "socks_bind_address"
const val DEFAULT_SOCKS_PORT = 1080
const val DEFAULT_UPSTREAM_PROXY_PORT = 1080
@@ -597,7 +684,8 @@ class SettingsStore(context: Context) {
(isValidIpAddress(normalized) &&
runCatching { java.net.InetAddress.getByName(normalized).isLoopbackAddress }
.getOrDefault(false))
- return loopback && (port == mixedPort || port == CONTROLLER_PORT)
+ val isLocal = loopback || normalized == "0.0.0.0" || normalized == "::"
+ return isLocal && (port == mixedPort || port == CONTROLLER_PORT)
}
fun isValidLocalProxyCredentials(username: String, password: String): Boolean =
diff --git a/app/src/main/java/com/vauth/foxyvpn/data/TunnelSettings.kt b/app/src/main/java/com/vauth/foxyvpn/data/TunnelSettings.kt
index 3e5da433..9313f624 100644
--- a/app/src/main/java/com/vauth/foxyvpn/data/TunnelSettings.kt
+++ b/app/src/main/java/com/vauth/foxyvpn/data/TunnelSettings.kt
@@ -3,14 +3,17 @@ package com.vauth.foxyvpn.data
data class TunnelSettings(
val automaticRecovery: Boolean,
val dnsProvider: SettingsStore.DnsProvider,
+ val bootstrapDnsProvider: SettingsStore.DnsProvider,
val effectiveCustomEdgeAddress: String?,
val effectiveDohUrl: String,
+ val effectiveBootstrapDohUrl: String,
val excludedApps: Set,
val includedApps: Set,
val splitTunnelMode: SettingsStore.SplitTunnelMode,
val fragmentFallbackDelay: Int,
val httpVersion: SettingsStore.HttpVersion,
- val ipv6Enabled: Boolean,
+ val ipMode: SettingsStore.IpMode = SettingsStore.IpMode.DUAL_STACK,
+ val ipv6Enabled: Boolean = ipMode.hasIpv6,
val maxConcurrentStreams: Int,
val mtu: Int,
val localProxyAuthEnabled: Boolean,
@@ -19,7 +22,6 @@ data class TunnelSettings(
val proxyOnlyMode: Boolean,
val socksBindAddress: String,
val socksPort: Int,
- val tcpFastOpen: Boolean,
val tlsFragmentMode: SettingsStore.TlsFragmentMode,
val upstreamProxyEnabled: Boolean,
val upstreamProxyHost: String,
@@ -31,7 +33,7 @@ data class TunnelSettings(
val bypassLocalNetwork: Boolean = true,
) {
override fun toString() =
- "TunnelSettings(proxyOnlyMode=$proxyOnlyMode, httpVersion=$httpVersion)"
+ "TunnelSettings(proxyOnlyMode=$proxyOnlyMode, httpVersion=$httpVersion, ipMode=$ipMode)"
}
fun SettingsStore.tunnelSnapshot(): TunnelSettings =
@@ -43,14 +45,17 @@ fun SettingsStore.tunnelSnapshot(): TunnelSettings =
automaticRecovery = automaticRecovery,
bypassLocalNetwork = bypassLocalNetwork,
dnsProvider = dnsProvider,
+ bootstrapDnsProvider = bootstrapDnsProvider,
effectiveCustomEdgeAddress = effectiveCustomEdgeAddress,
effectiveDohUrl = effectiveDohUrl,
+ effectiveBootstrapDohUrl = effectiveBootstrapDohUrl,
excludedApps = excludedApps.toSet(),
includedApps = includedApps.toSet(),
splitTunnelMode = splitTunnelMode,
fragmentFallbackDelay = fragmentFallbackDelay,
httpVersion = httpVersion,
- ipv6Enabled = ipv6Enabled,
+ ipMode = ipMode,
+ ipv6Enabled = ipMode.hasIpv6,
maxConcurrentStreams = maxConcurrentStreams,
mtu = mtu,
localProxyAuthEnabled = localProxyAuthEnabled,
@@ -59,7 +64,6 @@ fun SettingsStore.tunnelSnapshot(): TunnelSettings =
proxyOnlyMode = proxyOnlyMode,
socksBindAddress = socksBindAddress,
socksPort = socksPort,
- tcpFastOpen = tcpFastOpen,
tlsFragmentMode = tlsFragmentMode,
upstreamProxyEnabled = upstreamProxyEnabled,
upstreamProxyHost = if (upstreamProxyEnabled) upstreamProxyHost else "",
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 1b055f9f..59490d67 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
@@ -8,6 +8,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
@@ -114,14 +115,17 @@ fun FoxyNavGraph(
val app = context.applicationContext as? com.vauth.foxyvpn.FoxyVpnApp
val updater = app?.appUpdater
var autoUpdates by remember { mutableStateOf(updater?.automaticChecks ?: false) }
+ val manualUpdate by updater?.manualUpdate?.collectAsStateWithLifecycle()
+ ?: remember { mutableStateOf(null) }
SettingsScreen(
settingsStore = container.settingsStore,
onOpenLogs = { navController.navigate(FoxyRoutes.LOGS) },
signOutInProgress = signingOut,
onCheckForUpdates = {
- updater?.showPrompt()
- updater?.check()
+ updater?.check(automatic = false)
},
+ manualUpdate = manualUpdate,
+ onDismissUpdateDialog = { updater?.dismissManualUpdate() },
automaticUpdates = autoUpdates,
onAutomaticUpdatesChange = { enabled ->
updater?.automaticChecks = enabled
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 1902a644..cef811c0 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
@@ -17,7 +17,9 @@ 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 android.widget.Toast
import androidx.compose.material.icons.filled.Code
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
@@ -55,12 +57,14 @@ 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.IpModePickerDialog
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.update.ReleaseInfo
import com.vauth.foxyvpn.vpn.FoxyVpnService
import com.vauth.foxyvpn.vpn.TunnelHealth
@@ -74,9 +78,11 @@ private enum class SettingsDialog {
CONCURRENCY,
MTU,
DNS_PROVIDER,
+ BOOTSTRAP_DNS_PROVIDER,
EDGE_ADDRESS,
SPLIT_TUNNEL,
HTTP_VERSION,
+ IP_MODE,
}
@Composable
@@ -87,6 +93,8 @@ fun SettingsScreen(
onBack: () -> Unit,
signOutInProgress: Boolean = false,
onCheckForUpdates: (() -> Unit)? = null,
+ manualUpdate: ReleaseInfo? = null,
+ onDismissUpdateDialog: () -> Unit = {},
automaticUpdates: Boolean = false,
onAutomaticUpdatesChange: ((Boolean) -> Unit)? = null,
) {
@@ -95,6 +103,8 @@ fun SettingsScreen(
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 customBootstrapDohUrl by remember { mutableStateOf(settingsStore.customBootstrapDohUrl) }
var proxyOnlyMode by remember { mutableStateOf(settingsStore.proxyOnlyMode) }
var customEdgeAddress by remember { mutableStateOf(settingsStore.customEdgeAddress) }
var excludedApps by remember { mutableStateOf(settingsStore.excludedApps) }
@@ -107,9 +117,8 @@ fun SettingsScreen(
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 ipMode by remember { mutableStateOf(settingsStore.ipMode) }
var activeDialog by rememberSaveable { mutableStateOf(SettingsDialog.NONE) }
val proxyViewModel: ProxySettingsViewModel =
@@ -128,6 +137,44 @@ fun SettingsScreen(
proxyViewModel::save,
)
+ if (manualUpdate != null) {
+ val release = manualUpdate
+ AlertDialog(
+ onDismissRequest = onDismissUpdateDialog,
+ title = { Text(stringResource(R.string.update_dialog_title)) },
+ text = {
+ Text(stringResource(R.string.update_notification_text, release.tag))
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ onDismissUpdateDialog()
+ runCatching {
+ context.startActivity(
+ Intent(Intent.ACTION_VIEW, Uri.parse(release.htmlUrl)).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+ )
+ }.onFailure {
+ Toast.makeText(
+ context,
+ context.getString(R.string.browser_unavailable),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ ) {
+ Text(stringResource(R.string.update_btn_view_release))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismissUpdateDialog) {
+ Text(stringResource(R.string.update_dialog_dismiss))
+ }
+ },
+ )
+ }
+
when (activeDialog) {
SettingsDialog.NONE -> {}
SettingsDialog.LATENCY_PROBE -> LatencyProbeDialog(
@@ -171,7 +218,7 @@ fun SettingsScreen(
)
SettingsDialog.MTU -> MtuPickerDialog(
current = mtu,
- minimum = if (ipv6Enabled) 1280 else SettingsStore.MIN_MTU,
+ minimum = if (ipMode.hasIpv6) 1280 else SettingsStore.MIN_MTU,
onDismiss = { activeDialog = SettingsDialog.NONE },
onConfirm = {
mtu = it
@@ -190,6 +237,19 @@ fun SettingsScreen(
activeDialog = SettingsDialog.NONE
},
)
+ SettingsDialog.BOOTSTRAP_DNS_PROVIDER -> DnsProviderPickerDialog(
+ title = stringResource(R.string.settings_bootstrap_dns_provider),
+ helpText = stringResource(R.string.dialog_dns_bootstrap_help),
+ currentProvider = bootstrapDnsProvider,
+ currentCustomUrl = customBootstrapDohUrl,
+ onDismiss = { activeDialog = SettingsDialog.NONE },
+ onConfirm = { provider, url ->
+ settingsStore.saveBootstrapDns(provider, url)
+ bootstrapDnsProvider = provider
+ customBootstrapDohUrl = settingsStore.customBootstrapDohUrl
+ activeDialog = SettingsDialog.NONE
+ },
+ )
SettingsDialog.EDGE_ADDRESS -> CustomEdgeAddressDialog(
current = customEdgeAddress,
onDismiss = { activeDialog = SettingsDialog.NONE },
@@ -221,6 +281,19 @@ fun SettingsScreen(
activeDialog = SettingsDialog.NONE
},
)
+ SettingsDialog.IP_MODE -> IpModePickerDialog(
+ current = ipMode,
+ onDismiss = { activeDialog = SettingsDialog.NONE },
+ onConfirm = {
+ if (it.hasIpv6 && mtu < 1280) {
+ settingsStore.mtu = 1280
+ mtu = 1280
+ }
+ settingsStore.ipMode = it
+ ipMode = it
+ activeDialog = SettingsDialog.NONE
+ },
+ )
}
Scaffold(
@@ -298,6 +371,22 @@ fun SettingsScreen(
},
modifier = Modifier.clickable { activeDialog = SettingsDialog.DNS_PROVIDER },
)
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.settings_bootstrap_dns_provider)) },
+ supportingContent = {
+ val subtitle =
+ when (bootstrapDnsProvider) {
+ SettingsStore.DnsProvider.CUSTOM ->
+ customBootstrapDohUrl.ifBlank { "Custom DoH" }
+ else -> bootstrapDnsProvider.label
+ }
+ Text(subtitle)
+ },
+ trailingContent = {
+ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
+ },
+ modifier = Modifier.clickable { activeDialog = SettingsDialog.BOOTSTRAP_DNS_PROVIDER },
+ )
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionLabel(stringResource(R.string.proxy))
@@ -329,7 +418,7 @@ fun SettingsScreen(
else
stringResource(
R.string.local_proxy_summary,
- SettingsStore.DEFAULT_SOCKS_BIND_ADDRESS,
+ summary.localBindAddress,
summary.localPort,
)
)
@@ -470,7 +559,9 @@ fun SettingsScreen(
headlineContent = { Text(stringResource(R.string.settings_tls_fragmentation)) },
supportingContent = {
Text(
- if (tlsFragmentMode == SettingsStore.TlsFragmentMode.OFF) {
+ if (httpVersion == SettingsStore.HttpVersion.HTTP_3) {
+ stringResource(R.string.settings_tcp_only_notice)
+ } else if (tlsFragmentMode == SettingsStore.TlsFragmentMode.OFF) {
stringResource(R.string.settings_disabled)
} else if (tlsFragmentMode == SettingsStore.TlsFragmentMode.RECORD) {
tlsFragmentMode.label
@@ -488,7 +579,15 @@ fun SettingsScreen(
headlineContent = {
Text(stringResource(R.string.settings_browser_tls_fingerprint))
},
- supportingContent = { Text(utlsFingerprint.label) },
+ supportingContent = {
+ Text(
+ if (httpVersion == SettingsStore.HttpVersion.HTTP_3) {
+ "${utlsFingerprint.label} • ${stringResource(R.string.settings_tcp_only_notice)}"
+ } else {
+ utlsFingerprint.label
+ }
+ )
+ },
trailingContent = {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
},
@@ -497,36 +596,36 @@ fun SettingsScreen(
ListItem(
headlineContent = { Text(stringResource(R.string.settings_multiplex_concurrency)) },
supportingContent = {
- Text(pluralStringResource(R.plurals.stream_count, maxConcurrentStreams, maxConcurrentStreams))
+ Text(
+ if (httpVersion != SettingsStore.HttpVersion.HTTP_2) {
+ "${pluralStringResource(R.plurals.stream_count, maxConcurrentStreams, maxConcurrentStreams)} • ${stringResource(R.string.settings_http2_only_notice)}"
+ } else {
+ 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
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.settings_ip_mode)) },
+ supportingContent = {
+ Text(
+ when (ipMode) {
+ SettingsStore.IpMode.DUAL_STACK -> stringResource(R.string.ip_mode_dual_stack)
+ SettingsStore.IpMode.PREFER_IPV4 -> stringResource(R.string.ip_mode_prefer_ipv4)
+ SettingsStore.IpMode.PREFER_IPV6 -> stringResource(R.string.ip_mode_prefer_ipv6)
+ SettingsStore.IpMode.IPV4_ONLY -> stringResource(R.string.ip_mode_ipv4_only)
+ SettingsStore.IpMode.IPV6_ONLY -> stringResource(R.string.ip_mode_ipv6_only)
+ }
+ )
},
- )
- 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
+ trailingContent = {
+ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
},
+ modifier = Modifier.clickable { activeDialog = SettingsDialog.IP_MODE },
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
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
index dd3d49f9..59f6f4aa 100644
--- a/app/src/main/java/com/vauth/foxyvpn/ui/settings/ConnectionDialogs.kt
+++ b/app/src/main/java/com/vauth/foxyvpn/ui/settings/ConnectionDialogs.kt
@@ -46,6 +46,8 @@ private val NetworkDraftSaver =
@Composable
internal fun DnsProviderPickerDialog(
+ title: String = stringResource(R.string.settings_dns_provider),
+ helpText: String = stringResource(R.string.dialog_dns_tunnel_help),
currentProvider: SettingsStore.DnsProvider,
currentCustomUrl: String,
onDismiss: () -> Unit,
@@ -60,11 +62,11 @@ internal fun DnsProviderPickerDialog(
AlertDialog(
onDismissRequest = onDismiss,
- title = { Text(stringResource(R.string.settings_dns_provider)) },
+ title = { Text(title) },
text = {
Column(Modifier.verticalScroll(rememberScrollState()).selectableGroup()) {
Text(
- stringResource(R.string.dialog_dns_tunnel_help),
+ helpText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
@@ -233,6 +235,63 @@ internal fun HttpVersionPickerDialog(
)
}
+@Composable
+internal fun IpModePickerDialog(
+ current: SettingsStore.IpMode,
+ onDismiss: () -> Unit,
+ onConfirm: (SettingsStore.IpMode) -> Unit,
+) {
+ var selected by rememberSaveable { mutableStateOf(current) }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.settings_ip_mode)) },
+ text = {
+ Column(Modifier.verticalScroll(rememberScrollState()).selectableGroup()) {
+ Text(
+ stringResource(R.string.dialog_ip_mode_help),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 12.dp),
+ )
+ SettingsStore.IpMode.entries.forEach { mode ->
+ val label =
+ when (mode) {
+ SettingsStore.IpMode.DUAL_STACK -> stringResource(R.string.ip_mode_dual_stack)
+ SettingsStore.IpMode.PREFER_IPV4 -> stringResource(R.string.ip_mode_prefer_ipv4)
+ SettingsStore.IpMode.PREFER_IPV6 -> stringResource(R.string.ip_mode_prefer_ipv6)
+ SettingsStore.IpMode.IPV4_ONLY -> stringResource(R.string.ip_mode_ipv4_only)
+ SettingsStore.IpMode.IPV6_ONLY -> stringResource(R.string.ip_mode_ipv6_only)
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier.fillMaxWidth()
+ .selectable(
+ selected = selected == mode,
+ role = Role.RadioButton,
+ onClick = { selected = mode },
+ )
+ .heightIn(min = 48.dp)
+ .padding(vertical = 4.dp),
+ ) {
+ RadioButton(selected = selected == mode, onClick = null)
+ Text(
+ text = 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 MtuPickerDialog(
current: Int,
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
index 52b184d9..c960c2b3 100644
--- a/app/src/main/java/com/vauth/foxyvpn/ui/settings/ProxySettingsDialogs.kt
+++ b/app/src/main/java/com/vauth/foxyvpn/ui/settings/ProxySettingsDialogs.kt
@@ -98,23 +98,61 @@ private fun LocalProxyDialog(
onDismiss: () -> Unit,
onSave: (LocalProxyOptions) -> Unit,
) {
+ var bindAddress by remember(initial) { mutableStateOf(initial.bindAddress) }
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)
+ val validAddress =
+ bindAddress == SettingsStore.DEFAULT_SOCKS_BIND_ADDRESS ||
+ bindAddress == SettingsStore.ALL_INTERFACES_BIND_ADDRESS
ProxyEditorDialog(
stringResource(R.string.local_proxy),
busy,
error,
- validPort && validCredentials,
+ validPort && validCredentials && validAddress,
onDismiss,
- { onSave(LocalProxyOptions(port.toInt(), authentication, username, password)) },
+ { onSave(LocalProxyOptions(bindAddress, port.toInt(), authentication, username, password)) },
) {
- Text(stringResource(R.string.local_address_value, SettingsStore.DEFAULT_SOCKS_BIND_ADDRESS))
+ Text(
+ stringResource(R.string.local_listen_address),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ Column(Modifier.selectableGroup()) {
+ listOf(
+ SettingsStore.DEFAULT_SOCKS_BIND_ADDRESS to R.string.local_address_loopback,
+ SettingsStore.ALL_INTERFACES_BIND_ADDRESS to R.string.local_address_all,
+ ).forEach { (address, descRes) ->
+ Row(
+ Modifier.fillMaxWidth()
+ .selectable(
+ selected = bindAddress == address,
+ enabled = !busy,
+ role = Role.RadioButton,
+ onClick = { bindAddress = address },
+ )
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(
+ selected = bindAddress == address,
+ onClick = null,
+ enabled = !busy,
+ )
+ Column(Modifier.padding(start = 8.dp)) {
+ Text(address, style = MaterialTheme.typography.bodyMedium)
+ Text(
+ stringResource(descRes),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
PortField(port, { port = it }, stringResource(R.string.mixed_port), !busy, !validPort)
Text(
stringResource(R.string.local_port_help, SettingsStore.CONTROLLER_PORT),
diff --git a/app/src/main/java/com/vauth/foxyvpn/update/AppUpdater.kt b/app/src/main/java/com/vauth/foxyvpn/update/AppUpdater.kt
index 1d6faadc..00a68e65 100644
--- a/app/src/main/java/com/vauth/foxyvpn/update/AppUpdater.kt
+++ b/app/src/main/java/com/vauth/foxyvpn/update/AppUpdater.kt
@@ -1,24 +1,27 @@
package com.vauth.foxyvpn.update
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.NotificationManager
+import android.app.PendingIntent
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 android.widget.Toast
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
import com.vauth.foxyvpn.BuildConfig
+import com.vauth.foxyvpn.FoxyVpnApp
+import com.vauth.foxyvpn.R
+import com.vauth.foxyvpn.data.AppLinks
+import com.vauth.foxyvpn.data.ControlPlaneHttp
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
@@ -27,34 +30,45 @@ 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.CookieJar
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
+private const val UPDATE_NOTIFICATION_ID = 2001
+private const val UPDATE_PENDING_INTENT_REQUEST_CODE = 2001
+
+data class ReleaseInfo(
+ val tag: String,
+ val htmlUrl: String,
+)
+
+internal fun isNewerVersion(tag: String, currentVersionName: String): Boolean {
+ fun parts(value: String): List? {
+ val base =
+ value.trim().removePrefix("v").removePrefix("V")
+ .substringBefore('-').substringBefore('+')
+ val segments = base.split('.')
+ if (segments.isEmpty() || segments.size > 5) return null
+ return segments.map { it.toLongOrNull() ?: return null }
+ }
-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
+ val release = parts(tag)
+ val current = parts(currentVersionName)
+ if (release != null && current != null) {
+ val maxLen = maxOf(release.size, current.size)
+ for (i in 0 until maxLen) {
+ val r = release.getOrElse(i) { 0L }
+ val c = current.getOrElse(i) { 0L }
+ if (r != c) return r > c
+ }
+ return false
+ }
+ val cleanTag = tag.trim().removePrefix("v").removePrefix("V")
+ val cleanCurrent = currentVersionName.trim().removePrefix("v").removePrefix("V")
+ return cleanTag.compareTo(cleanCurrent, ignoreCase = true) > 0
}
internal class AppUpdater(private val context: Context) {
@@ -62,29 +76,23 @@ internal class AppUpdater(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val client =
- OkHttpClient.Builder()
+ ControlPlaneHttp.client
+ .newBuilder()
+ .cookieJar(CookieJar.NO_COOKIES)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
- .callTimeout(10, TimeUnit.MINUTES)
- .followSslRedirects(false)
+ .followSslRedirects(true)
.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
- }
+ private var checkJob: Job? = null
+
+ private val mutableManualUpdate = MutableStateFlow(null)
+ val manualUpdate: StateFlow = mutableManualUpdate
- fun hidePrompt() {
- mutablePromptVisible.value = false
+ fun dismissManualUpdate() {
+ mutableManualUpdate.value = null
}
- private val mutableState = MutableStateFlow(UpdateState.Idle)
- val state: StateFlow = mutableState
var automaticChecks: Boolean
get() = prefs.getBoolean("automatic_checks", true)
set(value) {
@@ -92,306 +100,155 @@ internal class AppUpdater(private val context: Context) {
}
init {
- directory.listFiles()?.forEach { it.delete() }
+ try {
+ File(context.cacheDir, "updates").deleteRecursively()
+ } catch (_: Exception) {}
}
fun check(automatic: Boolean = false) {
- if (operation?.isActive == true) return
- if (mutableState.value is UpdateState.Ready) {
- if (automatic) return
- cancel()
- }
+ if (checkJob?.isActive == true) return
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")
- }
+ if (automatic) {
+ if (!automaticChecks) return
+ val lastSuccessful = prefs.getLong("last_successful_check", 0L)
+ val lastAttempt = prefs.getLong("last_check_attempt", 0L)
+ if (now - lastSuccessful in 0 until CHECK_INTERVAL_MS ||
+ now - lastAttempt in 0 until FAILED_CHECK_BACKOFF_MS
+ ) {
+ return
}
- }
+ }
+
+ prefs.edit().putLong("last_check_attempt", now).apply()
+
+ if (!automatic) {
+ Toast.makeText(context, R.string.update_checking, Toast.LENGTH_SHORT).show()
+ }
- fun download(release: AppRelease) {
- if (operation?.isActive == true) return
- val myGeneration = synchronized(publicationLock) { ++generation }
- operation =
+ checkJob =
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)
+ val release = withContext(Dispatchers.IO) { discoverRelease() }
+ prefs.edit().putLong("last_successful_check", System.currentTimeMillis()).apply()
+
+ if (release != null && isNewerVersion(release.tag, BuildConfig.VERSION_NAME)) {
+ if (automatic) {
+ val lastNotifiedTag = prefs.getString("last_notified_tag", null)
+ if (lastNotifiedTag != release.tag) {
+ notifyUpdateAvailable(release)
+ prefs.edit().putString("last_notified_tag", release.tag).apply()
+ }
+ } else {
+ mutableManualUpdate.value = release
+ }
+ } else if (!automatic) {
+ mutableManualUpdate.value = null
+ Toast.makeText(
+ context,
+ context.getString(R.string.update_up_to_date, BuildConfig.VERSION_NAME),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ if (!automatic) {
+ mutableManualUpdate.value = null
+ Toast.makeText(
+ context,
+ context.getString(
+ R.string.update_check_failed,
+ e.message ?: "Network error",
+ ),
+ Toast.LENGTH_LONG,
+ ).show()
}
- } 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? {
+ private suspend fun discoverRelease(): ReleaseInfo? {
val request =
Request.Builder()
- .url(RELEASE_API)
+ .url(AppLinks.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()
+ return client.newCall(request).awaitResponse().use { response ->
+ if (response.code == 404) return null
+ check(response.isSuccessful) { "HTTP ${response.code}" }
+ val raw =
+ response.body?.byteStream()?.use { it.readBoundedUtf8(512 * 1024) }
+ ?: error("Empty release response")
+ val json = JSONObject(raw)
+ if (json.optBoolean("draft", false) || json.optBoolean("prerelease", false)) {
+ return null
}
- 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")
+ val tag = json.getString("tag_name")
+ val htmlUrl = json.optString("html_url", "${AppLinks.SOURCE}/releases/tag/$tag")
+ ReleaseInfo(tag = tag, htmlUrl = htmlUrl)
}
}
- 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
+ @SuppressLint("MissingPermission")
+ private fun notifyUpdateAvailable(release: ReleaseInfo) {
+ val viewIntent =
+ Intent(Intent.ACTION_VIEW, Uri.parse(release.htmlUrl)).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
- 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)
- }
- }
- }
+ val pendingIntent =
+ PendingIntent.getActivity(
+ context,
+ UPDATE_PENDING_INTENT_REQUEST_CODE,
+ viewIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
- }
- @Suppress("DEPRECATION")
- private fun validateApk(file: File, expectedHash: String): String? {
- check(file.isFile && file.canonicalFile.parentFile == directory.canonicalFile) {
- "Update file is missing"
+ val notification =
+ NotificationCompat.Builder(context, FoxyVpnApp.UPDATE_NOTIFICATION_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification_update)
+ .setContentTitle(context.getString(R.string.update_notification_title))
+ .setContentText(context.getString(R.string.update_notification_text, release.tag))
+ .setStyle(
+ NotificationCompat.BigTextStyle()
+ .bigText(context.getString(R.string.update_notification_text, release.tag))
+ )
+ .setContentIntent(pendingIntent)
+ .setAutoCancel(true)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .addAction(
+ 0,
+ context.getString(R.string.update_btn_view_release),
+ pendingIntent,
+ )
+ .build()
+
+ if (hasNotificationPermission()) {
+ try {
+ NotificationManagerCompat.from(context).notify(UPDATE_NOTIFICATION_ID, notification)
+ } catch (_: SecurityException) {
+ }
}
- 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)
- }
+ private fun hasNotificationPermission(): Boolean {
+ val manager = NotificationManagerCompat.from(context)
+ if (!manager.areNotificationsEnabled()) return false
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ return false
+ }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = manager.getNotificationChannel(FoxyVpnApp.UPDATE_NOTIFICATION_CHANNEL_ID)
+ if (channel != null && channel.importance == NotificationManager.IMPORTANCE_NONE) {
+ return false
}
- .toSet()
+ }
+ return true
}
}
diff --git a/app/src/main/java/com/vauth/foxyvpn/update/UpdatePolicy.kt b/app/src/main/java/com/vauth/foxyvpn/update/UpdatePolicy.kt
deleted file mode 100644
index 7ace7629..00000000
--- a/app/src/main/java/com/vauth/foxyvpn/update/UpdatePolicy.kt
+++ /dev/null
@@ -1,145 +0,0 @@
-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
deleted file mode 100644
index f526dbc3..00000000
--- a/app/src/main/java/com/vauth/foxyvpn/update/UpdateUi.kt
+++ /dev/null
@@ -1,144 +0,0 @@
-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/FoxyPlatformInterface.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyPlatformInterface.kt
index a0f1f816..f35aac0f 100644
--- a/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyPlatformInterface.kt
+++ b/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyPlatformInterface.kt
@@ -127,8 +127,13 @@ class FoxyPlatformInterface(
}
}
if (!addedDns) {
- builder.addDnsServer("172.19.0.2")
- builder.addRoute("172.19.0.2", 32)
+ if (hasIpv4) {
+ builder.addDnsServer("172.19.0.2")
+ builder.addRoute("172.19.0.2", 32)
+ } else if (hasIpv6) {
+ builder.addDnsServer("fdfe:dcba:9876::2")
+ builder.addRoute("fdfe:dcba:9876::2", 128)
+ }
}
}
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 ccad48dc..a38bc34f 100644
--- a/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyVpnService.kt
+++ b/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyVpnService.kt
@@ -339,12 +339,12 @@ class FoxyVpnService : VpnService() {
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
}
+ stopping = false
_state.value = ConnectionState.CONNECTING
_lastError.value = null
statusLabel = "Connecting\u2026"
@@ -612,10 +612,13 @@ class FoxyVpnService : VpnService() {
ensureGenerationCurrent(myGeneration)
val socksPort = tunnelSettings.socksPort
+ val socksBindAddress = tunnelSettings.socksBindAddress
ListenerAvailability.requireAvailable(
- listOf(socksPort, SettingsStore.CONTROLLER_PORT)
+ listOf(
+ socksBindAddress to socksPort,
+ SettingsStore.DEFAULT_SOCKS_BIND_ADDRESS to SettingsStore.CONTROLLER_PORT,
+ )
)
- val socksBindAddress = tunnelSettings.socksBindAddress
val proxyOnlyMode = tunnelSettings.proxyOnlyMode
nativeProtectionRequired = !proxyOnlyMode
diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/ListenerAvailability.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/ListenerAvailability.kt
index 2f83154a..933e1917 100644
--- a/app/src/main/java/com/vauth/foxyvpn/vpn/ListenerAvailability.kt
+++ b/app/src/main/java/com/vauth/foxyvpn/vpn/ListenerAvailability.kt
@@ -5,25 +5,63 @@ import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.ServerSocket
+import kotlinx.coroutines.delay
+
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,
- )
+ suspend fun requireAvailable(
+ listeners: List>,
+ maxAttempts: Int = 5,
+ delayMs: Long = 200L,
+ ) {
+ val distinctListeners = listeners.distinct()
+ var lastException: BindException? = null
+ var unavailablePort: Int? = null
+
+ for (attempt in 0 until maxAttempts) {
+ val reservations = mutableListOf()
+ try {
+ lastException = null
+ unavailablePort = null
+ for ((host, port) in distinctListeners) {
+ val socket = ServerSocket()
+ reservations.add(socket)
+ socket.reuseAddress = true
+ try {
+ socket.bind(InetSocketAddress(InetAddress.getByName(host), port))
+ } catch (error: BindException) {
+ lastException = error
+ unavailablePort = port
+ break
+ }
}
+ if (lastException == null) {
+ return
+ }
+ } finally {
+ reservations.forEach { socket -> runCatching { socket.close() } }
+ }
+
+ if (attempt < maxAttempts - 1) {
+ delay(delayMs)
}
- } finally {
- reservations.forEach { socket -> runCatching { socket.close() } }
+ }
+
+ if (lastException != null && unavailablePort != null) {
+ throw IllegalArgumentException(
+ "Local port $unavailablePort is unavailable. Close the other listener or change the proxy port.",
+ lastException,
+ )
}
}
+
+ suspend fun requireAvailable(
+ ports: List,
+ bindAddress: String = "127.0.0.1",
+ maxAttempts: Int = 5,
+ delayMs: Long = 200L,
+ ) = requireAvailable(
+ listeners = ports.map { bindAddress to it },
+ maxAttempts = maxAttempts,
+ delayMs = delayMs,
+ )
}
diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/SingBoxConfigBuilder.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/SingBoxConfigBuilder.kt
index ba72285c..8bbc8f4a 100644
--- a/app/src/main/java/com/vauth/foxyvpn/vpn/SingBoxConfigBuilder.kt
+++ b/app/src/main/java/com/vauth/foxyvpn/vpn/SingBoxConfigBuilder.kt
@@ -18,12 +18,16 @@ object SingBoxConfigBuilder {
}
)
- private val localNetworkAddresses =
+ private val localNetworkAddressesIpv4 =
listOf(
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16",
+ )
+
+ private val localNetworkAddressesIpv6 =
+ listOf(
"fc00::/7",
"fe80::/10",
)
@@ -78,7 +82,14 @@ object SingBoxConfigBuilder {
) {
"Upstream proxy must not point to a FoxyVPN listener"
}
- require(!settings.ipv6Enabled || settings.mtu >= 1280) {
+ require(
+ !settings.upstreamProxyEnabled ||
+ settings.upstreamProxyType != SettingsStore.UpstreamProxyType.HTTP ||
+ settings.httpVersion != SettingsStore.HttpVersion.HTTP_3
+ ) {
+ "HTTP/3 (QUIC) is not supported over an upstream HTTP proxy"
+ }
+ require(!settings.ipMode.hasIpv6 || settings.mtu >= 1280) {
"IPv6 requires a tunnel MTU of at least 1280"
}
require(
@@ -97,10 +108,17 @@ object SingBoxConfigBuilder {
"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(
+ settings.socksBindAddress == SettingsStore.DEFAULT_SOCKS_BIND_ADDRESS ||
+ settings.socksBindAddress == SettingsStore.ALL_INTERFACES_BIND_ADDRESS,
+ ) {
+ "Local proxy listen address must be ${SettingsStore.DEFAULT_SOCKS_BIND_ADDRESS} or ${SettingsStore.ALL_INTERFACES_BIND_ADDRESS}"
}
require(bearerToken.isNotBlank()) { "A proxy pass is required" }
+ val localNetworkAddresses = buildList {
+ if (settings.ipMode.hasIpv4) addAll(localNetworkAddressesIpv4)
+ if (settings.ipMode.hasIpv6) addAll(localNetworkAddressesIpv6)
+ }
val root = JSONObject()
val log =
@@ -123,6 +141,37 @@ object SingBoxConfigBuilder {
}
)
+ val hasBootstrapDns =
+ settings.bootstrapDnsProvider != SettingsStore.DnsProvider.DIRECT_SYSTEM &&
+ settings.effectiveBootstrapDohUrl.isNotBlank()
+ val bootstrapDnsTag = "bootstrap-dns"
+
+ if (hasBootstrapDns) {
+ val bootstrapDohUri = URI(settings.effectiveBootstrapDohUrl)
+ val bootstrapDnsServer = bootstrapDohUri.host.removeSurrounding("[", "]")
+ val bootstrapDnsPath = bootstrapDohUri.rawPath.takeIf { it.isNotBlank() }
+ val bootstrapDnsPort = bootstrapDohUri.port.takeIf { it > 0 }
+
+ dnsServers.put(
+ JSONObject().apply {
+ put("type", "https")
+ put("tag", bootstrapDnsTag)
+ put("server", bootstrapDnsServer)
+ if (bootstrapDnsPort != null && bootstrapDnsPort != 443) {
+ put("server_port", bootstrapDnsPort)
+ }
+ if (bootstrapDnsPath != null && bootstrapDnsPath != "/dns-query") {
+ put("path", bootstrapDnsPath)
+ }
+ if (bootstrapDnsServer.any { it.isLetter() }) {
+ put("domain_resolver", "local-dns")
+ }
+ }
+ )
+ }
+
+ val activeBootstrapDnsTag = if (hasBootstrapDns) bootstrapDnsTag else "local-dns"
+
val finalDnsTag: String =
when (settings.dnsProvider) {
SettingsStore.DnsProvider.DIRECT_SYSTEM -> {
@@ -146,7 +195,7 @@ object SingBoxConfigBuilder {
put("path", remoteDnsPath)
}
if (remoteDnsServer.any { it.isLetter() }) {
- put("domain_resolver", "local-dns")
+ put("domain_resolver", activeBootstrapDnsTag)
}
put("detour", "proxy-out")
}
@@ -157,7 +206,9 @@ object SingBoxConfigBuilder {
dns.put("servers", dnsServers)
dns.put("final", finalDnsTag)
- dns.put("strategy", if (settings.ipv6Enabled) "prefer_ipv4" else "ipv4_only")
+ settings.ipMode.dnsStrategy?.let {
+ dns.put("strategy", it)
+ }
dns.put("disable_cache", false)
root.put("dns", dns)
@@ -189,7 +240,8 @@ object SingBoxConfigBuilder {
val inbounds = JSONArray()
if (!settings.proxyOnlyMode) {
- val hasTunIpv6 = settings.ipv6Enabled || settings.mtu >= 1280
+ val hasTunIpv4 = settings.ipMode.hasIpv4
+ val hasTunIpv6 = settings.ipMode.hasIpv6
val tunInbound =
JSONObject().apply {
put("type", "tun")
@@ -198,7 +250,9 @@ object SingBoxConfigBuilder {
put(
"address",
JSONArray().apply {
- put(DEFAULT_TUN_IPV4)
+ if (hasTunIpv4) {
+ put(DEFAULT_TUN_IPV4)
+ }
if (hasTunIpv6) {
put(DEFAULT_TUN_IPV6)
}
@@ -207,7 +261,9 @@ object SingBoxConfigBuilder {
put(
"dns_address",
JSONArray().apply {
- put(DEFAULT_TUN_DNS_IPV4)
+ if (hasTunIpv4) {
+ put(DEFAULT_TUN_DNS_IPV4)
+ }
if (hasTunIpv6) {
put(DEFAULT_TUN_DNS_IPV6)
}
@@ -215,7 +271,7 @@ object SingBoxConfigBuilder {
)
put("auto_route", true)
put("strict_route", true)
- if (settings.bypassLocalNetwork) {
+ if (settings.bypassLocalNetwork && localNetworkAddresses.isNotEmpty()) {
put("route_exclude_address", JSONArray(localNetworkAddresses))
}
if (settings.splitTunnelMode == SettingsStore.SplitTunnelMode.INCLUDE) {
@@ -248,6 +304,7 @@ object SingBoxConfigBuilder {
put("tag", "mixed-in")
put("listen", settings.socksBindAddress)
put("listen_port", settings.socksPort)
+ put("reuse_addr", true)
if (settings.localProxyAuthEnabled) put("users", proxyUsers(settings))
}
inbounds.put(mixedInbound)
@@ -280,53 +337,50 @@ object SingBoxConfigBuilder {
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("domain_resolver", activeBootstrapDnsTag)
put("dynamic_bearer_token", true)
put("version", targetVersion)
+ put("disable_version_fallback", true)
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",
- )
+ if (targetVersion != 3) {
+ 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",
+ )
+ }
}
}
}
@@ -356,7 +410,7 @@ object SingBoxConfigBuilder {
put("tag", "upstream-detour")
put("server", settings.upstreamProxyHost)
put("server_port", settings.upstreamProxyPort)
- put("domain_resolver", "local-dns")
+ put("domain_resolver", activeBootstrapDnsTag)
if (settings.upstreamProxyType == SettingsStore.UpstreamProxyType.SOCKS5)
put("version", "5")
if (settings.upstreamProxyUsername.isNotEmpty()) {
@@ -392,7 +446,7 @@ object SingBoxConfigBuilder {
put("action", "reject")
}
)
- if (settings.bypassLocalNetwork) {
+ if (settings.bypassLocalNetwork && localNetworkAddresses.isNotEmpty()) {
put(
JSONObject().apply {
put("ip_cidr", JSONArray(localNetworkAddresses))
@@ -400,7 +454,7 @@ object SingBoxConfigBuilder {
}
)
}
- if (!settings.ipv6Enabled) {
+ if (!settings.ipMode.hasIpv6) {
put(
JSONObject().apply {
put("ip_version", 6)
@@ -408,13 +462,50 @@ object SingBoxConfigBuilder {
}
)
}
+ if (!settings.ipMode.hasIpv4) {
+ put(
+ JSONObject().apply {
+ put("ip_version", 4)
+ put("action", "reject")
+ }
+ )
+ }
+ put(
+ JSONObject().apply {
+ put(
+ "domain",
+ JSONArray().apply {
+ put("mtalk.google.com")
+ for (i in 1..8) {
+ put("alt$i-mtalk.google.com")
+ }
+ },
+ )
+ put("override_port", 443)
+ put("outbound", "proxy-out")
+ }
+ )
+ put(
+ JSONObject().apply {
+ put(
+ "port",
+ JSONArray().apply {
+ put(5228)
+ put(5229)
+ put(5230)
+ },
+ )
+ put("override_port", 443)
+ put("outbound", "proxy-out")
+ }
+ )
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_domain_resolver", activeBootstrapDnsTag)
put("default_http_client", "default-http-client")
}
root.put("route", route)
diff --git a/app/src/main/res/drawable/ic_notification_update.xml b/app/src/main/res/drawable/ic_notification_update.xml
new file mode 100644
index 00000000..e1361d31
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notification_update.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c91f4d91..a448ca98 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -9,6 +9,9 @@
Local proxy
Upstream proxy
HTTP / SOCKS5 · %1$s:%2$d
+ Listen address
+ This device only (Loopback)
+ All interfaces (Allow LAN devices)
Local address: %1$s
HTTP / SOCKS5 port
The same port supports HTTP and SOCKS5. Controller port %1$d is reserved.
@@ -37,6 +40,7 @@
Latency test URL
Tunnel MTU
DNS provider
+ Bootstrap DNS
Proxy-only mode
Run local proxy servers without VPN
Custom edge address
@@ -44,9 +48,15 @@
TLS fragmentation
Browser TLS fingerprint
Multiplex concurrency
- TCP Fast Open
- Reduce connection latency on supported networks
- IPv6 tunnel
+ Inactive (TCP only)
+ HTTP/2 only
+ IP protocol mode
+ Select which IP versions to route and the domain lookup strategy.
+ Dual-Stack
+ Prefer IPv4
+ Prefer IPv6
+ IPv4 Only
+ IPv6 Only
Tunnel health
Automatic connection recovery
Reconnect after repeated confirmed proxy-path failures; stay in the selected country. Applies on next connection.
@@ -56,7 +66,7 @@
Check for updates
Signed releases from GitHub
Automatic update checks
- Check once a day when the app is opened; installation always needs approval
+ Check once a day when the app is opened; notifies when a new release is available
Source code
Sign out
DoH URL
@@ -118,8 +128,6 @@
- %1$d apps
Disabled
- IPv4 only
- Dual-stack IPv4 and IPv6
Not connected
Checking HTTPS through the proxy
HTTPS proxy path is responding
@@ -153,6 +161,7 @@
Clear logs
DNS server used inside the tunnel.
+ DNS resolver used outside the tunnel for proxy endpoints, control-plane domains, and remote DoH bootstrapping.
Direct via local network
HTTPS DoH endpoint
Leave empty to resolve the server hostname normally.
@@ -162,19 +171,16 @@
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
+ Update available
+ FoxyVPN %1$s is available
+ App updates
+ Notifications when a newer app release is available
+ View release
+ Update available
+ Dismiss
+ FoxyVPN is up to date (%1$s)
+ Unable to check for updates: %1$s
Review the proxy address before saving the configuration without TLS.
Enter credentials valid for the selected proxy type, or clear both fields.
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
index 3db8c420..297ca921 100644
--- a/app/src/main/res/xml/file_paths.xml
+++ b/app/src/main/res/xml/file_paths.xml
@@ -1,5 +1,4 @@
-