From 553e484a075cea88cc7e70e0dffb98e050640560 Mon Sep 17 00:00:00 2001 From: Ilya Yakelzon Date: Fri, 15 May 2026 16:09:53 +0200 Subject: [PATCH 1/7] Add stealth direct-connection app exclusions --- android/app/build.gradle | 23 +++ .../lantern/handler/MethodHandler.kt | 59 +++++- .../lantern/service/LanternVpnService.kt | 2 + .../DirectConnectionAppExclusionStore.kt | 147 ++++++++++++++ .../stealth/DirectConnectionAppExclusions.kt | 91 +++++++++ assets/locales/en.po | 9 + assets/stealth/default_exclusions.json | 186 ++++++++++++++++++ docs/stealth-direct-connection-apps.md | 50 +++++ lib/core/common/app_build_info.dart | 5 + .../split_tunneling/apps_split_tunneling.dart | 15 +- .../split_tunneling/split_tunneling.dart | 69 ++++--- pubspec.yaml | 1 + .../default_exclusions_asset_test.dart | 39 ++++ 13 files changed, 662 insertions(+), 34 deletions(-) create mode 100644 android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusionStore.kt create mode 100644 android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusions.kt create mode 100644 assets/stealth/default_exclusions.json create mode 100644 docs/stealth-direct-connection-apps.md create mode 100644 test/features/split_tunneling/default_exclusions_asset_test.dart 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..4d304931c9 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)) { + val store = DirectConnectionAppExclusionStore(appContext) + splitCsvClean(items).forEach(store::addPackage) + 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)) { + val store = DirectConnectionAppExclusionStore(appContext) + splitCsvClean(items).forEach(store::removePackage) + 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..1635f25a42 --- /dev/null +++ b/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusionStore.kt @@ -0,0 +1,147 @@ +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 { + loadDefaultExclusions(context) + } + + 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) { + val packageName = DirectConnectionAppExclusions.normalizePackageName(rawPackageName) + require(DirectConnectionAppExclusions.isValidPackageName(packageName)) { + "Invalid package name" + } + + val defaults = defaultPackageNames() + val added = stringSet(KEY_USER_ADDED).toMutableSet() + val removedDefaults = stringSet(KEY_USER_REMOVED_DEFAULTS).toMutableSet() + + if (packageName in defaults) { + removedDefaults -= packageName + } else { + added += packageName + } + + prefs.edit() + .putStringSet(KEY_USER_ADDED, added) + .putStringSet(KEY_USER_REMOVED_DEFAULTS, removedDefaults) + .apply() + } + + fun removePackage(rawPackageName: String) { + val packageName = DirectConnectionAppExclusions.normalizePackageName(rawPackageName) + require(DirectConnectionAppExclusions.isValidPackageName(packageName)) { + "Invalid package name" + } + + val defaults = defaultPackageNames() + val added = stringSet(KEY_USER_ADDED).toMutableSet() + val removedDefaults = stringSet(KEY_USER_REMOVED_DEFAULTS).toMutableSet() + + 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 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", + ) + + fun enabled(): Boolean = BuildConfig.STEALTH_DIRECT_CONNECTION_APPS + + 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 + } + + return runCatching { DirectConnectionAppExclusions.parseDefaults(json) } + .onFailure { e -> AppLogger.w(TAG, "Failed to parse $path", e) } + .getOrDefault(emptyList()) + } + + 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..df2ea018a2 --- /dev/null +++ b/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusions.kt @@ -0,0 +1,91 @@ +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 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 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/en.po b/assets/locales/en.po index e9591d27f7..d32f61719f 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,6 +366,9 @@ 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)" 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..620fca9661 --- /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`, project properties, and +environment variables 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..85e95ae2a4 100644 --- a/lib/features/split_tunneling/apps_split_tunneling.dart +++ b/lib/features/split_tunneling/apps_split_tunneling.dart @@ -4,6 +4,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/common/app_build_info.dart'; import 'package:lantern/core/common/app_text_styles.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/app_data.dart'; @@ -28,6 +29,10 @@ class AppsSplitTunneling extends ConsumerWidget { final enabledAppsAsync = ref.watch(splitTunnelingAppsProvider); final enabledApps = enabledAppsAsync.value ?? const {}; + final directConnectionApps = AppBuildInfo.stealthDirectConnectionApps; + final title = directConnectionApps + ? 'direct_connection_apps'.i18n + : 'apps_split_tunneling'.i18n; final allApps = dedupeAndSortApps( (ref.watch(appsDataProvider).value ?? const []).where( @@ -50,10 +55,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 +67,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(), ], diff --git a/lib/features/split_tunneling/split_tunneling.dart b/lib/features/split_tunneling/split_tunneling.dart index 5381e18d5e..ee1f0e9011 100644 --- a/lib/features/split_tunneling/split_tunneling.dart +++ b/lib/features/split_tunneling/split_tunneling.dart @@ -1,6 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/common/app_build_info.dart'; import 'package:lantern/core/common/app_text_styles.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/app_data.dart'; @@ -21,14 +22,24 @@ class SplitTunneling extends HookConsumerWidget { final splitTunnelingEnabled = ref.watch( radianceSettingsProvider.select((s) => s.splitTunneling), ); + final directConnectionApps = AppBuildInfo.stealthDirectConnectionApps; + 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,39 @@ 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, + label: directConnectionApps + ? 'direct_connection_apps'.i18n + : 'apps'.i18n, actionText: '${enabledApps.length} Added', 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: '${enabledWebsites.length} Added', + 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..0b7442a978 --- /dev/null +++ b/test/features/split_tunneling/default_exclusions_asset_test.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/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.length, 22); + + 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')); + }); +} From 82626d851c14d3782078daba14136ae3267cda6f Mon Sep 17 00:00:00 2001 From: Ilya Yakelzon Date: Fri, 15 May 2026 17:44:05 +0200 Subject: [PATCH 2/7] Address review feedback for direct app exclusions --- .../DirectConnectionAppExclusionStore.kt | 11 +++-- assets/locales/ar.po | 10 +++++ assets/locales/bn.po | 10 +++++ assets/locales/es-cu.po | 10 +++++ assets/locales/es.po | 10 +++++ assets/locales/fa.po | 10 +++++ assets/locales/fr-ca.po | 10 +++++ assets/locales/fr.po | 10 +++++ assets/locales/hi.po | 10 +++++ assets/locales/ms.po | 10 +++++ assets/locales/my.po | 10 +++++ assets/locales/ps.po | 10 +++++ assets/locales/ru.po | 10 +++++ assets/locales/th.po | 10 +++++ assets/locales/tk.po | 10 +++++ assets/locales/tr.po | 10 +++++ assets/locales/ur.po | 10 +++++ assets/locales/vi.po | 10 +++++ assets/locales/zh-Hans.po | 10 +++++ assets/locales/zh-Hant.po | 10 +++++ docs/stealth-direct-connection-apps.md | 4 +- .../split_tunneling/apps_split_tunneling.dart | 29 +++++++++--- .../provider/apps_notifier.dart | 44 ++++++++++++------- 23 files changed, 251 insertions(+), 27 deletions(-) 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 index 1635f25a42..1c257b9dbe 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusionStore.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusionStore.kt @@ -107,9 +107,14 @@ class DirectConnectionAppExclusionStore( continue } - return runCatching { DirectConnectionAppExclusions.parseDefaults(json) } - .onFailure { e -> AppLogger.w(TAG, "Failed to parse $path", e) } - .getOrDefault(emptyList()) + 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") diff --git a/assets/locales/ar.po b/assets/locales/ar.po index 538aea8775..9447498735 100644 --- a/assets/locales/ar.po +++ b/assets/locales/ar.po @@ -1641,3 +1641,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..f7ccd8668a 100644 --- a/assets/locales/bn.po +++ b/assets/locales/bn.po @@ -1677,3 +1677,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-cu.po b/assets/locales/es-cu.po index 1a23f1fe70..df6448f26a 100644 --- a/assets/locales/es-cu.po +++ b/assets/locales/es-cu.po @@ -1702,3 +1702,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..4bc07d2909 100644 --- a/assets/locales/es.po +++ b/assets/locales/es.po @@ -1698,3 +1698,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..d137fbcccd 100644 --- a/assets/locales/fa.po +++ b/assets/locales/fa.po @@ -1675,3 +1675,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..4e16cd936a 100644 --- a/assets/locales/fr-ca.po +++ b/assets/locales/fr-ca.po @@ -2092,3 +2092,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.po b/assets/locales/fr.po index 0e915991b6..98abb94748 100644 --- a/assets/locales/fr.po +++ b/assets/locales/fr.po @@ -1561,3 +1561,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/hi.po b/assets/locales/hi.po index 89ede2f6b1..74bfba02aa 100644 --- a/assets/locales/hi.po +++ b/assets/locales/hi.po @@ -1677,3 +1677,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/ms.po b/assets/locales/ms.po index 0d15f705c7..67ef85eeec 100644 --- a/assets/locales/ms.po +++ b/assets/locales/ms.po @@ -1687,3 +1687,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/my.po b/assets/locales/my.po index 4f5116a248..77231ecd01 100644 --- a/assets/locales/my.po +++ b/assets/locales/my.po @@ -1717,3 +1717,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/ps.po b/assets/locales/ps.po index baaeec2dde..622b3590fe 100644 --- a/assets/locales/ps.po +++ b/assets/locales/ps.po @@ -1704,3 +1704,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/ru.po b/assets/locales/ru.po index ededd01f0f..2540516146 100644 --- a/assets/locales/ru.po +++ b/assets/locales/ru.po @@ -1694,3 +1694,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/th.po b/assets/locales/th.po index bb75f7f4ea..5e52ddcd6c 100644 --- a/assets/locales/th.po +++ b/assets/locales/th.po @@ -1649,3 +1649,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/tk.po b/assets/locales/tk.po index f5975eed86..b02bb36bfd 100644 --- a/assets/locales/tk.po +++ b/assets/locales/tk.po @@ -1662,3 +1662,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/tr.po b/assets/locales/tr.po index 9a47b094ff..426b716ee6 100644 --- a/assets/locales/tr.po +++ b/assets/locales/tr.po @@ -1675,3 +1675,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/ur.po b/assets/locales/ur.po index d58df7d11b..a4c1bb753c 100644 --- a/assets/locales/ur.po +++ b/assets/locales/ur.po @@ -2042,3 +2042,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/vi.po b/assets/locales/vi.po index bbd35dd377..b464c20b3d 100644 --- a/assets/locales/vi.po +++ b/assets/locales/vi.po @@ -1680,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/zh-Hans.po b/assets/locales/zh-Hans.po index 038fe5adad..1da9d2ee5c 100644 --- a/assets/locales/zh-Hans.po +++ b/assets/locales/zh-Hans.po @@ -1541,3 +1541,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/zh-Hant.po b/assets/locales/zh-Hant.po index 06caa05bca..6e73b80bd2 100644 --- a/assets/locales/zh-Hant.po +++ b/assets/locales/zh-Hant.po @@ -1543,3 +1543,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/docs/stealth-direct-connection-apps.md b/docs/stealth-direct-connection-apps.md index 620fca9661..2893f9177d 100644 --- a/docs/stealth-direct-connection-apps.md +++ b/docs/stealth-direct-connection-apps.md @@ -26,8 +26,8 @@ flutter build apk \ --dart-define=STEALTH_DIRECT_CONNECTION_APPS=true ``` -The Android Gradle file reads Flutter `dart-defines`, project properties, and -environment variables in that order. CI may also use: +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 diff --git a/lib/features/split_tunneling/apps_split_tunneling.dart b/lib/features/split_tunneling/apps_split_tunneling.dart index 85e95ae2a4..68ba1f880f 100644 --- a/lib/features/split_tunneling/apps_split_tunneling.dart +++ b/lib/features/split_tunneling/apps_split_tunneling.dart @@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lantern/core/common/app_build_info.dart'; import 'package:lantern/core/common/app_text_styles.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/app_data.dart'; @@ -16,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') @@ -33,6 +33,15 @@ class AppsSplitTunneling extends ConsumerWidget { 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( @@ -104,7 +113,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); }, ), ); @@ -113,7 +125,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); + }, ); }, ), @@ -146,9 +161,10 @@ class AppsSplitTunneling extends ConsumerWidget { label: 'select_all'.i18n, fontSize: 14, onPressed: () async { - await notifier.selectApps( + final changed = await notifier.selectApps( filteredDisabled, ); + showReconnectNoticeIfNeeded(changed); }, ), ); @@ -157,7 +173,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; }, ); } From 634b105403104167b8057364aae8d88505e32e83 Mon Sep 17 00:00:00 2001 From: Ilya Yakelzon Date: Fri, 15 May 2026 18:15:32 +0200 Subject: [PATCH 3/7] Use flutter_test in exclusion asset test --- .../features/split_tunneling/default_exclusions_asset_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/features/split_tunneling/default_exclusions_asset_test.dart b/test/features/split_tunneling/default_exclusions_asset_test.dart index 0b7442a978..7290efd57d 100644 --- a/test/features/split_tunneling/default_exclusions_asset_test.dart +++ b/test/features/split_tunneling/default_exclusions_asset_test.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:io'; -import 'package:test/test.dart'; +import 'package:flutter_test/flutter_test.dart'; void main() { test('stealth direct-connection defaults are valid package names', () async { From 3ee912d260d5d125c8e83a05cee0147f2d06c1e1 Mon Sep 17 00:00:00 2001 From: Ilya Yakelzon Date: Fri, 15 May 2026 18:44:10 +0200 Subject: [PATCH 4/7] Gate direct connection apps to Android --- lib/features/split_tunneling/apps_split_tunneling.dart | 3 ++- lib/features/split_tunneling/split_tunneling.dart | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/features/split_tunneling/apps_split_tunneling.dart b/lib/features/split_tunneling/apps_split_tunneling.dart index 68ba1f880f..23820d623d 100644 --- a/lib/features/split_tunneling/apps_split_tunneling.dart +++ b/lib/features/split_tunneling/apps_split_tunneling.dart @@ -29,7 +29,8 @@ class AppsSplitTunneling extends ConsumerWidget { final enabledAppsAsync = ref.watch(splitTunnelingAppsProvider); final enabledApps = enabledAppsAsync.value ?? const {}; - final directConnectionApps = AppBuildInfo.stealthDirectConnectionApps; + final directConnectionApps = + AppBuildInfo.stealthDirectConnectionApps && PlatformUtils.isAndroid; final title = directConnectionApps ? 'direct_connection_apps'.i18n : 'apps_split_tunneling'.i18n; diff --git a/lib/features/split_tunneling/split_tunneling.dart b/lib/features/split_tunneling/split_tunneling.dart index ee1f0e9011..2523a46565 100644 --- a/lib/features/split_tunneling/split_tunneling.dart +++ b/lib/features/split_tunneling/split_tunneling.dart @@ -1,7 +1,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lantern/core/common/app_build_info.dart'; import 'package:lantern/core/common/app_text_styles.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/app_data.dart'; @@ -22,7 +21,8 @@ class SplitTunneling extends HookConsumerWidget { final splitTunnelingEnabled = ref.watch( radianceSettingsProvider.select((s) => s.splitTunneling), ); - final directConnectionApps = AppBuildInfo.stealthDirectConnectionApps; + final directConnectionApps = + AppBuildInfo.stealthDirectConnectionApps && PlatformUtils.isAndroid; final showSplitTunnelingItems = directConnectionApps || splitTunnelingEnabled; final title = directConnectionApps From d6b8693c340132960ef761e5c03fa17861fca7df Mon Sep 17 00:00:00 2001 From: Ilya Yakelzon Date: Sat, 16 May 2026 12:54:43 +0200 Subject: [PATCH 5/7] fix: batch direct app exclusion updates --- .../lantern/handler/MethodHandler.kt | 8 +-- .../DirectConnectionAppExclusionStore.kt | 61 +++++++++++-------- .../stealth/DirectConnectionAppExclusions.kt | 13 ++++ 3 files changed, 53 insertions(+), 29 deletions(-) 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 4d304931c9..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 @@ -345,8 +345,8 @@ class MethodHandler : FlutterPlugin, call.argument("filterType") ?: error("Missing filterType") val items = call.argument("value") if (useDirectConnectionApps(filterType)) { - val store = DirectConnectionAppExclusionStore(appContext) - splitCsvClean(items).forEach(store::addPackage) + DirectConnectionAppExclusionStore(appContext) + .addPackages(splitCsvClean(items)) noteDirectConnectionAppsReconnect() } else { Mobile.addSplitTunnelItems(items) @@ -369,8 +369,8 @@ class MethodHandler : FlutterPlugin, call.argument("filterType") ?: error("Missing filterType") val items = call.argument("value") if (useDirectConnectionApps(filterType)) { - val store = DirectConnectionAppExclusionStore(appContext) - splitCsvClean(items).forEach(store::removePackage) + DirectConnectionAppExclusionStore(appContext) + .removePackages(splitCsvClean(items)) noteDirectConnectionAppsReconnect() } else { Mobile.removeSplitTunnelItems(items) 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 index 1c257b9dbe..0e57aba01d 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusionStore.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusionStore.kt @@ -33,41 +33,44 @@ class DirectConnectionAppExclusionStore( } fun addPackage(rawPackageName: String) { - val packageName = DirectConnectionAppExclusions.normalizePackageName(rawPackageName) - require(DirectConnectionAppExclusions.isValidPackageName(packageName)) { - "Invalid package name" - } + updatePackages(listOf(rawPackageName), adding = true) + } - val defaults = defaultPackageNames() - val added = stringSet(KEY_USER_ADDED).toMutableSet() - val removedDefaults = stringSet(KEY_USER_REMOVED_DEFAULTS).toMutableSet() + fun addPackages(rawPackageNames: Iterable) { + updatePackages(rawPackageNames, adding = true) + } - if (packageName in defaults) { - removedDefaults -= packageName - } else { - added += packageName - } + fun removePackage(rawPackageName: String) { + updatePackages(listOf(rawPackageName), adding = false) + } - prefs.edit() - .putStringSet(KEY_USER_ADDED, added) - .putStringSet(KEY_USER_REMOVED_DEFAULTS, removedDefaults) - .apply() + fun removePackages(rawPackageNames: Iterable) { + updatePackages(rawPackageNames, adding = false) } - fun removePackage(rawPackageName: String) { - val packageName = DirectConnectionAppExclusions.normalizePackageName(rawPackageName) - require(DirectConnectionAppExclusions.isValidPackageName(packageName)) { - "Invalid package name" + 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() - if (packageName in defaults) { - removedDefaults += packageName - } else { - added -= packageName + 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() @@ -76,6 +79,14 @@ class DirectConnectionAppExclusionStore( .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) 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 index df2ea018a2..156df39622 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusions.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusions.kt @@ -1,5 +1,6 @@ package org.getlantern.lantern.stealth +import org.getlantern.lantern.utils.AppLogger import org.json.JSONArray import org.json.JSONObject import java.util.Locale @@ -16,6 +17,9 @@ data class DirectConnectionAppExclusion( object DirectConnectionAppExclusions { const val DEFAULT_ASSET_PATH = "flutter_assets/assets/stealth/default_exclusions.json" + private const val TAG = "DirectAppExclusions" + private const val SUPPORTED_SCHEMA_VERSION = 1 + private val packageNamePattern = Regex("^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$") @@ -29,6 +33,15 @@ object DirectConnectionAppExclusions { fun parseDefaults(json: String): List { val root = JSONObject(json) + val schemaVersion = root.optInt("schema_version", -1) + if (schemaVersion != SUPPORTED_SCHEMA_VERSION) { + AppLogger.w( + TAG, + "Unsupported direct-connection defaults schema_version=$schemaVersion", + ) + return emptyList() + } + val defaults = root.optJSONArray("defaults") ?: JSONArray() val out = ArrayList(defaults.length()) val seen = LinkedHashSet() From eb97b465b7e58fe4305359f4285c0d141b5168e9 Mon Sep 17 00:00:00 2001 From: Ilya Yakelzon Date: Sat, 16 May 2026 13:16:14 +0200 Subject: [PATCH 6/7] fix: harden direct app defaults --- .../stealth/DirectConnectionAppExclusionStore.kt | 15 ++++++++++++++- .../stealth/DirectConnectionAppExclusions.kt | 6 +----- assets/locales/ar.po | 3 +++ assets/locales/bn.po | 3 +++ assets/locales/en.po | 3 +++ assets/locales/es-cu.po | 3 +++ assets/locales/es.po | 3 +++ assets/locales/fa.po | 3 +++ assets/locales/fr-ca.po | 4 ++++ assets/locales/fr.po | 4 ++++ assets/locales/hi.po | 4 ++++ assets/locales/ms.po | 4 ++++ assets/locales/my.po | 4 ++++ assets/locales/ps.po | 4 ++++ assets/locales/ru.po | 4 ++++ assets/locales/th.po | 4 ++++ assets/locales/tk.po | 4 ++++ assets/locales/tr.po | 4 ++++ assets/locales/ur.po | 4 ++++ assets/locales/vi.po | 4 ++++ assets/locales/zh-Hans.po | 4 ++++ assets/locales/zh-Hant.po | 4 ++++ lib/features/split_tunneling/split_tunneling.dart | 8 ++++++-- .../default_exclusions_asset_test.dart | 2 +- 24 files changed, 96 insertions(+), 9 deletions(-) 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 index 0e57aba01d..bf417ed71e 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusionStore.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusionStore.kt @@ -13,7 +13,7 @@ class DirectConnectionAppExclusionStore( private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val defaultExclusions: List by lazy { - loadDefaultExclusions(context) + cachedDefaultExclusions(context.applicationContext) } fun defaultPackageNames(): Set { @@ -105,9 +105,22 @@ class DirectConnectionAppExclusionStore( "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 { 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 index 156df39622..eac5a58089 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusions.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusions.kt @@ -1,6 +1,5 @@ package org.getlantern.lantern.stealth -import org.getlantern.lantern.utils.AppLogger import org.json.JSONArray import org.json.JSONObject import java.util.Locale @@ -17,7 +16,6 @@ data class DirectConnectionAppExclusion( object DirectConnectionAppExclusions { const val DEFAULT_ASSET_PATH = "flutter_assets/assets/stealth/default_exclusions.json" - private const val TAG = "DirectAppExclusions" private const val SUPPORTED_SCHEMA_VERSION = 1 private val packageNamePattern = @@ -35,11 +33,9 @@ object DirectConnectionAppExclusions { val root = JSONObject(json) val schemaVersion = root.optInt("schema_version", -1) if (schemaVersion != SUPPORTED_SCHEMA_VERSION) { - AppLogger.w( - TAG, + throw IllegalArgumentException( "Unsupported direct-connection defaults schema_version=$schemaVersion", ) - return emptyList() } val defaults = root.optJSONArray("defaults") ?: JSONArray() diff --git a/assets/locales/ar.po b/assets/locales/ar.po index 9447498735..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." diff --git a/assets/locales/bn.po b/assets/locales/bn.po index f7ccd8668a..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 থেকে আমার ডায়াগনস্টিক লগ।" diff --git a/assets/locales/en.po b/assets/locales/en.po index d32f61719f..b4beb41f5c 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -372,6 +372,9 @@ 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 df6448f26a..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." diff --git a/assets/locales/es.po b/assets/locales/es.po index 4bc07d2909..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." diff --git a/assets/locales/fa.po b/assets/locales/fa.po index d137fbcccd..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 است." diff --git a/assets/locales/fr-ca.po b/assets/locales/fr-ca.po index 4e16cd936a..7758b6e097 100644 --- a/assets/locales/fr-ca.po +++ b/assets/locales/fr-ca.po @@ -2102,3 +2102,7 @@ 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 98abb94748..1476d2a377 100644 --- a/assets/locales/fr.po +++ b/assets/locales/fr.po @@ -1571,3 +1571,7 @@ 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 74bfba02aa..1ef2b874ab 100644 --- a/assets/locales/hi.po +++ b/assets/locales/hi.po @@ -1687,3 +1687,7 @@ 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 67ef85eeec..b2bfe4e0c3 100644 --- a/assets/locales/ms.po +++ b/assets/locales/ms.po @@ -1697,3 +1697,7 @@ 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 77231ecd01..e58ac5b7e7 100644 --- a/assets/locales/my.po +++ b/assets/locales/my.po @@ -1727,3 +1727,7 @@ 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 622b3590fe..5dbaea501e 100644 --- a/assets/locales/ps.po +++ b/assets/locales/ps.po @@ -1714,3 +1714,7 @@ 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 2540516146..65450cc40c 100644 --- a/assets/locales/ru.po +++ b/assets/locales/ru.po @@ -1704,3 +1704,7 @@ 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 5e52ddcd6c..658c72c3a8 100644 --- a/assets/locales/th.po +++ b/assets/locales/th.po @@ -1659,3 +1659,7 @@ 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 b02bb36bfd..3ad05cdc0d 100644 --- a/assets/locales/tk.po +++ b/assets/locales/tk.po @@ -1672,3 +1672,7 @@ 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 426b716ee6..c4735d5c18 100644 --- a/assets/locales/tr.po +++ b/assets/locales/tr.po @@ -1685,3 +1685,7 @@ 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 a4c1bb753c..91554befab 100644 --- a/assets/locales/ur.po +++ b/assets/locales/ur.po @@ -2052,3 +2052,7 @@ 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 b464c20b3d..b30a0a77eb 100644 --- a/assets/locales/vi.po +++ b/assets/locales/vi.po @@ -1690,3 +1690,7 @@ 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 1da9d2ee5c..eb3261825a 100644 --- a/assets/locales/zh-Hans.po +++ b/assets/locales/zh-Hans.po @@ -1551,3 +1551,7 @@ 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 6e73b80bd2..46b78618e1 100644 --- a/assets/locales/zh-Hant.po +++ b/assets/locales/zh-Hant.po @@ -1553,3 +1553,7 @@ 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/lib/features/split_tunneling/split_tunneling.dart b/lib/features/split_tunneling/split_tunneling.dart index 2523a46565..2e91706795 100644 --- a/lib/features/split_tunneling/split_tunneling.dart +++ b/lib/features/split_tunneling/split_tunneling.dart @@ -93,7 +93,9 @@ class SplitTunneling extends HookConsumerWidget { label: directConnectionApps ? 'direct_connection_apps'.i18n : 'apps'.i18n, - actionText: '${enabledApps.length} Added', + actionText: 'items_added_count'.i18n.fill([ + enabledApps.length, + ]), onPressed: () => appRouter.push(AppsSplitTunneling()), ), if (!directConnectionApps) ...{ @@ -101,7 +103,9 @@ class SplitTunneling extends HookConsumerWidget { SplitTunnelingTile( icon: AppImagePaths.world, label: 'websites'.i18n, - actionText: '${enabledWebsites.length} Added', + actionText: 'items_added_count'.i18n.fill([ + enabledWebsites.length, + ]), onPressed: () => appRouter.push(WebsiteSplitTunneling()), ), }, diff --git a/test/features/split_tunneling/default_exclusions_asset_test.dart b/test/features/split_tunneling/default_exclusions_asset_test.dart index 7290efd57d..7f104f310a 100644 --- a/test/features/split_tunneling/default_exclusions_asset_test.dart +++ b/test/features/split_tunneling/default_exclusions_asset_test.dart @@ -14,7 +14,7 @@ void main() { expect(decoded['source'], isA>()); final defaults = decoded['defaults'] as List; - expect(defaults.length, 22); + expect(defaults, isNotEmpty); final packageNames = []; final packageNamePattern = RegExp(r'^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$'); From cb01400e403d0d0bb10af0805be3cecbc0fbe0a2 Mon Sep 17 00:00:00 2001 From: Ilya Yakelzon Date: Sat, 16 May 2026 13:43:38 +0200 Subject: [PATCH 7/7] test: use decoded json types --- .../split_tunneling/default_exclusions_asset_test.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/features/split_tunneling/default_exclusions_asset_test.dart b/test/features/split_tunneling/default_exclusions_asset_test.dart index 7f104f310a..c3371d8f7a 100644 --- a/test/features/split_tunneling/default_exclusions_asset_test.dart +++ b/test/features/split_tunneling/default_exclusions_asset_test.dart @@ -8,25 +8,25 @@ void main() { final raw = await File( 'assets/stealth/default_exclusions.json', ).readAsString(); - final decoded = jsonDecode(raw) as Map; + final decoded = jsonDecode(raw) as Map; expect(decoded['schema_version'], 1); - expect(decoded['source'], isA>()); + expect(decoded['source'], isA()); - final defaults = decoded['defaults'] as List; + 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>()) { + 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['reason_flags'], isA()); expect(entry['source'], 'rks'); }