From 72bfb3c035ff09404bcb8ce7f93bc18ac4b4bb8b Mon Sep 17 00:00:00 2001 From: Vauth <122077080+Vauth@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:12:08 +0330 Subject: [PATCH] v2.0.3 --- app/build.gradle.kts | 4 +- .../main/java/com/vauth/foxyvpn/FoxyVpnApp.kt | 22 + .../vauth/foxyvpn/data/ControlPlaneHttp.kt | 77 ++++ .../foxyvpn/data/FastlyChallengeSolver.kt | 48 +- .../foxyvpn/data/FirefoxAccountHeaders.kt | 4 +- .../vauth/foxyvpn/data/FoxyRemoteConfig.kt | 151 +++++- .../vauth/foxyvpn/data/FxaAuthRepository.kt | 27 +- .../com/vauth/foxyvpn/data/GuardianClient.kt | 21 +- .../vauth/foxyvpn/data/RelayerInterceptor.kt | 172 +++++++ .../vauth/foxyvpn/data/ServerListClient.kt | 8 +- .../com/vauth/foxyvpn/data/SettingsStore.kt | 68 +++ .../foxyvpn/ui/navigation/FoxyNavGraph.kt | 4 + .../vauth/foxyvpn/ui/screens/LoginScreen.kt | 181 ++++++-- .../foxyvpn/ui/screens/SettingsScreen.kt | 112 ++++- .../foxyvpn/ui/settings/RelayerDialogs.kt | 432 ++++++++++++++++++ .../java/com/vauth/foxyvpn/vpn/ExitCheck.kt | 9 +- .../com/vauth/foxyvpn/vpn/FoxyVpnService.kt | 5 +- app/src/main/res/values/strings.xml | 24 + relayer/README.md | 62 +++ relayer/worker.js | 171 +++++++ 20 files changed, 1486 insertions(+), 116 deletions(-) create mode 100644 app/src/main/java/com/vauth/foxyvpn/data/RelayerInterceptor.kt create mode 100644 app/src/main/java/com/vauth/foxyvpn/ui/settings/RelayerDialogs.kt create mode 100644 relayer/README.md create mode 100644 relayer/worker.js diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2ef5b8ea..71ee2418 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 = 48 - versionName = "2.0.2" + versionCode = 49 + versionName = "2.0.3" } splits { diff --git a/app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt b/app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt index 6e50ba14..acd375f8 100644 --- a/app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt +++ b/app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt @@ -10,9 +10,11 @@ import com.vauth.foxyvpn.data.FoxyRemoteConfig import com.vauth.foxyvpn.data.FxaAuthRepository import com.vauth.foxyvpn.data.GuardianClient import com.vauth.foxyvpn.data.ProxyStateStore +import com.vauth.foxyvpn.data.RelayerConfig import com.vauth.foxyvpn.data.ServerListClient import com.vauth.foxyvpn.data.SettingsStore import com.vauth.foxyvpn.data.TokenStore +import com.vauth.foxyvpn.data.UpstreamProxyConfig import com.vauth.foxyvpn.update.AppUpdater import com.vauth.foxyvpn.vpn.FoxyVpnService import com.vauth.foxyvpn.widget.QuickConnectWidgetProvider @@ -55,6 +57,26 @@ class FoxyVpnApp : Application(), AppContainer { ControlPlaneHttp.bootstrapDnsConfigProvider = { settingsStore.bootstrapDnsProvider to settingsStore.effectiveBootstrapDohUrl } + ControlPlaneHttp.upstreamProxyProvider = { + if (settingsStore.upstreamProxyEnabled && settingsStore.upstreamProxyHost.isNotBlank()) { + UpstreamProxyConfig( + type = settingsStore.upstreamProxyType, + host = settingsStore.upstreamProxyHost, + port = settingsStore.upstreamProxyPort, + username = settingsStore.upstreamProxyUsername, + password = settingsStore.upstreamProxyPassword, + ) + } else null + } + ControlPlaneHttp.relayerConfigProvider = { + RelayerConfig( + provider = settingsStore.relayerProvider, + customUrl = settingsStore.customRelayerUrl, + relayedEndpoints = settingsStore.relayedEndpoints, + availableRelayers = FoxyRemoteConfig.current.relayers, + failoverCodes = FoxyRemoteConfig.current.failoverCodes, + ) + } FoxyRemoteConfig.init(this) FoxyRemoteConfig.triggerRefresh() guardianClient = GuardianClient() 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 ea702838..da619f9d 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt @@ -2,20 +2,39 @@ package com.vauth.foxyvpn.data import android.net.Network import android.webkit.CookieManager +import java.io.IOException +import java.net.Authenticator as JavaAuthenticator import java.net.InetAddress import java.net.InetSocketAddress +import java.net.PasswordAuthentication +import java.net.Proxy +import java.net.ProxySelector import java.net.Socket +import java.net.SocketAddress +import java.net.URI import java.util.concurrent.TimeUnit import javax.net.SocketFactory +import okhttp3.Authenticator as OkHttpAuthenticator import okhttp3.Cookie import okhttp3.CookieJar +import okhttp3.Credentials import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Request +import okhttp3.Response +import okhttp3.Route import okhttp3.dnsoverhttps.DnsOverHttps +data class UpstreamProxyConfig( + val type: SettingsStore.UpstreamProxyType, + val host: String, + val port: Int, + val username: String = "", + val password: String = "", +) + fun Request.Builder.applyFirefoxHeaders(): Request.Builder = FirefoxAccountHeaders.apply(this) class SimpleCookieJar : CookieJar { @@ -129,6 +148,58 @@ object ControlPlaneHttp { @Volatile var bootstrapDnsConfigProvider: (() -> Pair)? = null + @Volatile var upstreamProxyProvider: (() -> UpstreamProxyConfig?)? = null + + @Volatile var relayerConfigProvider: (() -> RelayerConfig)? = null + + private val relayerInterceptor = RelayerInterceptor { relayerConfigProvider?.invoke() } + + init { + JavaAuthenticator.setDefault( + object : JavaAuthenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication? { + val config = upstreamProxyProvider?.invoke() ?: return null + if (requestorType == RequestorType.PROXY && config.username.isNotBlank()) { + return PasswordAuthentication(config.username, config.password.toCharArray()) + } + return null + } + } + ) + } + + private val dynamicProxySelector = + object : ProxySelector() { + override fun select(uri: URI?): List { + val config = upstreamProxyProvider?.invoke() + if (config != null && config.host.isNotBlank() && config.port in 1..65535) { + val proxyType = + if (config.type == SettingsStore.UpstreamProxyType.HTTP) { + Proxy.Type.HTTP + } else { + Proxy.Type.SOCKS + } + return listOf(Proxy(proxyType, InetSocketAddress.createUnresolved(config.host, config.port))) + } + return listOf(Proxy.NO_PROXY) + } + + override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {} + } + + private val dynamicProxyAuthenticator = + object : OkHttpAuthenticator { + override fun authenticate(route: Route?, response: Response): Request? { + val config = upstreamProxyProvider?.invoke() ?: return null + if (config.username.isBlank()) return null + if (response.request.header("Proxy-Authorization") != null) return null + val credential = Credentials.basic(config.username, config.password) + return response.request.newBuilder() + .header("Proxy-Authorization", credential) + .build() + } + } + internal fun underlyingNetwork(): Network? { val provider = networkProvider ?: return null return provider() ?: throw java.io.IOException("No underlying network is available") @@ -137,6 +208,9 @@ object ControlPlaneHttp { private val dohBootstrapClient: OkHttpClient by lazy { OkHttpClient.Builder() .protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1)) + .addInterceptor(relayerInterceptor) + .proxySelector(dynamicProxySelector) + .proxyAuthenticator(dynamicProxyAuthenticator) .socketFactory(ProtectingSocketFactory()) .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) @@ -199,7 +273,10 @@ object ControlPlaneHttp { val client: OkHttpClient by lazy { OkHttpClient.Builder() .protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1)) + .addInterceptor(relayerInterceptor) .cookieJar(cookieJar) + .proxySelector(dynamicProxySelector) + .proxyAuthenticator(dynamicProxyAuthenticator) .socketFactory(ProtectingSocketFactory()) .dns( object : okhttp3.Dns { diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt b/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt index 974d7075..5ca4f645 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt @@ -37,6 +37,33 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { val solverMutex = Mutex() } + private fun allowedHosts(): Set { + val hosts = mutableSetOf( + "vpn.mozilla.org", + "api.accounts.firefox.com", + "oauth.accounts.firefox.com", + "accounts.firefox.com", + ) + val endpoints = FoxyRemoteConfig.current.endpoints + val urls = listOf( + endpoints.controlPlane.login, + endpoints.controlPlane.recoveryStatus, + endpoints.controlPlane.sessionDestroy, + endpoints.controlPlane.oauthToken, + endpoints.controlPlane.oauthFallback, + endpoints.controlPlane.authorization, + endpoints.guardian.token, + endpoints.guardian.status, + endpoints.guardian.activate, + endpoints.serverList, + endpoints.productDetails, + ) + for (url in urls) { + runCatching { url.toHttpUrl().host }.getOrNull()?.let { hosts.add(it) } + } + return hosts + } + suspend fun solveAndInstall( challengedBody: String? = null, challengedOrigin: String? = null, @@ -47,18 +74,13 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { withContext(Dispatchers.IO) { val origin = (challengedOrigin ?: "https://api.accounts.firefox.com").toHttpUrl() + val trustedHosts = allowedHosts() require( origin.scheme == "https" && origin.port == 443 && origin.username.isEmpty() && origin.password.isEmpty() && - origin.host in - setOf( - "vpn.mozilla.org", - "api.accounts.firefox.com", - "oauth.accounts.firefox.com", - "accounts.firefox.com", - ) + origin.host in trustedHosts ) { "Untrusted challenge origin" } @@ -69,9 +91,13 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { solveChallengePage(challengedBody, base, userAgent, solver, solverJar) return@withContext } + val defaultBase = + runCatching { FoxyRemoteConfig.current.endpoints.controlPlane.oauthToken.toHttpUrl() } + .getOrNull()?.let { "https://${it.host}" } ?: "https://api.accounts.firefox.com" val bases = listOf( base, + defaultBase, "https://api.accounts.firefox.com", "https://accounts.firefox.com", ).distinct() @@ -291,13 +317,7 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { if (cookies.isEmpty()) throw FastlyChallengeError("challenge completed but no cookies were issued") - val targetHosts = - listOf( - "api.accounts.firefox.com", - "accounts.firefox.com", - "oauth.accounts.firefox.com", - "vpn.mozilla.org", - ) + val targetHosts = allowedHosts() for (cookie in cookies) { val hostClean = cookie.domain.trimStart('.') diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FirefoxAccountHeaders.kt b/app/src/main/java/com/vauth/foxyvpn/data/FirefoxAccountHeaders.kt index 62c00e57..64a2164c 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/FirefoxAccountHeaders.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/FirefoxAccountHeaders.kt @@ -10,8 +10,6 @@ import okhttp3.Request import org.json.JSONObject internal object FirefoxAccountHeaders { - private const val PRODUCT_DETAILS_URL = - "https://product-details.mozilla.org/1.0/firefox_versions.json" private val mutex = Mutex() @Volatile private var major = "159" private var nextRefreshNanos: Long? = null @@ -45,7 +43,7 @@ internal object FirefoxAccountHeaders { try { withTimeoutOrNull(5_000L) { val request = - apply(Request.Builder().url(PRODUCT_DETAILS_URL)).get().build() + apply(Request.Builder().url(FoxyRemoteConfig.current.endpoints.productDetails)).get().build() client.newCall(request).awaitResponse().use { response -> if (!response.isSuccessful) return@withTimeoutOrNull false val text = diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FoxyRemoteConfig.kt b/app/src/main/java/com/vauth/foxyvpn/data/FoxyRemoteConfig.kt index 73109aa7..bfdcd9b5 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/FoxyRemoteConfig.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/FoxyRemoteConfig.kt @@ -23,8 +23,40 @@ data class ComponentConfig( val protocols: List = listOf(Protocol.HTTP_2, Protocol.HTTP_1_1), ) +data class GuardianEndpoints( + val token: String = "https://vpn.mozilla.org/api/v1/fpn/token", + val status: String = "https://vpn.mozilla.org/api/v1/fpn/status", + val activate: String = "https://vpn.mozilla.org/api/v1/fpn/activate", +) + +data class ControlPlaneEndpoints( + val oauthToken: String = "https://oauth.accounts.firefox.com/v1/token", + val oauthFallback: String = "https://api.accounts.firefox.com/v1/oauth/token", + val authorization: String = "https://api.accounts.firefox.com/v1/oauth/authorization", + val recoveryStatus: String = "https://api.accounts.firefox.com/v1/recovery_email/status", + val sessionDestroy: String = "https://api.accounts.firefox.com/v1/session/destroy", + val login: String = "https://accounts.firefox.com/?context=fx_desktop_v3&service=sync&action=email", +) + +data class EndpointsConfig( + val guardian: GuardianEndpoints = GuardianEndpoints(), + val controlPlane: ControlPlaneEndpoints = ControlPlaneEndpoints(), + val serverList: String = "https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/vpn-serverlist/records", + val productDetails: String = "https://product-details.mozilla.org/1.0/firefox_versions.json", + val exitCheck: String = "https://www.cloudflare.com/cdn-cgi/trace", + val latencyProbe: String = "https://www.gstatic.com/generate_204", +) + +val DEFAULT_RELAYERS: Map = emptyMap() +val DEFAULT_RELAYER_ROUTES: Set = setOf("guardian") +val DEFAULT_FAILOVER_CODES: Set = setOf(451) + data class RemoteConfig( val version: Int, + val endpoints: EndpointsConfig = EndpointsConfig(), + val relayerRoutes: Set = DEFAULT_RELAYER_ROUTES, + val relayers: Map = DEFAULT_RELAYERS, + val failoverCodes: Set = DEFAULT_FAILOVER_CODES, val controlPlane: ComponentConfig, val guardian: ComponentConfig, val solver: ComponentConfig, @@ -91,6 +123,10 @@ private val DEFAULT_BROWSER = val DEFAULT_REMOTE_CONFIG = RemoteConfig( version = 1, + endpoints = EndpointsConfig(), + relayerRoutes = DEFAULT_RELAYER_ROUTES, + relayers = DEFAULT_RELAYERS, + failoverCodes = DEFAULT_FAILOVER_CODES, controlPlane = DEFAULT_CONTROL_PLANE, guardian = DEFAULT_GUARDIAN, solver = DEFAULT_SOLVER, @@ -131,6 +167,9 @@ object FoxyRemoteConfig { if (parsed != null) { current = parsed lastRawJson = text + AppLogger.i(TAG, "loaded cached config (version ${parsed.version})") + } else { + AppLogger.w(TAG, "could not parse cached config; using defaults") } } } @@ -153,11 +192,21 @@ object FoxyRemoteConfig { val fetchedJson = fetchRemoteJson(client, PRIMARY_URL) ?: fetchRemoteJson(client, FALLBACK_URL) - ?: return@withContext + ?: run { + AppLogger.w(TAG, "could not fetch remote config from primary or fallback") + return@withContext + } - if (fetchedJson == lastRawJson) return@withContext + if (fetchedJson.trim() == lastRawJson.trim()) { + AppLogger.d(TAG, "remote config is up to date (version ${current.version})") + return@withContext + } - val parsed = parseConfig(fetchedJson) ?: return@withContext + val parsed = parseConfig(fetchedJson) + if (parsed == null) { + AppLogger.w(TAG, "could not parse fetched remote config") + return@withContext + } val file = File(context.filesDir, FILE_NAME) val tempFile = File(context.filesDir, "$FILE_NAME.tmp") @@ -176,7 +225,9 @@ object FoxyRemoteConfig { if (writeSuccess) { current = parsed lastRawJson = fetchedJson - AppLogger.i(TAG, "config updated") + AppLogger.i(TAG, "config updated to version ${parsed.version}") + } else { + AppLogger.w(TAG, "failed to persist remote config to disk") } } } @@ -192,14 +243,18 @@ object FoxyRemoteConfig { .header("Cache-Control", "no-cache") .build() client.newCall(request).awaitResponse().use { response -> - if (!response.isSuccessful) return@withTimeoutOrNull null + if (!response.isSuccessful) { + AppLogger.w(TAG, "fetching $url returned HTTP ${response.code}") + return@withTimeoutOrNull null + } val text = response.body?.readBoundedText(128 * 1024).orEmpty().trim() if (text.startsWith("{") && text.endsWith("}")) text else null } } } catch (cancellation: CancellationException) { throw cancellation - } catch (_: Exception) { + } catch (e: Exception) { + AppLogger.w(TAG, "failed to fetch config from $url: ${e.message}") null } } @@ -209,6 +264,44 @@ object FoxyRemoteConfig { val root = JSONObject(jsonString) val version = root.optInt("version", 1) + fun parseEndpoints(): EndpointsConfig { + val defaultEndpoints = EndpointsConfig() + val endpointsObj = root.optJSONObject("endpoints") ?: return defaultEndpoints + val guardianObj = endpointsObj.optJSONObject("guardian") + val guardian = + if (guardianObj != null) { + val d = defaultEndpoints.guardian + GuardianEndpoints( + token = guardianObj.optString("token", "").ifBlank { d.token }, + status = guardianObj.optString("status", "").ifBlank { d.status }, + activate = guardianObj.optString("activate", "").ifBlank { d.activate }, + ) + } else defaultEndpoints.guardian + + val cpObj = endpointsObj.optJSONObject("controlPlane") + val controlPlane = + if (cpObj != null) { + val d = defaultEndpoints.controlPlane + ControlPlaneEndpoints( + oauthToken = cpObj.optString("oauthToken", "").ifBlank { d.oauthToken }, + oauthFallback = cpObj.optString("oauthFallback", "").ifBlank { d.oauthFallback }, + authorization = cpObj.optString("authorization", "").ifBlank { d.authorization }, + recoveryStatus = cpObj.optString("recoveryStatus", "").ifBlank { d.recoveryStatus }, + sessionDestroy = cpObj.optString("sessionDestroy", "").ifBlank { d.sessionDestroy }, + login = cpObj.optString("login", "").ifBlank { d.login }, + ) + } else defaultEndpoints.controlPlane + + return EndpointsConfig( + guardian = guardian, + controlPlane = controlPlane, + serverList = endpointsObj.optString("serverList", "").ifBlank { defaultEndpoints.serverList }, + productDetails = endpointsObj.optString("productDetails", "").ifBlank { defaultEndpoints.productDetails }, + exitCheck = endpointsObj.optString("exitCheck", "").ifBlank { defaultEndpoints.exitCheck }, + latencyProbe = endpointsObj.optString("latencyProbe", "").ifBlank { defaultEndpoints.latencyProbe }, + ) + } + fun parseComponent(name: String, default: ComponentConfig): ComponentConfig { val obj = root.optJSONObject(name) ?: return default val headersObj = obj.optJSONObject("headers") @@ -243,8 +336,54 @@ object FoxyRemoteConfig { return ComponentConfig(headers = headers, protocols = protocols) } + fun parseRelayers(): Map { + val obj = root.optJSONObject("relayers") ?: return emptyMap() + val result = mutableMapOf() + for (key in obj.keys()) { + val raw = obj.optString(key, "").trim() + if (raw.isNotEmpty()) { + val url = + if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) { + raw + } else { + "https://$raw" + } + result[key] = url + } + } + return result + } + + fun parseRelayerRoutes(): Set { + val arr = root.optJSONArray("relayerRoutes") ?: return DEFAULT_RELAYER_ROUTES + val set = mutableSetOf() + for (i in 0 until arr.length()) { + val item = arr.optString(i, "").trim().lowercase() + if (item.isNotEmpty()) { + set.add(item) + } + } + return if (set.isNotEmpty()) set else DEFAULT_RELAYER_ROUTES + } + + fun parseFailoverCodes(): Set { + val arr = root.optJSONArray("failoverCodes") ?: return DEFAULT_FAILOVER_CODES + val set = mutableSetOf() + for (i in 0 until arr.length()) { + val code = arr.optInt(i, -1) + if (code in 100..599) { + set.add(code) + } + } + return if (set.isNotEmpty()) set else DEFAULT_FAILOVER_CODES + } + RemoteConfig( version = version, + endpoints = parseEndpoints(), + relayerRoutes = parseRelayerRoutes(), + relayers = parseRelayers(), + failoverCodes = parseFailoverCodes(), controlPlane = parseComponent("controlPlane", DEFAULT_CONTROL_PLANE), guardian = parseComponent("guardian", DEFAULT_GUARDIAN), solver = parseComponent("solver", DEFAULT_SOLVER), diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt b/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt index 776beab3..d93f2b62 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt @@ -51,8 +51,6 @@ class FxaRefreshFailure(message: String, val permanent: Boolean, cause: Throwabl class FastlyBlockedException(message: String, cause: Throwable? = null) : Exception(message, cause) -private const val FXA_AUTH_SERVER = "https://api.accounts.firefox.com/v1" -private const val FXA_OAUTH_SERVER = "https://oauth.accounts.firefox.com/v1" private const val FIREFOX_CLIENT_ID = "5882386c6d801776" private const val OAUTH_SCOPE = "profile https://identity.mozilla.com/apps/vpn" private const val PROTOCOL_VERSION = "identity.mozilla.com/picl/v1/" @@ -61,9 +59,6 @@ private const val DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 24 * 60 * 60 private const val MAX_CONFIRM_ATTEMPTS = 100 private const val CONFIRM_POLL_DELAY_MS = 3000L -private const val WEBCHANNEL_LOGIN_URL = - "https://accounts.firefox.com/?context=fx_desktop_v3&service=sync&action=email" - private val jsonMediaType = "application/json".toMediaType() class FxaAuthRepository( @@ -81,13 +76,13 @@ class FxaAuthRepository( .build() val storageError = tokenStore.storageError - fun getBrowserLoginUrl(): String = WEBCHANNEL_LOGIN_URL + fun getBrowserLoginUrl(): String = FoxyRemoteConfig.current.endpoints.controlPlane.login suspend fun isSessionVerified(sessionToken: String): Boolean = withContext(Dispatchers.IO) { val clean = sessionToken.trim().replace(Regex("[\\s\"]"), "").lowercase() if (!Regex("^[0-9a-f]{64}$").matches(clean)) return@withContext false - val url = "$FXA_AUTH_SERVER/recovery_email/status".toHttpUrl() + val url = FoxyRemoteConfig.current.endpoints.controlPlane.recoveryStatus.toHttpUrl() val (tokenId, hmacKey) = deriveHawkCredentials(clean) val header = hawkHeader("GET", url, tokenId, hmacKey, ByteArray(0)) val request = @@ -229,9 +224,9 @@ class FxaAuthRepository( try { FirefoxAccountHeaders.refresh(client) try { - executeFxaRequest(FXA_OAUTH_SERVER, "/token", jsonBody = body) + executeFxaRequest(fullUrl = FoxyRemoteConfig.current.endpoints.controlPlane.oauthToken, jsonBody = body) } catch (_: FastlyBlockedException) { - executeFxaRequest(FXA_AUTH_SERVER, "/oauth/token", jsonBody = body) + executeFxaRequest(fullUrl = FoxyRemoteConfig.current.endpoints.controlPlane.oauthFallback, jsonBody = body) } } catch (rejected: FxaApiError) { throw FxaRefreshFailure( @@ -304,7 +299,7 @@ class FxaAuthRepository( withContext(Dispatchers.IO) { val clean = sessionToken.trim().replace(Regex("[\\s\"]"), "").lowercase() if (!Regex("^[0-9a-f]{64}$").matches(clean)) return@withContext - val url = "$FXA_AUTH_SERVER/session/destroy".toHttpUrl() + val url = FoxyRemoteConfig.current.endpoints.controlPlane.sessionDestroy.toHttpUrl() val (tokenId, hmacKey) = deriveHawkCredentials(clean) val bodyBytes = "{}".toByteArray(Charsets.UTF_8) val header = hawkHeader("POST", url, tokenId, hmacKey, bodyBytes) @@ -405,8 +400,7 @@ class FxaAuthRepository( val authResponse = executeFxaRequest( - baseUrl = FXA_AUTH_SERVER, - path = "/oauth/authorization", + fullUrl = FoxyRemoteConfig.current.endpoints.controlPlane.authorization, jsonBody = authorizeBody, sessionToken = sessionToken, ) @@ -427,8 +421,7 @@ class FxaAuthRepository( val tokens = executeFxaRequest( - baseUrl = FXA_OAUTH_SERVER, - path = "/token", + fullUrl = FoxyRemoteConfig.current.endpoints.controlPlane.oauthToken, jsonBody = tokenBody, sessionToken = null, ) @@ -440,17 +433,15 @@ class FxaAuthRepository( tokens } - private suspend fun executeFxaRequest( - baseUrl: String, - path: String, + fullUrl: String, method: String = "POST", jsonBody: JSONObject, sessionToken: String? = null, ): JSONObject = withTimeout(120_000L) { withContext(Dispatchers.IO) { - val url = "$baseUrl$path".toHttpUrl() + val url = fullUrl.toHttpUrl() val origin = "${url.scheme}://${url.host}" val bodyBytes = jsonBody.toString().toByteArray(Charsets.UTF_8) val hawkCreds = sessionToken?.let { deriveHawkCredentials(it) } diff --git a/app/src/main/java/com/vauth/foxyvpn/data/GuardianClient.kt b/app/src/main/java/com/vauth/foxyvpn/data/GuardianClient.kt index 8e264abf..d897fd07 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/GuardianClient.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/GuardianClient.kt @@ -51,7 +51,12 @@ class GuardianClient( suspend fun fetchProxyPass(endpoint: String, accessToken: String): ProxyPass = withTimeout(120_000L) { withContext(Dispatchers.IO) { - val url = "${endpoint.trimEnd('/')}/api/v1/fpn/token" + val url = + if (endpoint == GUARDIAN_ENDPOINT_DEFAULT || endpoint.isBlank()) { + FoxyRemoteConfig.current.endpoints.guardian.token + } else { + "${endpoint.trimEnd('/')}/api/v1/fpn/token" + } authorizedRequest("GET", url, accessToken).use { resp -> when (resp.code) { 401, @@ -125,7 +130,12 @@ class GuardianClient( AppLogger.w(TAG, "could not fetch proxy pass for quota", e) } - val statusUrl = "${endpoint.trimEnd('/')}/api/v1/fpn/status" + val statusUrl = + if (endpoint == GUARDIAN_ENDPOINT_DEFAULT || endpoint.isBlank()) { + FoxyRemoteConfig.current.endpoints.guardian.status + } else { + "${endpoint.trimEnd('/')}/api/v1/fpn/status" + } val statusEntitlement = runCatching { authorizedRequest("GET", statusUrl, accessToken).use { resp -> @@ -165,7 +175,12 @@ class GuardianClient( suspend fun activateGuardian(endpoint: String, accessToken: String): Entitlement = withTimeout(120_000L) { withContext(Dispatchers.IO) { - val url = "${endpoint.trimEnd('/')}/api/v1/fpn/activate" + val url = + if (endpoint == GUARDIAN_ENDPOINT_DEFAULT || endpoint.isBlank()) { + FoxyRemoteConfig.current.endpoints.guardian.activate + } else { + "${endpoint.trimEnd('/')}/api/v1/fpn/activate" + } authorizedRequest("POST", url, accessToken).use { resp -> val text = resp.body?.readBoundedText().orEmpty() if (resp.code != 200) { diff --git a/app/src/main/java/com/vauth/foxyvpn/data/RelayerInterceptor.kt b/app/src/main/java/com/vauth/foxyvpn/data/RelayerInterceptor.kt new file mode 100644 index 00000000..31a59963 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/data/RelayerInterceptor.kt @@ -0,0 +1,172 @@ +package com.vauth.foxyvpn.data + +import android.util.Base64 +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response + +data class RelayerConfig( + val provider: String, + val customUrl: String, + val relayedEndpoints: Set, + val availableRelayers: Map, + val failoverCodes: Set = DEFAULT_FAILOVER_CODES, +) + +private const val DIRECT_CONNECT_TIMEOUT_SECONDS = 5 + +class RelayerInterceptor( + private val configProvider: () -> RelayerConfig? +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val config = configProvider() ?: return chain.proceed(chain.request()) + if (config.provider == SettingsStore.RELAYER_DIRECT) { + return chain.proceed(chain.request()) + } + + val request = chain.request() + if (request.body?.isOneShot() == true) { + return chain.proceed(request) + } + + val urlStr = request.url.toString() + val shouldRelay = config.relayedEndpoints.any { matchesCategory(urlStr, it) } + if (!shouldRelay) { + return chain.proceed(request) + } + + val candidates = getCandidateRelayers(config) + if (candidates.isEmpty()) { + return chain.proceed(request) + } + + var directException: Exception? = null + var directFailureResponse: Response? = null + try { + val directResponse = chain.withConnectTimeout(DIRECT_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS).proceed(request) + if (directResponse.isSuccessful || directResponse.code !in config.failoverCodes) { + return directResponse + } + AppLogger.i("Relayer", "Direct connection returned status ${directResponse.code}, starting relayer failover") + directFailureResponse = directResponse + } catch (e: Exception) { + if (e is CancellationException) throw e + AppLogger.i("Relayer", "Direct connection failed (${e.message ?: e::class.java.simpleName}), starting relayer failover") + directException = e + } + + for (relayerUrl in candidates) { + try { + AppLogger.i("Relayer", "Attempting request via $relayerUrl") + val relayed = buildRelayedRequest(request, relayerUrl) + val response = chain.proceed(relayed) + if (response.code !in config.failoverCodes) { + AppLogger.i("Relayer", "Request succeeded via $relayerUrl (status ${response.code})") + directFailureResponse?.close() + return response + } + AppLogger.w("Relayer", "Relayer $relayerUrl returned failover status ${response.code}") + response.close() + } catch (e: Exception) { + if (e is CancellationException) throw e + AppLogger.w("Relayer", "Relayer $relayerUrl failed (${e.message ?: e::class.java.simpleName})") + } + } + + AppLogger.w("Relayer", "All candidate relayers failed for request") + if (directFailureResponse != null) { + return directFailureResponse + } + if (directException != null) { + when (directException) { + is IOException -> throw directException + is RuntimeException -> throw directException + else -> throw IOException(directException) + } + } + return chain.proceed(request) + } + + private fun getCandidateRelayers(config: RelayerConfig): List { + val provider = config.provider.trim() + + if (provider.equals(SettingsStore.RELAYER_CUSTOM, ignoreCase = true)) { + val custom = config.customUrl.trim() + return if (custom.isNotBlank() && SettingsStore.isValidRelayerUrl(custom)) { + listOf(custom) + } else { + emptyList() + } + } + + if (provider.equals(SettingsStore.RELAYER_AUTOMATIC, ignoreCase = true)) { + val list = mutableListOf() + config.availableRelayers.values.forEach { url -> + val trimmed = url.trim() + if (trimmed.isNotBlank() && !list.contains(trimmed)) list.add(trimmed) + } + return list + } + + val match = config.availableRelayers.entries.firstOrNull { + it.key.equals(provider, ignoreCase = true) + } + if (match != null && match.value.isNotBlank()) { + return listOf(match.value.trim()) + } else if (SettingsStore.isValidRelayerUrl(provider)) { + return listOf(provider) + } + + return emptyList() + } + + private fun buildRelayedRequest(originalRequest: Request, relayerBaseUrl: String): Request { + val originalUrl = originalRequest.url.toString() + val b64 = Base64.encodeToString( + originalUrl.toByteArray(Charsets.UTF_8), + Base64.URL_SAFE or Base64.NO_WRAP, + ).trim() + val rawBase = relayerBaseUrl.trim() + val base = if (rawBase.startsWith("http://", ignoreCase = true) || rawBase.startsWith("https://", ignoreCase = true)) { + rawBase + } else { + "https://$rawBase" + } + val relayerUrl = when { + base.endsWith("?url=") -> "$base$b64" + base.endsWith("&url=") -> "$base$b64" + base.contains("?") -> "${base}&url=$b64" + else -> "${base.trimEnd('/')}/?url=$b64" + } + return originalRequest.newBuilder() + .url(relayerUrl) + .build() + } + + private fun matchesCategory(urlStr: String, category: String): Boolean { + val ep = FoxyRemoteConfig.current.endpoints + return when (category) { + "guardian" -> + urlStr == ep.guardian.token || + urlStr == ep.guardian.status || + urlStr == ep.guardian.activate || + urlStr.contains("/api/v1/fpn/") + "fxa" -> + urlStr == ep.controlPlane.oauthToken || + urlStr == ep.controlPlane.oauthFallback || + urlStr == ep.controlPlane.authorization || + urlStr == ep.controlPlane.recoveryStatus || + urlStr == ep.controlPlane.sessionDestroy || + urlStr.contains("accounts.firefox.com") + "config" -> + urlStr.contains("FoxyConfig") || urlStr.contains("foxy_config") + "server_list" -> + urlStr == ep.serverList || urlStr.contains("vpn-serverlist") + else -> false + } + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/data/ServerListClient.kt b/app/src/main/java/com/vauth/foxyvpn/data/ServerListClient.kt index 809047c6..62e1413e 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/ServerListClient.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/ServerListClient.kt @@ -19,9 +19,6 @@ import org.json.JSONObject private const val TAG = "ServerListClient" -private const val REMOTE_SETTINGS_URL = - "https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/vpn-serverlist/records" - const val RECOMMENDED_COUNTRY_CODE = com.vauth.foxyvpn.data.model.RECOMMENDED_COUNTRY_CODE private val EXCLUDED_COUNTRY_NAMES = setOf("CatchAll Anycast") @@ -42,7 +39,10 @@ class ServerListClient( suspend fun fetchCountries(): List = withContext(Dispatchers.IO) { val request = - Request.Builder().url(REMOTE_SETTINGS_URL).applyFirefoxHeaders().build() + Request.Builder() + .url(FoxyRemoteConfig.current.endpoints.serverList) + .applyFirefoxHeaders() + .build() client.newCall(request).awaitResponse().use { response -> if (!response.isSuccessful) { throw IOException("Remote Settings fetch failed: HTTP ${response.code}") 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 baa8784f..1ebde001 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/SettingsStore.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/SettingsStore.kt @@ -86,6 +86,7 @@ class SettingsStore(context: Context) { var latencyProbeUrl: String get() = prefs.getString(KEY_LATENCY_PROBE_URL, null)?.takeIf { isValidProbeUrl(it) } + ?: FoxyRemoteConfig.current.endpoints.latencyProbe.takeIf { isValidProbeUrl(it) } ?: DEFAULT_LATENCY_PROBE_URL set(value) { val url = value.trim() @@ -276,6 +277,49 @@ class SettingsStore(context: Context) { else -> bootstrapDnsProvider.dohUrl } + var relayerProvider: String + get() = prefs.getString(KEY_RELAYER_PROVIDER, RELAYER_AUTOMATIC) ?: RELAYER_AUTOMATIC + set(value) { + prefs.edit().putString(KEY_RELAYER_PROVIDER, value.trim()).apply() + } + + var customRelayerUrl: String + get() = prefs.getString(KEY_CUSTOM_RELAYER_URL, "") ?: "" + set(value) { + val trimmed = value.trim() + require(trimmed.isEmpty() || isValidRelayerUrl(trimmed)) { + "Relayer URL must be a valid HTTP or HTTPS address" + } + val normalized = if (trimmed.isNotEmpty() && !trimmed.startsWith("http://", ignoreCase = true) && !trimmed.startsWith("https://", ignoreCase = true)) { + "https://$trimmed" + } else { + trimmed + } + prefs.edit().putString(KEY_CUSTOM_RELAYER_URL, normalized).apply() + } + + fun saveRelayer(provider: String, customUrl: String) { + val trimmed = customUrl.trim() + require(provider != RELAYER_CUSTOM || isValidRelayerUrl(trimmed)) { + "Enter a valid HTTP or HTTPS relayer URL" + } + val normalized = if (trimmed.isNotEmpty() && !trimmed.startsWith("http://", ignoreCase = true) && !trimmed.startsWith("https://", ignoreCase = true)) { + "https://$trimmed" + } else { + trimmed + } + prefs.edit() + .putString(KEY_RELAYER_PROVIDER, provider.trim()) + .putString(KEY_CUSTOM_RELAYER_URL, normalized) + .apply() + } + + var relayedEndpoints: Set + get() = prefs.getStringSet(KEY_RELAYED_ENDPOINTS, null) ?: FoxyRemoteConfig.current.relayerRoutes + set(value) { + prefs.edit().putStringSet(KEY_RELAYED_ENDPOINTS, value).apply() + } + var tlsFragmentMode: TlsFragmentMode get() = TlsFragmentMode.entries.firstOrNull { @@ -610,6 +654,30 @@ class SettingsStore(context: Context) { } .getOrDefault(false) + const val RELAYER_AUTOMATIC = "AUTOMATIC" + const val RELAYER_DIRECT = "DIRECT" + const val RELAYER_CUSTOM = "CUSTOM" + val DEFAULT_RELAYED_ENDPOINTS = setOf("guardian") + private const val KEY_RELAYER_PROVIDER = "relayer_provider" + private const val KEY_CUSTOM_RELAYER_URL = "custom_relayer_url" + private const val KEY_RELAYED_ENDPOINTS = "relayed_endpoints" + + fun isValidRelayerUrl(value: String): Boolean = + runCatching { + val trimmed = value.trim() + val normalized = if (trimmed.startsWith("http://", ignoreCase = true) || trimmed.startsWith("https://", ignoreCase = true)) { + trimmed + } else { + "https://$trimmed" + } + val uri = java.net.URI(normalized) + (uri.scheme.equals("https", ignoreCase = true) || uri.scheme.equals("http", ignoreCase = true)) && + !uri.host.isNullOrBlank() && + uri.rawUserInfo == null && + uri.rawFragment == null && + (uri.port == -1 || uri.port in 1..65_535) + }.getOrDefault(false) + private const val KEY_THEME_MODE = "theme_mode" private const val KEY_CUSTOM_MTU = "custom_mtu" private const val KEY_CACHED_REAL_IP = "cached_real_ip" 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 59490d67..b513f02e 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 @@ -67,11 +67,13 @@ fun FoxyNavGraph( composable(FoxyRoutes.LOGIN) { LoginScreen( authRepository = container.authRepository, + themeController = themeController, onSignedIn = { navController.navigate(FoxyRoutes.HOME) { popUpTo(FoxyRoutes.LOGIN) { inclusive = true } } }, + onOpenSettings = { navController.navigate(FoxyRoutes.SETTINGS) }, ) } composable(FoxyRoutes.HOME) { @@ -117,8 +119,10 @@ fun FoxyNavGraph( var autoUpdates by remember { mutableStateOf(updater?.automaticChecks ?: false) } val manualUpdate by updater?.manualUpdate?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) } + val isSignedIn = remember(container.tokenStore.sessionEpoch) { container.tokenStore.hasStoredSession() } SettingsScreen( settingsStore = container.settingsStore, + isSignedIn = isSignedIn, onOpenLogs = { navController.navigate(FoxyRoutes.LOGS) }, signOutInProgress = signingOut, onCheckForUpdates = { diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt index feafcc24..e5ac9075 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt @@ -1,5 +1,8 @@ package com.vauth.foxyvpn.ui.screens +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,28 +13,50 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BrightnessAuto +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vauth.foxyvpn.R import com.vauth.foxyvpn.data.FxaAuthRepository +import com.vauth.foxyvpn.data.ThemeMode import com.vauth.foxyvpn.ui.LoginViewModel +import com.vauth.foxyvpn.ui.theme.ThemeController +@OptIn(ExperimentalFoundationApi::class) @Composable -fun LoginScreen(authRepository: FxaAuthRepository, onSignedIn: () -> Unit) { +fun LoginScreen( + authRepository: FxaAuthRepository, + themeController: ThemeController? = null, + onSignedIn: () -> Unit, + onOpenSettings: () -> Unit = {}, +) { val model: LoginViewModel = androidx.lifecycle.viewmodel.compose.viewModel( factory = remember(authRepository) { LoginViewModel.factory(authRepository) } @@ -41,6 +66,8 @@ fun LoginScreen(authRepository: FxaAuthRepository, onSignedIn: () -> Unit) { val completingBrowserLogin = uiState.isLoading val errorMessage = uiState.errorMessage val storageError by authRepository.storageError.collectAsStateWithLifecycle() + val haptics = LocalHapticFeedback.current + val systemInDarkTheme = isSystemInDarkTheme() androidx.compose.runtime.LaunchedEffect(uiState.signedIn) { if (uiState.signedIn) onSignedIn() } if (browserStartUrl != null) { @@ -54,56 +81,118 @@ fun LoginScreen(authRepository: FxaAuthRepository, onSignedIn: () -> Unit) { return } - Box( - modifier = Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding().imePadding(), - contentAlignment = Alignment.Center, - ) { - Column( + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.app_name)) }, + actions = { + if (themeController != null) { + val mode = themeController.mode + val showingDark = themeController.resolveDark(systemInDarkTheme) + Box( + modifier = + Modifier.size(48.dp) + .clip(CircleShape) + .combinedClickable( + role = Role.Button, + onClick = { themeController.toggle(systemInDarkTheme) }, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + themeController.set(ThemeMode.SYSTEM) + }, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = + when (mode) { + ThemeMode.SYSTEM -> Icons.Filled.BrightnessAuto + ThemeMode.LIGHT -> Icons.Filled.LightMode + ThemeMode.DARK -> Icons.Filled.DarkMode + }, + contentDescription = + when (mode) { + ThemeMode.SYSTEM -> + if (showingDark) { + stringResource(R.string.theme_desc_system_light) + } else { + stringResource(R.string.theme_desc_system_dark) + } + ThemeMode.LIGHT -> + stringResource(R.string.theme_desc_light) + ThemeMode.DARK -> + stringResource(R.string.theme_desc_dark) + }, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + IconButton(onClick = onOpenSettings) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = stringResource(R.string.settings_title), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) + } + ) { padding -> + Box( modifier = - Modifier.widthIn(max = 520.dp) - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(24.dp), - verticalArrangement = Arrangement.Center, + Modifier.fillMaxSize() + .padding(padding) + .navigationBarsPadding() + .imePadding(), + contentAlignment = Alignment.Center, ) { - Text( - stringResource(R.string.login_title), - style = MaterialTheme.typography.headlineMedium, - ) - Spacer(Modifier.height(8.dp)) - Text( - stringResource(R.string.login_desc), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - storageError?.let { - Spacer(Modifier.height(12.dp)) - Text(it, color = MaterialTheme.colorScheme.error) - } - Spacer(Modifier.height(24.dp)) - - Button( - onClick = model::startBrowserLogin, - enabled = !completingBrowserLogin, - modifier = Modifier.fillMaxWidth(), + Column( + modifier = + Modifier.widthIn(max = 520.dp) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.Center, ) { - Text(stringResource(R.string.login_btn_sign_in)) - } - Text( - stringResource(R.string.login_help), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 6.dp), - ) + Text( + stringResource(R.string.login_title), + style = MaterialTheme.typography.headlineMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.login_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + storageError?.let { + Spacer(Modifier.height(12.dp)) + Text(it, color = MaterialTheme.colorScheme.error) + } + Spacer(Modifier.height(24.dp)) - errorMessage?.let { - Spacer(Modifier.height(12.dp)) + Button( + onClick = model::startBrowserLogin, + enabled = !completingBrowserLogin, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.login_btn_sign_in)) + } Text( - text = it, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + stringResource(R.string.login_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 6.dp), ) + + errorMessage?.let { + Spacer(Modifier.height(12.dp)) + Text( + text = it, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + ) + } } } } 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 cef811c0..4ba315d5 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 @@ -49,6 +49,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vauth.foxyvpn.BuildConfig import com.vauth.foxyvpn.R +import com.vauth.foxyvpn.data.FoxyRemoteConfig import com.vauth.foxyvpn.data.ProxySettingsRepository import com.vauth.foxyvpn.data.SettingsStore import com.vauth.foxyvpn.ui.ProxyEditor @@ -61,9 +62,12 @@ 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.RelayedEndpointsDialog +import com.vauth.foxyvpn.ui.settings.RelayerProviderPickerDialog import com.vauth.foxyvpn.ui.settings.SplitTunnelDialog import com.vauth.foxyvpn.ui.settings.TlsFragmentPickerDialog import com.vauth.foxyvpn.ui.settings.UtlsFingerprintPickerDialog +import java.util.Locale import com.vauth.foxyvpn.update.ReleaseInfo import com.vauth.foxyvpn.vpn.FoxyVpnService import com.vauth.foxyvpn.vpn.TunnelHealth @@ -83,6 +87,8 @@ private enum class SettingsDialog { SPLIT_TUNNEL, HTTP_VERSION, IP_MODE, + RELAYER_PROVIDER, + RELAYER_ENDPOINTS, } @Composable @@ -91,6 +97,7 @@ fun SettingsScreen( onOpenLogs: () -> Unit, onSignOut: () -> Unit, onBack: () -> Unit, + isSignedIn: Boolean = true, signOutInProgress: Boolean = false, onCheckForUpdates: (() -> Unit)? = null, manualUpdate: ReleaseInfo? = null, @@ -119,6 +126,9 @@ fun SettingsScreen( var utlsFingerprint by remember { mutableStateOf(settingsStore.utlsFingerprint) } var maxConcurrentStreams by remember { mutableIntStateOf(settingsStore.maxConcurrentStreams) } var ipMode by remember { mutableStateOf(settingsStore.ipMode) } + var relayerProvider by remember { mutableStateOf(settingsStore.relayerProvider) } + var customRelayerUrl by remember { mutableStateOf(settingsStore.customRelayerUrl) } + var relayedEndpoints by remember { mutableStateOf(settingsStore.relayedEndpoints) } var activeDialog by rememberSaveable { mutableStateOf(SettingsDialog.NONE) } val proxyViewModel: ProxySettingsViewModel = @@ -294,6 +304,27 @@ fun SettingsScreen( activeDialog = SettingsDialog.NONE }, ) + SettingsDialog.RELAYER_PROVIDER -> RelayerProviderPickerDialog( + currentProvider = relayerProvider, + currentCustomUrl = customRelayerUrl, + availableRelayers = FoxyRemoteConfig.current.relayers, + onDismiss = { activeDialog = SettingsDialog.NONE }, + onConfirm = { provider, customUrl -> + settingsStore.saveRelayer(provider, customUrl) + relayerProvider = provider + customRelayerUrl = settingsStore.customRelayerUrl + activeDialog = SettingsDialog.NONE + }, + ) + SettingsDialog.RELAYER_ENDPOINTS -> RelayedEndpointsDialog( + currentEndpoints = relayedEndpoints, + onDismiss = { activeDialog = SettingsDialog.NONE }, + onConfirm = { endpoints -> + settingsStore.relayedEndpoints = endpoints + relayedEndpoints = endpoints + activeDialog = SettingsDialog.NONE + }, + ) } Scaffold( @@ -388,6 +419,57 @@ fun SettingsScreen( modifier = Modifier.clickable { activeDialog = SettingsDialog.BOOTSTRAP_DNS_PROVIDER }, ) + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + SectionLabel(stringResource(R.string.settings_section_relayer)) + ListItem( + headlineContent = { Text(stringResource(R.string.settings_relayer_provider)) }, + supportingContent = { + val label = when { + relayerProvider.equals(SettingsStore.RELAYER_AUTOMATIC, ignoreCase = true) -> + stringResource(R.string.settings_relayer_automatic) + relayerProvider.equals(SettingsStore.RELAYER_DIRECT, ignoreCase = true) -> + stringResource(R.string.settings_relayer_direct) + relayerProvider.equals(SettingsStore.RELAYER_CUSTOM, ignoreCase = true) -> + customRelayerUrl.ifBlank { stringResource(R.string.settings_relayer_custom) } + else -> { + val entry = FoxyRemoteConfig.current.relayers.entries.firstOrNull { + it.key.equals(relayerProvider, ignoreCase = true) + } + if (entry != null) { + entry.key.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() } + } else { + relayerProvider + } + } + } + Text(label) + }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, + modifier = Modifier.clickable { activeDialog = SettingsDialog.RELAYER_PROVIDER }, + ) + ListItem( + headlineContent = { Text(stringResource(R.string.settings_relayer_endpoints)) }, + supportingContent = { + val summary = if (relayedEndpoints.isEmpty()) { + stringResource(R.string.settings_none) + } else { + val names = mutableListOf() + if ("guardian" in relayedEndpoints) names.add(stringResource(R.string.settings_relayer_ep_guardian)) + if ("fxa" in relayedEndpoints) names.add(stringResource(R.string.settings_relayer_ep_fxa)) + if ("config" in relayedEndpoints) names.add(stringResource(R.string.settings_relayer_ep_config)) + if ("server_list" in relayedEndpoints) names.add(stringResource(R.string.settings_relayer_ep_server_list)) + names.joinToString(", ") + } + Text(summary) + }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, + modifier = Modifier.clickable { activeDialog = SettingsDialog.RELAYER_ENDPOINTS }, + ) + HorizontalDivider(Modifier.padding(vertical = 8.dp)) SectionLabel(stringResource(R.string.proxy)) SettingsToggle( @@ -707,20 +789,22 @@ fun SettingsScreen( }, ) - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - Button( - onClick = onSignOut, - enabled = !signOutInProgress, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.settings_sign_out)) + if (isSignedIn) { + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + Button( + onClick = onSignOut, + enabled = !signOutInProgress, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.settings_sign_out)) + } } } } diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/settings/RelayerDialogs.kt b/app/src/main/java/com/vauth/foxyvpn/ui/settings/RelayerDialogs.kt new file mode 100644 index 00000000..e4f45e54 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/ui/settings/RelayerDialogs.kt @@ -0,0 +1,432 @@ +package com.vauth.foxyvpn.ui.settings + +import android.util.Base64 +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.vauth.foxyvpn.R +import com.vauth.foxyvpn.data.ControlPlaneHttp +import com.vauth.foxyvpn.data.FoxyRemoteConfig +import com.vauth.foxyvpn.data.SettingsStore +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.Request + +private const val MAX_SAVED_RELAYER_DRAFT_CHARS = 4096 + +private val RelayerDraftSaver = + Saver( + save = { it.takeIf { value -> value.length <= MAX_SAVED_RELAYER_DRAFT_CHARS } }, + restore = { it }, + ) + +private val EndpointSelectionSaver = + listSaver, String>(save = { it.toList() }, restore = { it.toSet() }) + +private sealed interface RelayerProbeState { + data object Idle : RelayerProbeState + data object Testing : RelayerProbeState + data class Success(val ms: Int) : RelayerProbeState + data object Failed : RelayerProbeState +} + +private suspend fun probeRelayerEndpoint(targetRelayerUrl: String?): RelayerProbeState = + withContext(Dispatchers.IO) { + val client = ControlPlaneHttp.client.newBuilder() + .connectTimeout(4, TimeUnit.SECONDS) + .readTimeout(4, TimeUnit.SECONDS) + .callTimeout(4, TimeUnit.SECONDS) + .build() + try { + val probeTarget = FoxyRemoteConfig.current.endpoints.latencyProbe.ifBlank { "https://1.1.1.1" } + val requestUrl = if (targetRelayerUrl != null) { + val b64 = Base64.encodeToString( + probeTarget.toByteArray(Charsets.UTF_8), + Base64.URL_SAFE or Base64.NO_WRAP, + ).trim() + val rawBase = targetRelayerUrl.trim() + val base = if (rawBase.startsWith("http://", ignoreCase = true) || rawBase.startsWith("https://", ignoreCase = true)) { + rawBase + } else { + "https://$rawBase" + } + when { + base.endsWith("?url=") -> "$base$b64" + base.endsWith("&url=") -> "$base$b64" + base.contains("?") -> "${base}&url=$b64" + else -> "${base.trimEnd('/')}/?url=$b64" + } + } else { + probeTarget + } + val request = Request.Builder() + .url(requestUrl) + .header("Cache-Control", "no-cache, no-store") + .build() + val start = System.nanoTime() + client.newCall(request).execute().use { response -> + if (response.code in 200..499) { + val ms = ((System.nanoTime() - start) / 1_000_000L).coerceAtLeast(1L).toInt() + RelayerProbeState.Success(ms) + } else { + RelayerProbeState.Failed + } + } + } catch (_: Exception) { + RelayerProbeState.Failed + } + } + +private data class RelayerProviderOption( + val key: String, + val title: String, + val description: String, +) + +@Composable +internal fun RelayerProviderPickerDialog( + currentProvider: String, + currentCustomUrl: String, + availableRelayers: Map, + onDismiss: () -> Unit, + onConfirm: (String, String) -> Unit, +) { + var selectedProvider by rememberSaveable { mutableStateOf(currentProvider) } + var urlText by rememberSaveable(stateSaver = RelayerDraftSaver) { mutableStateOf(currentCustomUrl) } + val isCustom = selectedProvider.equals(SettingsStore.RELAYER_CUSTOM, ignoreCase = true) + val isValidCustomUrl = SettingsStore.isValidRelayerUrl(urlText) + val canSave = !isCustom || isValidCustomUrl + + val coroutineScope = rememberCoroutineScope() + var probeStates by remember { mutableStateOf>(emptyMap()) } + var isTesting by remember { mutableStateOf(false) } + + val options = mutableListOf() + options.add( + RelayerProviderOption( + key = SettingsStore.RELAYER_AUTOMATIC, + title = stringResource(R.string.settings_relayer_automatic), + description = stringResource(R.string.settings_relayer_automatic_desc), + ) + ) + options.add( + RelayerProviderOption( + key = SettingsStore.RELAYER_DIRECT, + title = stringResource(R.string.settings_relayer_direct), + description = stringResource(R.string.settings_relayer_direct_desc), + ) + ) + availableRelayers.forEach { (name, url) -> + val title = name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() } + options.add( + RelayerProviderOption( + key = name, + title = title, + description = url, + ) + ) + } + options.add( + RelayerProviderOption( + key = SettingsStore.RELAYER_CUSTOM, + title = stringResource(R.string.settings_relayer_custom), + description = stringResource(R.string.settings_relayer_custom_desc), + ) + ) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_relayer_provider)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()).selectableGroup()) { + Text( + stringResource(R.string.settings_relayer_provider_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + options.forEach { option -> + val isSelected = selectedProvider.equals(option.key, ignoreCase = true) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = isSelected, + role = Role.RadioButton, + onClick = { selectedProvider = option.key }, + ) + .heightIn(min = 48.dp) + .padding(vertical = 4.dp), + ) { + RadioButton( + selected = isSelected, + onClick = null, + ) + Column(Modifier.padding(start = 8.dp).weight(1f)) { + Text(option.title) + if (option.description.isNotEmpty()) { + Text( + option.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + val state = probeStates[option.key] + if (state != null && state !is RelayerProbeState.Idle) { + when (state) { + is RelayerProbeState.Testing -> { + Text( + stringResource(R.string.loading), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + is RelayerProbeState.Success -> { + Text( + stringResource(R.string.server_list_tcp_ms, state.ms), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + is RelayerProbeState.Failed -> { + Text( + stringResource(R.string.server_list_tcp_unavailable), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + else -> {} + } + } + } + } + } + TextButton( + onClick = { + if (isTesting) return@TextButton + isTesting = true + coroutineScope.launch { + val initial = mutableMapOf() + options.forEach { initial[it.key] = RelayerProbeState.Testing } + probeStates = initial + + val deferreds = options.map { opt -> + async { + val result = when (opt.key) { + SettingsStore.RELAYER_DIRECT -> probeRelayerEndpoint(null) + SettingsStore.RELAYER_CUSTOM -> { + val custom = urlText.trim() + if (custom.isNotBlank() && SettingsStore.isValidRelayerUrl(custom)) { + probeRelayerEndpoint(custom) + } else { + RelayerProbeState.Failed + } + } + SettingsStore.RELAYER_AUTOMATIC -> RelayerProbeState.Idle + else -> { + val url = availableRelayers.entries.firstOrNull { it.key.equals(opt.key, ignoreCase = true) }?.value + if (!url.isNullOrBlank()) { + probeRelayerEndpoint(url) + } else { + RelayerProbeState.Failed + } + } + } + opt.key to result + } + } + val results = deferreds.awaitAll().toMap().toMutableMap() + val candidates = results.filter { it.key != SettingsStore.RELAYER_AUTOMATIC }.values + val successful = candidates.filterIsInstance() + if (successful.isNotEmpty()) { + val best = successful.minByOrNull { it.ms } + if (best != null) { + results[SettingsStore.RELAYER_AUTOMATIC] = best + } + } else if (results.isNotEmpty()) { + results[SettingsStore.RELAYER_AUTOMATIC] = RelayerProbeState.Failed + } + probeStates = results + isTesting = false + } + }, + enabled = !isTesting, + modifier = Modifier.padding(top = 4.dp), + ) { + if (isTesting) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + } + Text(stringResource(R.string.settings_relayer_test_latency)) + } + if (isCustom) { + OutlinedTextField( + value = urlText, + onValueChange = { + urlText = it.trim() + probeStates = probeStates - SettingsStore.RELAYER_CUSTOM + }, + label = { Text(stringResource(R.string.settings_relayer_custom_url)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + autoCorrectEnabled = false, + ), + placeholder = { Text(stringResource(R.string.settings_relayer_custom_url_placeholder)) }, + supportingText = { Text(stringResource(R.string.settings_relayer_custom_url_help)) }, + singleLine = true, + isError = urlText.isNotBlank() && !isValidCustomUrl, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(selectedProvider, urlText) }, + enabled = canSave, + ) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} + +private data class RelayedEndpointOption( + val id: String, + val titleRes: Int, + val descRes: Int, +) + +private val ENDPOINT_OPTIONS = listOf( + RelayedEndpointOption( + id = "guardian", + titleRes = R.string.settings_relayer_ep_guardian, + descRes = R.string.settings_relayer_ep_guardian_desc, + ), + RelayedEndpointOption( + id = "fxa", + titleRes = R.string.settings_relayer_ep_fxa, + descRes = R.string.settings_relayer_ep_fxa_desc, + ), + RelayedEndpointOption( + id = "config", + titleRes = R.string.settings_relayer_ep_config, + descRes = R.string.settings_relayer_ep_config_desc, + ), + RelayedEndpointOption( + id = "server_list", + titleRes = R.string.settings_relayer_ep_server_list, + descRes = R.string.settings_relayer_ep_server_list_desc, + ), +) + +@Composable +internal fun RelayedEndpointsDialog( + currentEndpoints: Set, + onDismiss: () -> Unit, + onConfirm: (Set) -> Unit, +) { + var selected by rememberSaveable(stateSaver = EndpointSelectionSaver) { + mutableStateOf(currentEndpoints) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_relayer_endpoints)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + Text( + stringResource(R.string.settings_relayer_endpoints_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + ENDPOINT_OPTIONS.forEach { option -> + val isChecked = option.id in selected + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = isChecked, + role = Role.Checkbox, + onValueChange = { checked -> + selected = if (checked) selected + option.id else selected - option.id + }, + ) + .heightIn(min = 48.dp) + .padding(vertical = 4.dp), + ) { + Checkbox( + checked = isChecked, + onCheckedChange = null, + ) + Column(Modifier.padding(start = 8.dp)) { + Text(stringResource(option.titleRes)) + Text( + stringResource(option.descRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(selected) }) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/ExitCheck.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/ExitCheck.kt index e4afe935..46a17a95 100644 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/ExitCheck.kt +++ b/app/src/main/java/com/vauth/foxyvpn/vpn/ExitCheck.kt @@ -1,6 +1,7 @@ package com.vauth.foxyvpn.vpn import com.vauth.foxyvpn.data.ControlPlaneHttp +import com.vauth.foxyvpn.data.FoxyRemoteConfig import com.vauth.foxyvpn.data.SettingsStore import com.vauth.foxyvpn.data.awaitResponse import com.vauth.foxyvpn.data.readBoundedText @@ -9,8 +10,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.Request -private const val EXIT_CHECK_URL = "https://www.cloudflare.com/cdn-cgi/trace" - private const val EXIT_CHECK_TIMEOUT_SECONDS = 15L data class IpLocationInfo(val ip: String, val countryCode: String) @@ -20,7 +19,8 @@ class ExitCheck { suspend fun fetchDirectIpInfo(): Result = withContext(Dispatchers.IO) { runCatching { - val request = Request.Builder().url(EXIT_CHECK_URL).build() + val url = FoxyRemoteConfig.current.endpoints.exitCheck + val request = Request.Builder().url(url).build() ControlPlaneHttp.client.newCall(request).awaitResponse().use { response -> check(response.isSuccessful) { "Direct IP check failed: HTTP ${response.code}" @@ -40,7 +40,8 @@ class ExitCheck { val client = localProxyClient(socksPort, username, password, EXIT_CHECK_TIMEOUT_SECONDS) try { runCatching { - val request = Request.Builder().url(EXIT_CHECK_URL).build() + val url = FoxyRemoteConfig.current.endpoints.exitCheck + val request = Request.Builder().url(url).build() client.newCall(request).awaitResponse().use { response -> if (!response.isSuccessful) error("Proxied exit check failed: HTTP ${response.code}") 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 a38bc34f..39d49fec 100644 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyVpnService.kt +++ b/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyVpnService.kt @@ -22,6 +22,7 @@ import com.vauth.foxyvpn.FoxyVpnApp import com.vauth.foxyvpn.MainActivity import com.vauth.foxyvpn.data.AppLogger import com.vauth.foxyvpn.data.ControlPlaneHttp +import com.vauth.foxyvpn.data.FoxyRemoteConfig import com.vauth.foxyvpn.data.FxaRefreshFailure import com.vauth.foxyvpn.data.GUARDIAN_ENDPOINT_DEFAULT import com.vauth.foxyvpn.data.GuardianClient @@ -1069,8 +1070,8 @@ class FoxyVpnService : VpnService() { val credentials = activeProxyCredentials val urls = listOf( - "https://www.gstatic.com/generate_204", - "https://www.cloudflare.com/cdn-cgi/trace", + FoxyRemoteConfig.current.endpoints.latencyProbe, + FoxyRemoteConfig.current.endpoints.exitCheck, ) fun current() = isActive && isCurrentSession(generation) while (current()) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a448ca98..54859e7e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -195,4 +195,28 @@ Theme: follows the system. Tap for dark mode Theme: light. Tap for dark mode, long press to follow the system Theme: dark. Tap for light mode, long press to follow the system + + Relayer + Relayer provider + Route control plane and API endpoints through a relayer. + Routed endpoints + Choose which endpoints pass through the relayer. + Automatic + Tries direct first; fails over to relayer on block or error + Direct + Connect directly without relayer + Custom relayer + Custom HTTP or HTTPS worker + Custom relayer URL + https://your-worker.example.com + HTTP or HTTPS URL supporting ?url= parameter + Guardian + /api/v1/fpn/token, /status, /activate + Firefox Accounts + /oauth/token, /authorization, /session + Remote Config + GitHub / jsdelivr config sync + Server List + Mozilla Remote Settings catalog + Test latency diff --git a/relayer/README.md b/relayer/README.md new file mode 100644 index 00000000..62c27860 --- /dev/null +++ b/relayer/README.md @@ -0,0 +1,62 @@ +# Edge Relayer + +A lightweight edge proxy designed to provide reliable connectivity and network resilience for FoxyVPN control-plane communications in restricted or high-latency network environments. + +> [!NOTE] +> This proxy handles only standard control-plane API calls (account sign-in, token refresh, and server catalog fetching). It **never** carries VPN tunnel data traffic. + +
+ +## โš™๏ธ Deploy + +### 1. Cloudflare Setup +- Create a [Cloudflare](https://www.cloudflare.com/) **account**. +- Navigate to `Workers & Pages > Create > Create Worker`. +- Deploy the worker by clicking **Deploy**. +- Edit the code by clicking **Edit Code**. +- Paste `worker.js` code into **Cloudflare**. +- Finally, click **Deploy** and copy the URL. + +**Sample URL:** +```text +https://..workers.dev +``` + +### 2. App Configuration +- Open **FoxyVPN**. +- Go to **Settings > Relayer provider**. +- Select **Custom Relayer** and enter your relayer URL. +- Finally, click **Save**. + +> [!CAUTION] +> **Do not share your relayer URL with anyone** to protect your daily quota. + +
+ +## ๐ŸŒ Deploy on Other Platforms + +You can use the provided code to create and deploy the relayer on any platform of your choice (VPS, Vercel, Netlify, Google Apps Script, etc.). + +Simply copy the contents of [`worker.js`](worker.js) and provide it to an AI (such as ChatGPT, Claude, or Gemini) with the following prompt: + +```text +Use this Cloudflare Worker code as reference and adapt it into a production-ready relayer for [VPS/Vercel/Netlify/Google Apps Script]. Keep the exact same host whitelist, header stripping, cookie rewriting, and Base64 URL handling logic. +``` + +
+ +## ๐Ÿงช Verification + +Test that your relayer is live by checking the root endpoint: + +```bash +curl -s https://..workers.dev +``` + +Expected response: +```json +{ + "status": "ok", + "relay": "foxyvpn" +} +``` diff --git a/relayer/worker.js b/relayer/worker.js new file mode 100644 index 00000000..fcdfa053 --- /dev/null +++ b/relayer/worker.js @@ -0,0 +1,171 @@ +const ALLOWED_HOSTS = new Set([ + "vpn.mozilla.org", + "api.accounts.firefox.com", + "oauth.accounts.firefox.com", + "accounts.firefox.com", + "firefox.settings.services.mozilla.com", + "product-details.mozilla.org", + "github.com", + "githubusercontent.com", + "raw.githubusercontent.com", + "www.gstatic.com", +]); + +const STRIP_REQUEST_HEADERS = new Set([ + "host", + "connection", + "content-length", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "via", + "forwarded", + "x-forwarded-for", + "x-forwarded-proto", + "x-forwarded-host", + "x-forwarded-client-cert", + "x-real-ip", + "true-client-ip", +]); + +const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]); + +export default { + async fetch(request, env, ctx) { + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS, HEAD", + "Access-Control-Allow-Headers": "*", + "Access-Control-Max-Age": "86400", + }, + }); + } + + try { + const incomingUrl = new URL(request.url); + const rawUrlParam = incomingUrl.searchParams.get("url"); + + if (!rawUrlParam) { + return new Response( + JSON.stringify({ status: "ok", relay: "foxyvpn" }), + { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } } + ); + } + + const targetUrlStr = parseUrl(rawUrlParam); + let targetUrl; + try { + targetUrl = new URL(targetUrlStr); + } catch { + return new Response( + JSON.stringify({ error: "Malformed decoded URL: " + targetUrlStr }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + if (targetUrl.protocol !== "https:") { + return new Response( + JSON.stringify({ error: "Target URL must use HTTPS" }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + const host = targetUrl.hostname.toLowerCase(); + const isAllowed = + ALLOWED_HOSTS.has(host) || + host === "firefox.com" || + host.endsWith(".firefox.com") || + host === "mozilla.org" || + host.endsWith(".mozilla.org") || + host === "mozilla.com" || + host.endsWith(".mozilla.com") || + host === "github.com" || + host.endsWith(".github.com") || + host.endsWith(".githubusercontent.com"); + + if (!isAllowed) { + return new Response( + JSON.stringify({ error: `Forbidden: Host '${host}' is not permitted` }), + { status: 403, headers: { "Content-Type": "application/json" } } + ); + } + + const forwardHeaders = new Headers(); + for (const [key, value] of request.headers.entries()) { + const lowerKey = key.toLowerCase(); + if (!STRIP_REQUEST_HEADERS.has(lowerKey) && !lowerKey.startsWith("cf-")) { + forwardHeaders.set(key, value); + } + } + forwardHeaders.set("Host", targetUrl.host); + + const hasBody = !["GET", "HEAD"].includes(request.method); + const bodyData = hasBody ? await request.arrayBuffer() : null; + + const upstreamRequest = new Request(targetUrl.toString(), { + method: request.method, + headers: forwardHeaders, + body: bodyData, + redirect: "follow", + }); + + const upstreamResponse = await fetch(upstreamRequest); + + const responseHeaders = new Headers(upstreamResponse.headers); + responseHeaders.delete("content-encoding"); + responseHeaders.delete("content-length"); + + if (typeof responseHeaders.getSetCookie === "function") { + const cookies = responseHeaders.getSetCookie(); + if (cookies.length > 0) { + responseHeaders.delete("set-cookie"); + for (const c of cookies) { + responseHeaders.append("set-cookie", c.replace(/;\s*domain=[^;]+/gi, "")); + } + } + } else if (responseHeaders.has("set-cookie")) { + const rawCookies = responseHeaders.get("set-cookie"); + responseHeaders.set("set-cookie", rawCookies.replace(/;\s*domain=[^;]+/gi, "")); + } + + responseHeaders.set("Access-Control-Allow-Origin", "*"); + responseHeaders.set( + "Access-Control-Expose-Headers", + "X-Quota-Remaining, X-Quota-Limit, X-Quota-Reset, Quota-Remaining, Retry-After" + ); + + const finalBody = NULL_BODY_STATUSES.has(upstreamResponse.status) + ? null + : upstreamResponse.body; + + return new Response(finalBody, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: responseHeaders, + }); + } catch (err) { + return new Response( + JSON.stringify({ + error: "Relayer Gateway Error", + details: err.message || String(err), + }), + { status: 502, headers: { "Content-Type": "application/json" } } + ); + } + }, +}; + +function parseUrl(input) { + let str = (input || "").trim(); + if (str.startsWith("https://") || str.startsWith("http://")) return str; + str = str.replace(/ /g, "+").replace(/-/g, "+").replace(/_/g, "/"); + while (str.length % 4 !== 0) str += "="; + return atob(str); +}