diff --git a/android/app/build.gradle b/android/app/build.gradle index 57bb8ad393..798bead0f2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -42,6 +42,27 @@ def start = new Date(2015, 1, 1).getTime() def now = System.currentTimeMillis() def code = (int)((now - start) / 1000) +def dartDefines = [:] +if (project.hasProperty('dart-defines')) { + project.property('dart-defines').toString().split(',').each { encoded -> + if (encoded?.trim()) { + def decoded = new String(encoded.decodeBase64(), 'UTF-8') + def separator = decoded.indexOf('=') + if (separator > 0) { + dartDefines[decoded.substring(0, separator)] = decoded.substring(separator + 1) + } + } + } +} + +def buildConfigBoolean = { name, defaultValue = false -> + def raw = dartDefines[name] ?: System.getenv(name) ?: project.findProperty(name) + if (raw == null) { + return defaultValue.toString() + } + return (raw.toString().trim().toLowerCase() in ['1', 'true', 'yes', 'on']) ? 'true' : 'false' +} + android { namespace = "org.getlantern.lantern" compileSdk = 36 @@ -132,6 +153,8 @@ android { buildFeatures { buildConfig true } + buildConfigField "boolean", "STEALTH_DIRECT_CONNECTION_APPS", + buildConfigBoolean("STEALTH_DIRECT_CONNECTION_APPS") } signingConfigs { diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt b/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt index 5637ce57f6..8cf3cf0681 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt @@ -20,11 +20,13 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import lantern.io.mobile.Mobile +import org.getlantern.lantern.BuildConfig import org.getlantern.lantern.MainActivity import org.getlantern.lantern.apps.AppFilters import org.getlantern.lantern.constant.VPNStatus import org.getlantern.lantern.updater.AndroidSideloadInstaller import org.getlantern.lantern.updater.AndroidSideloadUpdateRequest +import org.getlantern.lantern.stealth.DirectConnectionAppExclusionStore import org.getlantern.lantern.utils.AppLogger import org.getlantern.lantern.utils.PrivateServerListener import org.getlantern.lantern.utils.VpnStatusManager @@ -296,7 +298,12 @@ class MethodHandler : FlutterPlugin, val filterType = call.argument("filterType") ?: error("Missing filterType") val value = call.argument("value") ?: error("Missing value") - Mobile.addSplitTunnelItem(filterType, value) + if (useDirectConnectionApps(filterType)) { + DirectConnectionAppExclusionStore(appContext).addPackage(value) + noteDirectConnectionAppsReconnect() + } else { + Mobile.addSplitTunnelItem(filterType, value) + } success("Item added") }.onFailure { e -> result.error( @@ -314,7 +321,12 @@ class MethodHandler : FlutterPlugin, val filterType = call.argument("filterType") ?: error("Missing filterType") val value = call.argument("value") ?: error("Missing value") - Mobile.removeSplitTunnelItem(filterType, value) + if (useDirectConnectionApps(filterType)) { + DirectConnectionAppExclusionStore(appContext).removePackage(value) + noteDirectConnectionAppsReconnect() + } else { + Mobile.removeSplitTunnelItem(filterType, value) + } success("Item removed") }.onFailure { e -> result.error( @@ -329,8 +341,16 @@ class MethodHandler : FlutterPlugin, Methods.AddAllItems.method -> { scope.launch { result.runCatching { + val filterType = + call.argument("filterType") ?: error("Missing filterType") val items = call.argument("value") - Mobile.addSplitTunnelItems(items) + if (useDirectConnectionApps(filterType)) { + DirectConnectionAppExclusionStore(appContext) + .addPackages(splitCsvClean(items)) + noteDirectConnectionAppsReconnect() + } else { + Mobile.addSplitTunnelItems(items) + } success("All items added") }.onFailure { e -> result.error( @@ -345,8 +365,16 @@ class MethodHandler : FlutterPlugin, Methods.RemoveAllItems.method -> { scope.launch { result.runCatching { + val filterType = + call.argument("filterType") ?: error("Missing filterType") val items = call.argument("value") - Mobile.removeSplitTunnelItems(items) + if (useDirectConnectionApps(filterType)) { + DirectConnectionAppExclusionStore(appContext) + .removePackages(splitCsvClean(items)) + noteDirectConnectionAppsReconnect() + } else { + Mobile.removeSplitTunnelItems(items) + } success("All items removed") }.onFailure { e -> result.error( @@ -1059,7 +1087,11 @@ class MethodHandler : FlutterPlugin, result.runCatching { val filterType = call.argument("filterType") ?: error("Missing filterType") - val json = Mobile.getSplitTunnelItems(filterType) + val json = if (useDirectConnectionApps(filterType)) { + DirectConnectionAppExclusionStore(appContext).effectivePackageNamesJson() + } else { + Mobile.getSplitTunnelItems(filterType) + } withContext(Dispatchers.Main) { success(json) } }.onFailure { e -> result.error( @@ -1393,6 +1425,23 @@ class MethodHandler : FlutterPlugin, } return false } + + private fun useDirectConnectionApps(filterType: String): Boolean { + return BuildConfig.STEALTH_DIRECT_CONNECTION_APPS && filterType == "packageName" + } + + private fun noteDirectConnectionAppsReconnect() { + if (runCatching { Mobile.isVPNConnected() }.getOrDefault(false)) { + AppLogger.i(TAG, "Direct-connection app changes apply on next reconnect") + } + } +} + +private fun splitCsvClean(raw: String?): List { + return raw.orEmpty() + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } } private suspend fun MethodChannel.Result.mainSuccess(value: Any? = "ok") = diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/service/LanternVpnService.kt b/android/app/src/main/kotlin/org/getlantern/lantern/service/LanternVpnService.kt index 418c5c56cf..cf5366ab2c 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/service/LanternVpnService.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/service/LanternVpnService.kt @@ -25,6 +25,7 @@ import org.getlantern.lantern.MainActivity import org.getlantern.lantern.constant.VPNStatus import org.getlantern.lantern.notification.NotificationHelper import org.getlantern.lantern.service.LanternVpnService.Companion.ACTION_STOP_VPN +import org.getlantern.lantern.stealth.DirectConnectionAppExclusionStore import org.getlantern.lantern.utils.AppLogger import org.getlantern.lantern.utils.DeviceUtil import org.getlantern.lantern.utils.FlutterEventListener @@ -523,6 +524,7 @@ class LanternVpnService : // Disallow traffic from our own app to the VPN. builder.addDisallowedApplication(BuildConfig.APPLICATION_ID) + DirectConnectionAppExclusionStore.applyToBuilder(builder, this) if (options.autoRoute) { builder.addDnsServer(options.dnsServerAddress.value) diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusionStore.kt b/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusionStore.kt new file mode 100644 index 0000000000..bf417ed71e --- /dev/null +++ b/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusionStore.kt @@ -0,0 +1,176 @@ +package org.getlantern.lantern.stealth + +import android.content.Context +import android.content.pm.PackageManager +import android.net.VpnService +import org.getlantern.lantern.BuildConfig +import org.getlantern.lantern.utils.AppLogger +import org.json.JSONArray + +class DirectConnectionAppExclusionStore( + private val context: Context, +) { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private val defaultExclusions: List by lazy { + cachedDefaultExclusions(context.applicationContext) + } + + fun defaultPackageNames(): Set { + return defaultExclusions.mapTo(LinkedHashSet()) { it.packageName } + } + + fun effectivePackageNames(): Set { + return DirectConnectionAppExclusions.effectivePackageNames( + defaultPackages = defaultPackageNames(), + userAddedPackages = stringSet(KEY_USER_ADDED), + userRemovedDefaultPackages = stringSet(KEY_USER_REMOVED_DEFAULTS), + ) + } + + fun effectivePackageNamesJson(): String { + return JSONArray(effectivePackageNames()).toString() + } + + fun addPackage(rawPackageName: String) { + updatePackages(listOf(rawPackageName), adding = true) + } + + fun addPackages(rawPackageNames: Iterable) { + updatePackages(rawPackageNames, adding = true) + } + + fun removePackage(rawPackageName: String) { + updatePackages(listOf(rawPackageName), adding = false) + } + + fun removePackages(rawPackageNames: Iterable) { + updatePackages(rawPackageNames, adding = false) + } + + private fun updatePackages(rawPackageNames: Iterable, adding: Boolean) { + val packageNames = rawPackageNames.map(::requirePackageName) + if (packageNames.isEmpty()) { + return + } + val defaults = defaultPackageNames() + val added = stringSet(KEY_USER_ADDED).toMutableSet() + val removedDefaults = stringSet(KEY_USER_REMOVED_DEFAULTS).toMutableSet() + + for (packageName in packageNames) { + if (adding) { + if (packageName in defaults) { + removedDefaults -= packageName + } else { + added += packageName + } + } else { + if (packageName in defaults) { + removedDefaults += packageName + } else { + added -= packageName + } + } + } + + prefs.edit() + .putStringSet(KEY_USER_ADDED, added) + .putStringSet(KEY_USER_REMOVED_DEFAULTS, removedDefaults) + .apply() + } + + private fun requirePackageName(rawPackageName: String): String { + val packageName = DirectConnectionAppExclusions.normalizePackageName(rawPackageName) + require(DirectConnectionAppExclusions.isValidPackageName(packageName)) { + "Invalid package name: raw='$rawPackageName', normalized='$packageName'" + } + return packageName + } + + private fun stringSet(key: String): Set { + return prefs.getStringSet(key, emptySet()).orEmpty() + .map(DirectConnectionAppExclusions::normalizePackageName) + .filter(DirectConnectionAppExclusions::isValidPackageName) + .toSet() + } + + companion object { + private const val TAG = "DirectAppExclusions" + private const val PREFS_NAME = "direct_connection_app_exclusions" + private const val KEY_USER_ADDED = "user_added_packages" + private const val KEY_USER_REMOVED_DEFAULTS = "user_removed_default_packages" + + private val assetCandidates = listOf( + DirectConnectionAppExclusions.DEFAULT_ASSET_PATH, + "assets/stealth/default_exclusions.json", + "stealth/default_exclusions.json", + ) + @Volatile + private var defaultExclusionsCache: List? = null + + fun enabled(): Boolean = BuildConfig.STEALTH_DIRECT_CONNECTION_APPS + + private fun cachedDefaultExclusions( + context: Context, + ): List { + defaultExclusionsCache?.let { return it } + return synchronized(this) { + defaultExclusionsCache ?: loadDefaultExclusions(context).also { + defaultExclusionsCache = it + } + } + } + + fun loadDefaultExclusions(context: Context): List { + for (path in assetCandidates) { + val json = runCatching { + context.assets.open(path).bufferedReader().use { it.readText() } + }.getOrNull() + + if (json.isNullOrBlank()) { + continue + } + + val parsed = runCatching { + DirectConnectionAppExclusions.parseDefaults(json) + }.onFailure { e -> + AppLogger.w(TAG, "Failed to parse $path", e) + }.getOrNull() + if (parsed != null) { + return parsed + } + } + + AppLogger.w(TAG, "No default direct-connection app exclusions asset found") + return emptyList() + } + + fun applyToBuilder( + builder: VpnService.Builder, + context: Context, + ): Int { + if (!enabled()) { + return 0 + } + + var applied = 0 + val packages = DirectConnectionAppExclusionStore(context).effectivePackageNames() + for (packageName in packages) { + try { + builder.addDisallowedApplication(packageName) + applied += 1 + } catch (e: PackageManager.NameNotFoundException) { + AppLogger.d(TAG, "Skipping direct-connection app not installed: $packageName") + } catch (e: Exception) { + AppLogger.w(TAG, "Skipping direct-connection app: $packageName", e) + } + } + + AppLogger.i( + TAG, + "Applied $applied direct-connection app exclusions (${packages.size} configured)" + ) + return applied + } + } +} diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusions.kt b/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusions.kt new file mode 100644 index 0000000000..eac5a58089 --- /dev/null +++ b/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusions.kt @@ -0,0 +1,100 @@ +package org.getlantern.lantern.stealth + +import org.json.JSONArray +import org.json.JSONObject +import java.util.Locale + +data class DirectConnectionAppExclusion( + val packageName: String, + val displayName: String, + val reasonFlags: Set, + val source: String, + val confidence: String, + val version: String, +) + +object DirectConnectionAppExclusions { + const val DEFAULT_ASSET_PATH = "flutter_assets/assets/stealth/default_exclusions.json" + + private const val SUPPORTED_SCHEMA_VERSION = 1 + + private val packageNamePattern = + Regex("^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$") + + fun normalizePackageName(raw: String?): String { + return raw.orEmpty().trim().lowercase(Locale.US) + } + + fun isValidPackageName(packageName: String): Boolean { + return packageNamePattern.matches(packageName) + } + + fun parseDefaults(json: String): List { + val root = JSONObject(json) + val schemaVersion = root.optInt("schema_version", -1) + if (schemaVersion != SUPPORTED_SCHEMA_VERSION) { + throw IllegalArgumentException( + "Unsupported direct-connection defaults schema_version=$schemaVersion", + ) + } + + val defaults = root.optJSONArray("defaults") ?: JSONArray() + val out = ArrayList(defaults.length()) + val seen = LinkedHashSet() + + for (i in 0 until defaults.length()) { + val item = defaults.optJSONObject(i) ?: continue + val packageName = normalizePackageName(item.optString("package_name")) + if (!isValidPackageName(packageName) || !seen.add(packageName)) { + continue + } + + out += DirectConnectionAppExclusion( + packageName = packageName, + displayName = item.optString("display_name").trim().ifEmpty { packageName }, + reasonFlags = item.optJSONArray("reason_flags").toStringSet(), + source = item.optString("source").trim(), + confidence = item.optString("confidence").trim(), + version = item.optString("version").trim(), + ) + } + + return out + } + + fun effectivePackageNames( + defaultPackages: Iterable, + userAddedPackages: Iterable, + userRemovedDefaultPackages: Iterable, + ): Set { + val effective = LinkedHashSet() + defaultPackages + .map(::normalizePackageName) + .filter(::isValidPackageName) + .forEach { effective += it } + + userAddedPackages + .map(::normalizePackageName) + .filter(::isValidPackageName) + .forEach { effective += it } + + userRemovedDefaultPackages + .map(::normalizePackageName) + .filter(::isValidPackageName) + .forEach { effective -= it } + + return effective.toSortedSet() + } +} + +private fun JSONArray?.toStringSet(): Set { + if (this == null) return emptySet() + val out = LinkedHashSet() + for (i in 0 until length()) { + val value = optString(i).trim() + if (value.isNotEmpty()) { + out += value + } + } + return out +} diff --git a/assets/locales/ar.po b/assets/locales/ar.po index 538aea8775..2701aa2b01 100644 --- a/assets/locales/ar.po +++ b/assets/locales/ar.po @@ -337,6 +337,9 @@ msgstr "التطبيقات التي تتجاوز VPN (%d)" msgid "websites_bypassing_vpn" msgstr "المواقع التي تتجاوز VPN (%d)" +msgid "items_added_count" +msgstr "%d added" + msgid "logs_share_message" msgstr "هذه سجلات التشخيص الخاصة بي من Lantern." @@ -1641,3 +1644,13 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + diff --git a/assets/locales/bn.po b/assets/locales/bn.po index 0c14451cda..aba3295507 100644 --- a/assets/locales/bn.po +++ b/assets/locales/bn.po @@ -332,6 +332,9 @@ msgstr "ভিপিএন বাইপাস করছে এমন অ্য msgid "websites_bypassing_vpn" msgstr "ভিপিএন বাইপাস করছে এমন ওয়েবসাইট (%d)" +msgid "items_added_count" +msgstr "%d added" + msgid "logs_share_message" msgstr "এগুলো Lantern থেকে আমার ডায়াগনস্টিক লগ।" @@ -1677,3 +1680,13 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + diff --git a/assets/locales/en.po b/assets/locales/en.po index e9591d27f7..b4beb41f5c 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -66,6 +66,12 @@ msgstr "VPN Settings" msgid "split_tunneling" msgstr "Split Tunneling" +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + msgid "server_locations" msgstr "Server Locations" @@ -360,9 +366,15 @@ msgstr "Lantern automatically chooses which apps and websites bypass the VPN bas msgid "apps_bypassing_vpn" msgstr "Apps bypassing the VPN (%d)" +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + msgid "websites_bypassing_vpn" msgstr "Websites bypassing the VPN (%d)" +msgid "items_added_count" +msgstr "%d added" + msgid "logs_share_message" msgstr "Here are my diagnostic logs from Lantern." diff --git a/assets/locales/es-cu.po b/assets/locales/es-cu.po index 1a23f1fe70..e96975fb21 100644 --- a/assets/locales/es-cu.po +++ b/assets/locales/es-cu.po @@ -343,6 +343,9 @@ msgstr "Aplicaciones que omiten la VPN (%d)" msgid "websites_bypassing_vpn" msgstr "Sitios web que omiten la VPN (%d)" +msgid "items_added_count" +msgstr "%d added" + msgid "logs_share_message" msgstr "Aquí están mis registros de diagnóstico de Lantern." @@ -1702,3 +1705,13 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + diff --git a/assets/locales/es.po b/assets/locales/es.po index ce011eeb53..221728cbd6 100644 --- a/assets/locales/es.po +++ b/assets/locales/es.po @@ -339,6 +339,9 @@ msgstr "Aplicaciones que omiten la VPN (%d)" msgid "websites_bypassing_vpn" msgstr "Sitios web que omiten la VPN (%d)" +msgid "items_added_count" +msgstr "%d added" + msgid "logs_share_message" msgstr "Aquí están mis registros de diagnóstico de Lantern." @@ -1698,3 +1701,13 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + diff --git a/assets/locales/fa.po b/assets/locales/fa.po index 20db8f4377..4aed3a2be7 100644 --- a/assets/locales/fa.po +++ b/assets/locales/fa.po @@ -337,6 +337,9 @@ msgstr "برنامه‌هایی که از VPN دور می‌زنند (%d)" msgid "websites_bypassing_vpn" msgstr "وب‌سایت‌هایی که از VPN دور می‌زنند (%d)" +msgid "items_added_count" +msgstr "%d added" + msgid "logs_share_message" msgstr "این لاگ‌های تشخیصی من از Lantern است." @@ -1675,3 +1678,13 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + diff --git a/assets/locales/fr-ca.po b/assets/locales/fr-ca.po index 841bc3e552..7758b6e097 100644 --- a/assets/locales/fr-ca.po +++ b/assets/locales/fr-ca.po @@ -2092,3 +2092,17 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + + +msgid "items_added_count" +msgstr "%d added" + diff --git a/assets/locales/fr.po b/assets/locales/fr.po index 0e915991b6..1476d2a377 100644 --- a/assets/locales/fr.po +++ b/assets/locales/fr.po @@ -1561,3 +1561,17 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + + +msgid "items_added_count" +msgstr "%d added" + diff --git a/assets/locales/hi.po b/assets/locales/hi.po index 89ede2f6b1..1ef2b874ab 100644 --- a/assets/locales/hi.po +++ b/assets/locales/hi.po @@ -1677,3 +1677,17 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + + +msgid "items_added_count" +msgstr "%d added" + diff --git a/assets/locales/ms.po b/assets/locales/ms.po index 0d15f705c7..b2bfe4e0c3 100644 --- a/assets/locales/ms.po +++ b/assets/locales/ms.po @@ -1687,3 +1687,17 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + + +msgid "items_added_count" +msgstr "%d added" + diff --git a/assets/locales/my.po b/assets/locales/my.po index 4f5116a248..e58ac5b7e7 100644 --- a/assets/locales/my.po +++ b/assets/locales/my.po @@ -1717,3 +1717,17 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + + +msgid "items_added_count" +msgstr "%d added" + diff --git a/assets/locales/ps.po b/assets/locales/ps.po index baaeec2dde..5dbaea501e 100644 --- a/assets/locales/ps.po +++ b/assets/locales/ps.po @@ -1704,3 +1704,17 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + + +msgid "items_added_count" +msgstr "%d added" + diff --git a/assets/locales/ru.po b/assets/locales/ru.po index ededd01f0f..65450cc40c 100644 --- a/assets/locales/ru.po +++ b/assets/locales/ru.po @@ -1694,3 +1694,17 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + + +msgid "items_added_count" +msgstr "%d added" + diff --git a/assets/locales/th.po b/assets/locales/th.po index bb75f7f4ea..658c72c3a8 100644 --- a/assets/locales/th.po +++ b/assets/locales/th.po @@ -1649,3 +1649,17 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + + +msgid "items_added_count" +msgstr "%d added" + diff --git a/assets/locales/tk.po b/assets/locales/tk.po index f5975eed86..3ad05cdc0d 100644 --- a/assets/locales/tk.po +++ b/assets/locales/tk.po @@ -1662,3 +1662,17 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + + +msgid "items_added_count" +msgstr "%d added" + diff --git a/assets/locales/tr.po b/assets/locales/tr.po index 9a47b094ff..c4735d5c18 100644 --- a/assets/locales/tr.po +++ b/assets/locales/tr.po @@ -1675,3 +1675,17 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + + +msgid "items_added_count" +msgstr "%d added" + diff --git a/assets/locales/ur.po b/assets/locales/ur.po index d58df7d11b..91554befab 100644 --- a/assets/locales/ur.po +++ b/assets/locales/ur.po @@ -2042,3 +2042,17 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + + +msgid "items_added_count" +msgstr "%d added" + diff --git a/assets/locales/vi.po b/assets/locales/vi.po index bbd35dd377..b30a0a77eb 100644 --- a/assets/locales/vi.po +++ b/assets/locales/vi.po @@ -1680,3 +1680,17 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + + +msgid "items_added_count" +msgstr "%d added" + diff --git a/assets/locales/zh-Hans.po b/assets/locales/zh-Hans.po index 038fe5adad..eb3261825a 100644 --- a/assets/locales/zh-Hans.po +++ b/assets/locales/zh-Hans.po @@ -1541,3 +1541,17 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + + +msgid "items_added_count" +msgstr "%d added" + diff --git a/assets/locales/zh-Hant.po b/assets/locales/zh-Hant.po index 06caa05bca..46b78618e1 100644 --- a/assets/locales/zh-Hant.po +++ b/assets/locales/zh-Hant.po @@ -1543,3 +1543,17 @@ msgstr "Couldn't check for updates" msgid "check_connection_and_retry" msgstr "Check your connection and try again" + +msgid "direct_connection_apps" +msgstr "Direct Connection Apps" + +msgid "direct_connection_apps_subtitle" +msgstr "Selected apps connect directly outside protected routing" + +msgid "apps_using_direct_connection" +msgstr "Apps using direct connection (%d)" + + +msgid "items_added_count" +msgstr "%d added" + diff --git a/assets/stealth/default_exclusions.json b/assets/stealth/default_exclusions.json new file mode 100644 index 0000000000..caa217de6e --- /dev/null +++ b/assets/stealth/default_exclusions.json @@ -0,0 +1,186 @@ +{ + "schema_version": 1, + "generated_at": "2026-05-15", + "source": { + "database_url": "https://airtable.com/appWkT7FmgHwcP4K1/shr4OxfMOh2kZXH72/tbl4NptrrXE0yf3OB/viwrejZqSFlinGMR7", + "url": "https://files.rks.global/russian_apps_search_for_vpn_en.pdf" + }, + "defaults": [ + { + "package_name": "com.avito.android", + "display_name": "Avito", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "com.idamob.tinkoff.android", + "display_name": "T-Bank", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "com.uma.musicvk", + "display_name": "VK Music", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "com.vk.vkvideo", + "display_name": "VK Video", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "com.vkontakte.android", + "display_name": "VKontakte", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "com.wildberries.ru", + "display_name": "Wildberries", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "com.yandex.browser", + "display_name": "Yandex Browser", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.alfabank.mobile.android", + "display_name": "Alfa-Bank", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.dublgis.dgismobile", + "display_name": "2GIS", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.kinopoisk", + "display_name": "Kinopoisk", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.megamarket.marketplace", + "display_name": "MegaMarket", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.mts.mymts", + "display_name": "My MTS", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.ok.android", + "display_name": "Odnoklassniki", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.oneme.app", + "display_name": "MAX", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.ozon.app.android", + "display_name": "Ozon", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.rutube.app", + "display_name": "Rutube", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.sbcs.store", + "display_name": "Samokat", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.sberbankmobile", + "display_name": "Sberbank Online", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.vk.store", + "display_name": "RuStore", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.vtb24.mobilebanking.android", + "display_name": "VTB Online", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.yandex.music", + "display_name": "Yandex Music", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.yandex.yandexmaps", + "display_name": "Yandex Maps", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + } + ] +} diff --git a/docs/stealth-direct-connection-apps.md b/docs/stealth-direct-connection-apps.md new file mode 100644 index 0000000000..2893f9177d --- /dev/null +++ b/docs/stealth-direct-connection-apps.md @@ -0,0 +1,50 @@ +# Stealth direct-connection app exclusions + +`STEALTH_DIRECT_CONNECTION_APPS=true` changes Android package-name split +tunneling into a local direct-connection exclusion list for stealth VPN builds. +Normal builds keep the existing lantern-core split-tunnel storage and behavior. + +## Behavior + +- Defaults are shipped in `assets/stealth/default_exclusions.json`. +- The Android app loads those defaults from Flutter assets and stores user edits + in `SharedPreferences`. +- Selected package names are applied to each new `VpnService.Builder` with + `addDisallowedApplication`, so those apps connect outside the VPN tunnel. +- User additions and removals are editable through the existing app selection + screen. Changes apply on the next reconnect because Android does not update + `VpnService.Builder` disallowed-app rules in place. +- Website/domain split tunneling is hidden in this stealth UI mode because this + ticket only protects Android apps that can inspect local VPN state. + +## Build input + +Pass the same define to Flutter and Gradle: + +```sh +flutter build apk \ + --dart-define=STEALTH_DIRECT_CONNECTION_APPS=true +``` + +The Android Gradle file reads Flutter `dart-defines`, environment variables, +and project properties in that order. CI may also use: + +```sh +STEALTH_DIRECT_CONNECTION_APPS=true make android-release-ci +``` + +## Updating defaults + +The first default list is based on the RKS/Airtable dataset referenced by the +stealth epic. Each entry must include: + +- `package_name`: lower-case Android package name. +- `display_name`: user-readable app name for review. +- `reason_flags`: list of detection reasons, currently `rks_vpn_detection`. +- `source`, `confidence`, and `version` metadata for support review. + +Run the asset test after edits: + +```sh +flutter test test/features/split_tunneling/default_exclusions_asset_test.dart +``` diff --git a/lib/core/common/app_build_info.dart b/lib/core/common/app_build_info.dart index dd800e9920..682e84624f 100644 --- a/lib/core/common/app_build_info.dart +++ b/lib/core/common/app_build_info.dart @@ -17,6 +17,11 @@ class AppBuildInfo { defaultValue: false, ); + static const bool stealthDirectConnectionApps = bool.fromEnvironment( + 'STEALTH_DIRECT_CONNECTION_APPS', + defaultValue: false, + ); + /// Developer mode is exposed in debug and nightly builds only. static bool get isDevModeEnabled => kDebugMode || buildType == 'nightly'; } diff --git a/lib/features/split_tunneling/apps_split_tunneling.dart b/lib/features/split_tunneling/apps_split_tunneling.dart index dd635512e5..23820d623d 100644 --- a/lib/features/split_tunneling/apps_split_tunneling.dart +++ b/lib/features/split_tunneling/apps_split_tunneling.dart @@ -15,6 +15,7 @@ import 'package:lantern/features/split_tunneling/provider/apps_data_provider.dar import 'package:lantern/features/split_tunneling/provider/apps_notifier.dart'; import 'package:lantern/features/split_tunneling/provider/search_query.dart'; import 'package:lantern/features/split_tunneling/utils/split_tunnel_app_utils.dart'; +import 'package:lantern/features/vpn/provider/vpn_notifier.dart'; // Widget to display and manage split tunneling apps @RoutePage(name: 'AppsSplitTunneling') @@ -28,6 +29,20 @@ class AppsSplitTunneling extends ConsumerWidget { final enabledAppsAsync = ref.watch(splitTunnelingAppsProvider); final enabledApps = enabledAppsAsync.value ?? const {}; + final directConnectionApps = + AppBuildInfo.stealthDirectConnectionApps && PlatformUtils.isAndroid; + final title = directConnectionApps + ? 'direct_connection_apps'.i18n + : 'apps_split_tunneling'.i18n; + void showReconnectNoticeIfNeeded(bool changed) { + if (!changed || + !directConnectionApps || + ref.read(vpnProvider) != VPNStatus.connected || + !context.mounted) { + return; + } + context.showSnackBar('changes_applied_after_restart'.i18n); + } final allApps = dedupeAndSortApps( (ref.watch(appsDataProvider).value ?? const []).where( @@ -50,10 +65,10 @@ class AppsSplitTunneling extends ConsumerWidget { .toList(); return BaseScreen( - title: 'apps_split_tunneling'.i18n, + title: title, appBar: AppSearchBar( ref: ref, - title: 'apps_split_tunneling'.i18n, + title: title, hintText: 'search_apps'.i18n, ), body: CustomScrollView( @@ -62,7 +77,11 @@ class AppsSplitTunneling extends ConsumerWidget { child: Row( children: [ SectionLabel( - 'apps_bypassing_vpn'.i18n.fill([enabledApps.length]), + directConnectionApps + ? 'apps_using_direct_connection'.i18n.fill([ + enabledApps.length, + ]) + : 'apps_bypassing_vpn'.i18n.fill([enabledApps.length]), ), const Spacer(), ], @@ -95,7 +114,10 @@ class AppsSplitTunneling extends ConsumerWidget { label: 'deselect_all'.i18n, fontSize: 14, onPressed: () async { - await notifier.deselectApps(filteredEnabled); + final changed = await notifier.deselectApps( + filteredEnabled, + ); + showReconnectNoticeIfNeeded(changed); }, ), ); @@ -104,7 +126,10 @@ class AppsSplitTunneling extends ConsumerWidget { return AppRow( app: app, enabled: true, - onToggle: () => notifier.toggleApp(app), + onToggle: () async { + final changed = await notifier.toggleApp(app); + showReconnectNoticeIfNeeded(changed); + }, ); }, ), @@ -137,9 +162,10 @@ class AppsSplitTunneling extends ConsumerWidget { label: 'select_all'.i18n, fontSize: 14, onPressed: () async { - await notifier.selectApps( + final changed = await notifier.selectApps( filteredDisabled, ); + showReconnectNoticeIfNeeded(changed); }, ), ); @@ -148,7 +174,10 @@ class AppsSplitTunneling extends ConsumerWidget { return AppRow( app: app, enabled: false, - onToggle: () => notifier.toggleApp(app), + onToggle: () async { + final changed = await notifier.toggleApp(app); + showReconnectNoticeIfNeeded(changed); + }, ); }, ), diff --git a/lib/features/split_tunneling/provider/apps_notifier.dart b/lib/features/split_tunneling/provider/apps_notifier.dart index 8cce9d759e..56f06ef8b4 100644 --- a/lib/features/split_tunneling/provider/apps_notifier.dart +++ b/lib/features/split_tunneling/provider/apps_notifier.dart @@ -107,11 +107,11 @@ class SplitTunnelingApps extends _$SplitTunnelingApps { Set _stateIds() => _current().map(normalizedAppId).toSet(); - Future toggleApp(AppData app) async { + Future toggleApp(AppData app) async { final id = normalizedAppId(app); final value = splitTunnelValue(app); if (value == null) { - return; + return false; } final current = _current(); final isEnabled = current.any((a) => normalizedAppId(a) == id); @@ -120,13 +120,14 @@ class SplitTunnelingApps extends _$SplitTunnelingApps { ? await _lanternService.removeSplitTunnelItem(getFilterType(), value) : await _lanternService.addSplitTunnelItem(getFilterType(), value); - await result.match( - (failure) async { + return result.match( + (failure) { appLogger.error( 'Failed to ${isEnabled ? "remove" : "add"} item: ${failure.error}', ); + return false; }, - (_) async { + (_) { // Optional optimistic UI update final next = isEnabled ? current.where((a) => normalizedAppId(a) != id).toSet() @@ -136,18 +137,19 @@ class SplitTunnelingApps extends _$SplitTunnelingApps { // Re-sync from lantern-core (authoritative) ref.invalidateSelf(); + return true; }, ); } /// Select exactly these apps - Future selectApps(Iterable apps) async { + Future selectApps(Iterable apps) async { final current = _current(); final currentIds = _stateIds(); final toAdd = apps .where((a) => !currentIds.contains(normalizedAppId(a))) .toList(); - if (toAdd.isEmpty) return; + if (toAdd.isEmpty) return false; final validToAdd = []; final paths = []; @@ -159,30 +161,34 @@ class SplitTunnelingApps extends _$SplitTunnelingApps { } } - if (paths.isEmpty) return; + if (paths.isEmpty) return false; final result = await _lanternService.addAllItems(getFilterType(), paths); - await result.match( - (l) async => appLogger.error('Failed to add apps: ${l.error}'), - (_) async { + return result.match( + (l) { + appLogger.error('Failed to add apps: ${l.error}'); + return false; + }, + (_) { state = AsyncData({ ...current, ...validToAdd.map((a) => a.copyWith(isEnabled: true)), }); ref.invalidateSelf(); + return true; }, ); } - Future deselectApps(Iterable apps) async { + Future deselectApps(Iterable apps) async { final current = _current(); final currentIds = _stateIds(); final toRemove = apps .where((a) => currentIds.contains(normalizedAppId(a))) .toList(); - if (toRemove.isEmpty) return; + if (toRemove.isEmpty) return false; final validToRemove = []; final paths = []; @@ -194,19 +200,23 @@ class SplitTunnelingApps extends _$SplitTunnelingApps { } } - if (paths.isEmpty) return; + if (paths.isEmpty) return false; final result = await _lanternService.removeAllItems(getFilterType(), paths); - await result.match( - (l) async => appLogger.error('Failed to remove apps: ${l.error}'), - (_) async { + return result.match( + (l) { + appLogger.error('Failed to remove apps: ${l.error}'); + return false; + }, + (_) { final removeIds = validToRemove.map(normalizedAppId).toSet(); state = AsyncData( current.where((a) => !removeIds.contains(normalizedAppId(a))).toSet(), ); ref.invalidateSelf(); + return true; }, ); } diff --git a/lib/features/split_tunneling/split_tunneling.dart b/lib/features/split_tunneling/split_tunneling.dart index 5381e18d5e..2e91706795 100644 --- a/lib/features/split_tunneling/split_tunneling.dart +++ b/lib/features/split_tunneling/split_tunneling.dart @@ -21,14 +21,25 @@ class SplitTunneling extends HookConsumerWidget { final splitTunnelingEnabled = ref.watch( radianceSettingsProvider.select((s) => s.splitTunneling), ); + final directConnectionApps = + AppBuildInfo.stealthDirectConnectionApps && PlatformUtils.isAndroid; + final showSplitTunnelingItems = + directConnectionApps || splitTunnelingEnabled; + final title = directConnectionApps + ? 'direct_connection_apps'.i18n + : 'split_tunneling'.i18n; + final subtitle = directConnectionApps + ? 'direct_connection_apps_subtitle'.i18n + : 'add_apps_websites_bypass_vpn'.i18n; final enabledApps = (ref.watch(splitTunnelingAppsProvider).value ?? const {}) .toList(growable: false); - final enabledWebsites = - (ref.watch(splitTunnelingWebsitesProvider).value ?? const {}) - .toList(growable: false); + final enabledWebsites = directConnectionApps + ? const [] + : (ref.watch(splitTunnelingWebsitesProvider).value ?? const {}) + .toList(growable: false); void toggleSplitTunneling() { ref @@ -37,7 +48,7 @@ class SplitTunneling extends HookConsumerWidget { } return BaseScreen( - title: 'split_tunneling'.i18n, + title: title, body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -47,14 +58,14 @@ class SplitTunneling extends HookConsumerWidget { child: Column( children: [ AppTile( - label: 'split_tunneling'.i18n, + label: title, tileTextStyle: AppTextStyles.bodyMedium.copyWith( fontWeight: FontWeight.w600, fontSize: 16, color: context.textPrimary, ), subtitle: Text( - 'add_apps_websites_bypass_vpn'.i18n, + subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: textTheme.labelMedium!.copyWith( @@ -62,33 +73,43 @@ class SplitTunneling extends HookConsumerWidget { letterSpacing: 0.0, ), ), - onPressed: toggleSplitTunneling, - trailing: SwitchButton( - value: splitTunnelingEnabled, - onChanged: (bool? value) { - ref - .read(radianceSettingsProvider.notifier) - .setSplitTunneling(value ?? false); - }, - activeColor: AppColors.green5, - ), + onPressed: directConnectionApps ? null : toggleSplitTunneling, + trailing: directConnectionApps + ? null + : SwitchButton( + value: splitTunnelingEnabled, + onChanged: (bool? value) { + ref + .read(radianceSettingsProvider.notifier) + .setSplitTunneling(value ?? false); + }, + activeColor: AppColors.green5, + ), ), - if (splitTunnelingEnabled) ...{ + if (showSplitTunnelingItems) ...{ DividerSpace(), SplitTunnelingTile( icon: AppImagePaths.keypad, - label: 'apps'.i18n, - actionText: '${enabledApps.length} Added', + label: directConnectionApps + ? 'direct_connection_apps'.i18n + : 'apps'.i18n, + actionText: 'items_added_count'.i18n.fill([ + enabledApps.length, + ]), onPressed: () => appRouter.push(AppsSplitTunneling()), ), - DividerSpace(), - SplitTunnelingTile( - icon: AppImagePaths.world, - label: 'websites'.i18n, - actionText: '${enabledWebsites.length} Added', - onPressed: () => appRouter.push(WebsiteSplitTunneling()), - ), - } + if (!directConnectionApps) ...{ + DividerSpace(), + SplitTunnelingTile( + icon: AppImagePaths.world, + label: 'websites'.i18n, + actionText: 'items_added_count'.i18n.fill([ + enabledWebsites.length, + ]), + onPressed: () => appRouter.push(WebsiteSplitTunneling()), + ), + }, + }, ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index cabdc55cd0..5f973e5100 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -154,6 +154,7 @@ flutter: assets: - assets/ - assets/images/ + - assets/stealth/ - path: assets/images/flags/ platforms: - windows diff --git a/test/features/split_tunneling/default_exclusions_asset_test.dart b/test/features/split_tunneling/default_exclusions_asset_test.dart new file mode 100644 index 0000000000..c3371d8f7a --- /dev/null +++ b/test/features/split_tunneling/default_exclusions_asset_test.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('stealth direct-connection defaults are valid package names', () async { + final raw = await File( + 'assets/stealth/default_exclusions.json', + ).readAsString(); + final decoded = jsonDecode(raw) as Map; + + expect(decoded['schema_version'], 1); + expect(decoded['source'], isA()); + + final defaults = decoded['defaults'] as List; + expect(defaults, isNotEmpty); + + final packageNames = []; + final packageNamePattern = RegExp(r'^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$'); + + for (final entry in defaults.cast>()) { + final packageName = entry['package_name'] as String; + packageNames.add(packageName); + + expect(packageName, packageName.toLowerCase()); + expect(packageNamePattern.hasMatch(packageName), isTrue); + expect(entry['display_name'], isA()); + expect(entry['reason_flags'], isA()); + expect(entry['source'], 'rks'); + } + + expect(packageNames.toSet(), hasLength(packageNames.length)); + expect(packageNames, contains('com.vkontakte.android')); + expect(packageNames, contains('ru.sberbankmobile')); + expect(packageNames, contains('ru.vtb24.mobilebanking.android')); + expect(packageNames, contains('com.yandex.browser')); + }); +}