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 @@ ![Sing-Box](https://img.shields.io/badge/Sing--Box-212121?style=for-the-badge&logo=go&logoColor=white) ![License](https://img.shields.io/badge/License_MIT-212121?style=for-the-badge) -*FoxyVPN 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 @@ -