From 389712bba35699d2a4152a498846d2bc65d810f7 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 10 Oct 2026 01:47:58 +0700 Subject: [PATCH] android: global direct-apps routing model, migration, single-source CharonVpnService excludes Co-Authored-By: Claude Opus 5.5 --- .../android/logic/CharonVpnService.java | 68 +- .../android/logic/routing/PackagePatterns.kt | 79 +++ .../android/logic/routing/RoutingManager.kt | 668 +++++++++++------- .../android/logic/routing/RoutingMigration.kt | 99 +++ .../android/logic/routing/RoutingPresets.kt | 164 +++-- .../android/ui/VpnProfileImportActivity.java | 6 + .../logic/routing/PackagePatternsTest.kt | 63 ++ .../logic/routing/RoutingManagerTest.kt | 191 ++--- .../logic/routing/RoutingMigrationTest.kt | 141 ++++ 9 files changed, 982 insertions(+), 497 deletions(-) create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/PackagePatterns.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingMigration.kt create mode 100644 src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/PackagePatternsTest.kt create mode 100644 src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/RoutingMigrationTest.kt diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java index cda134e30..9e3c88e90 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java @@ -51,7 +51,6 @@ import android.util.Log; import org.strongswan.android.R; import org.strongswan.android.data.VpnProfile; -import org.strongswan.android.data.VpnProfile.SelectedAppsHandling; import org.strongswan.android.data.VpnProfileDataSource; import org.strongswan.android.data.VpnProfileSource; import org.strongswan.android.data.VpnType.VpnTypeFeature; @@ -92,6 +91,7 @@ import java.util.List; import java.util.Locale; import java.util.Set; import java.util.SortedSet; +import java.util.TreeSet; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -2098,7 +2098,6 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe * the OS routes here */ private final boolean mUserspaceRouting; private final int mSplitTunneling; - private final SelectedAppsHandling mAppHandling; private final SortedSet mSelectedApps; private final List mDnsServers = new ArrayList<>(); /* addresses of the system's private DNS server (empty if none is @@ -2145,26 +2144,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe mExcludedSubnets = IPRangeSet.fromString(profile.getExcludedSubnets()); Integer splitTunneling = profile.getSplitTunneling(); mSplitTunneling = splitTunneling != null ? splitTunneling : 0; - SelectedAppsHandling appHandling = profile.getSelectedAppsHandling(); - mSelectedApps = profile.getSelectedAppsSet(); - /* exclude our own app, otherwise the fetcher is blocked */ - switch (appHandling) - { - case SELECTED_APPS_DISABLE: - appHandling = SelectedAppsHandling.SELECTED_APPS_EXCLUDE; - mSelectedApps.clear(); - /* fall-through */ - case SELECTED_APPS_EXCLUDE: - mSelectedApps.add(getPackageName()); - break; - case SELECTED_APPS_ONLY: - mSelectedApps.remove(getPackageName()); - break; - } - /* global routing rules: packages that must bypass the tunnel */ - Set globalExcludes = RoutingManager.getInstance(CharonVpnService.this).computeExcludedPackages(); - appHandling = RoutingManager.applyGlobalExcludes(appHandling, mSelectedApps, globalExcludes); - mAppHandling = appHandling; + /* the only per-app routing is the global one (Routing tab): its + * packages bypass the tunnel. The old per-profile handling is gone + * (migrated by RoutingManager); our own app is always excluded, + * otherwise the fetchers are blocked */ + mSelectedApps = new TreeSet<>(RoutingManager.getInstance(CharonVpnService.this).computeExcludedPackages()); + mSelectedApps.add(getPackageName()); /* RU geoip exclusions: a large presorted list. It is never passed * to VpnService's excludeRoute() - on some devices (One UI was @@ -2512,39 +2497,16 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe { applyRoutesWithGeo(builder, rangesV6, mUserspaceRouting ? Collections.emptyList() : geoV6); } - /* apply selected applications */ - if (mSelectedApps.size() > 0) + /* apply the directly routed applications (never empty: our own package) */ + for (String app : mSelectedApps) { - switch (mAppHandling) + try { - case SELECTED_APPS_EXCLUDE: - for (String app : mSelectedApps) - { - try - { - builder.addDisallowedApplication(app); - } - catch (PackageManager.NameNotFoundException e) - { - // possible if not configured via GUI or app was uninstalled - } - } - break; - case SELECTED_APPS_ONLY: - for (String app : mSelectedApps) - { - try - { - builder.addAllowedApplication(app); - } - catch (PackageManager.NameNotFoundException e) - { - // possible if not configured via GUI or app was uninstalled - } - } - break; - default: - break; + builder.addDisallowedApplication(app); + } + catch (PackageManager.NameNotFoundException e) + { + // app was uninstalled since the list was built } } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && mProxyServer != null) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/PackagePatterns.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/PackagePatterns.kt new file mode 100644 index 000000000..e869e2638 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/PackagePatterns.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic.routing + +/** + * User-defined package name patterns ("package templates" in the UI): + * - "ru.*" - package names starting with "ru." + * - "*yandex*" - package names containing "yandex" anywhere + * - "*.beta" - package names ending with ".beta" + * - "ru.ozon.app" - without a star: prefix match, same as "ru.ozon.app*" + * Matching is case-insensitive. + */ +object PackagePatterns +{ + private val ALLOWED = Regex("[a-z0-9._*-]+") + private val STARS = Regex("\\*+") + + /** Canonical form of user input, or null if it is empty, too broad or has invalid characters. */ + fun normalize(input: String): String? + { + val text = input.trim().lowercase() + if (text.isEmpty() || !ALLOWED.matches(text)) + { + return null + } + val collapsed = text.replace(STARS, "*") + if (collapsed.replace("*", "").isEmpty()) + { + return null + } + return if (collapsed.contains('*')) collapsed else "$collapsed*" + } + + fun matches(pattern: String, pkg: String): Boolean + { + val core = pattern.trim('*').lowercase() + if (core.isEmpty()) + { + return false + } + val name = pkg.lowercase() + val starts = pattern.startsWith("*") + val ends = pattern.endsWith("*") + return when + { + starts && ends -> name.contains(core) + ends -> name.startsWith(core) + starts -> name.endsWith(core) + else -> name == core + } + } + + /** Packages (of [installed]) matched by any of the patterns. */ + fun matchAll(patterns: Set, installed: Set): Set = + if (patterns.isEmpty()) + { + emptySet() + } + else + { + installed.filterTo(HashSet()) { pkg -> patterns.any { matches(it, pkg) } } + } + + fun fromPrefix(prefix: String): String = "$prefix*" + + fun fromKeyword(keyword: String): String = "*${keyword.lowercase()}*" +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt index 800f45d4b..29473212b 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt @@ -15,9 +15,11 @@ package org.strongswan.android.logic.routing import android.app.Service +import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.ServiceConnection import android.content.pm.PackageManager import android.os.Handler @@ -34,10 +36,15 @@ import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import org.strongswan.android.data.VpnProfile.SelectedAppsHandling +import org.strongswan.android.data.VpnProfileSqlDataSource import org.strongswan.android.logic.VpnStateService +import org.strongswan.android.logic.routing.RoutingPresets.matches import java.util.UUID -/** A named set of applications that bypass the VPN tunnel. */ +/** + * A named set of applications that bypassed the VPN tunnel (pre-2.16 model). + * Only used to migrate old settings, see [RoutingMigration.migrateRuleSets]. + */ data class RuleSet( val id: String, val name: String, @@ -58,25 +65,51 @@ data class ForceTunnelPrefix( val enabled: Boolean = true, ) +/** + * The one global answer to "what goes around the VPN" for apps: + * [effectiveExcludes] = ([directPackages] + apps matched by [enabledCategories] + * and [packagePatterns]) - [tunnelOverrides]. + */ data class RoutingConfig( val enabled: Boolean = true, - val ruleSets: List = emptyList(), + /** Apps the user switched to "direct" one by one. */ + val directPackages: Set = emptySet(), + /** Apps switched back to "via VPN" although a category or pattern covers them. */ + val tunnelOverrides: Set = emptySet(), + /** Enabled built-in categories ([RoutingPresets] ids). */ + val enabledCategories: Set = emptySet(), + /** User package patterns, see [PackagePatterns]. */ + val packagePatterns: Set = emptySet(), val forceTunnelPrefixes: List = emptyList(), + val version: Int = RoutingManager.CONFIG_VERSION, +) + +/** Why a package is (not) direct, for the per-app reason line. */ +data class TemplateMatch( + /** Built-in category id (may be [RoutingPresets.NAMESPACE]) or null. */ + val category: String? = null, + /** User pattern or null. */ + val pattern: String? = null, ) data class RoutingState( val config: RoutingConfig = RoutingConfig(), + /** Number of profiles whose "only selected apps" list was converted at migration (one-time notice). */ + val migratedOnlyProfiles: Int = 0, ) /** - * Global per-app routing: named rule sets of applications that must NOT use - * the VPN tunnel (they go direct), independent of the per-profile split - * tunneling settings. [CharonVpnService.BuilderCache] merges the effective - * package list into the TUN builder on every (re)connect. + * Global per-app routing: one list of applications that must NOT use the VPN + * tunnel (they go direct). [CharonVpnService.BuilderCache] merges the + * effective package list into the TUN builder on every (re)connect. * - * When rules change while the VPN is connected the connection is restarted - * automatically (debounced), because per-app routing only applies when the - * TUN interface is created. + * When the list changes while the VPN is connected the connection is + * restarted automatically (debounced), because per-app routing only applies + * when the TUN interface is created. + * + * The constructor migrates pre-2.16 settings (rule sets, per-profile app + * lists) once, so it has to run before the first VPN start - it does, from + * StrongSwanApplication.onCreate. */ class RoutingManager private constructor(private val context: Context) { @@ -85,8 +118,9 @@ class RoutingManager private constructor(private val context: Context) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var config = RoutingConfig() private var service: VpnStateService? = null + private var migratedOnlyProfiles = 0 - /* package cache: expanded prefixes against installed apps, keyed by config version */ + /* package cache: expanded patterns against installed apps, keyed by config version */ @Volatile private var configVersion = 0L @@ -118,15 +152,33 @@ class RoutingManager private constructor(private val context: Context) } } + /* new/removed apps change what categories and patterns match */ + private val packagesReceiver = object : BroadcastReceiver() + { + override fun onReceive(context: Context?, intent: Intent?) + { + invalidateCache() + scope.launch { computeExcludedPackages() } + } + } + init { config = loadConfig() + migrateProfiles() + migratedOnlyProfiles = prefs.getInt(KEY_NOTICE_ONLY, 0) publish() } fun start() { context.bindService(Intent(context, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE) + val filter = IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addDataScheme("package") + } + context.registerReceiver(packagesReceiver, filter) } fun updateConfig(transform: (RoutingConfig) -> RoutingConfig) @@ -149,90 +201,104 @@ class RoutingManager private constructor(private val context: Context) updateConfig { it.copy(enabled = enabled) } } - /** Adds an empty rule set and returns its id so the UI can expand and focus it. */ - fun addRuleSet(): String - { - val id = UUID.randomUUID().toString() - updateConfig { config -> - config.copy(ruleSets = config.ruleSets + RuleSet(id = id, name = "")) - } - return id - } - - fun deleteRuleSet(id: String) - { - updateConfig { config -> config.copy(ruleSets = config.ruleSets.filter { it.id != id }) } - } - - /** Re-inserts a just-deleted rule set (the snackbar "Undo" action). */ - fun restoreRuleSet(ruleSet: RuleSet) - { - updateConfig { config -> config.copy(ruleSets = config.ruleSets + ruleSet) } - } - - /** A rename never affects routing, so it must not schedule a reconnect. */ - fun updateRuleSetName(id: String, name: String) - { - config = config.copy(ruleSets = config.ruleSets.map { if (it.id == id) it.copy(name = name) else it }) - saveConfig(config) - publish() - } - - fun setRuleSetEnabled(id: String, enabled: Boolean) - { - updateConfig { config -> config.copy(ruleSets = config.ruleSets.map { if (it.id == id) it.copy(enabled = enabled) else it }) } - } - - fun updateRuleSetPackages(id: String, packages: Set) - { - updateConfig { config -> config.copy(ruleSets = config.ruleSets.map { if (it.id == id) it.copy(packages = packages) else it }) } - } - - /** Toggles a preset on a rule set: adds its prefixes/keywords, or removes them if all present. */ - fun togglePreset(id: String, preset: RoutingPresets.Preset) + /** + * The per-app switch: "direct" adds the app (or drops its tunnel + * override when a category/pattern already covers it); "via VPN" removes + * it, keeping a personal override when a category/pattern covers it. + */ + fun setAppDirect(pkg: String, direct: Boolean) { updateConfig { config -> - config.copy(ruleSets = config.ruleSets.map { ruleSet -> - if (ruleSet.id != id) - { - ruleSet - } - else if (ruleSet.prefixes.containsAll(preset.prefixes) && ruleSet.keywords.containsAll(preset.keywords)) - { - ruleSet.copy( - prefixes = ruleSet.prefixes - preset.prefixes.toSet(), - keywords = ruleSet.keywords - preset.keywords, - ) - } - else - { - ruleSet.copy( - prefixes = ruleSet.prefixes + preset.prefixes, - keywords = ruleSet.keywords + preset.keywords.map { it.lowercase() }, - ) - } - }) - } - } - - /** Quick action: enable every preset of the RF bundle as its own rule set. */ - fun applyRfBundle() - { - updateConfig { config -> - val sets = config.ruleSets.toMutableList() - for (preset in RoutingPresets.RF_BUNDLE) + val covered = templateMatch(pkg, config) != null + if (direct) { - val index = sets.indexOfFirst { it.name == preset.name } - if (index < 0) - { - sets.add(preset.asRuleSet()) - } - else if (!sets[index].enabled) - { - sets[index] = sets[index].copy(enabled = true) - } + config.copy( + enabled = true, + tunnelOverrides = config.tunnelOverrides - pkg, + directPackages = if (covered) config.directPackages else config.directPackages + pkg, + ) } - config.copy(enabled = true, ruleSets = sets) + else + { + config.copy( + directPackages = config.directPackages - pkg, + tunnelOverrides = if (covered) config.tunnelOverrides + pkg else config.tunnelOverrides, + ) + } + } + } + + /** Switches a built-in category on or off. Switching on also clears personal "via VPN" exceptions inside it. */ + fun setCategoryEnabled(category: String, enabled: Boolean) + { + updateConfig { config -> + if (enabled) + { + config.copy( + enabled = true, + enabledCategories = config.enabledCategories + category, + tunnelOverrides = config.tunnelOverrides.filterNot { RoutingPresets.matchesCategory(category, it) }.toSet(), + ) + } + else + { + config.copy(enabledCategories = config.enabledCategories - category) + } + } + } + + /** Quick action: every RF category plus the ru.* namespace ("all RF apps"). */ + fun enableAllRfApps() + { + updateConfig { config -> + config.copy( + enabled = true, + enabledCategories = config.enabledCategories + RoutingPresets.ALL_CATEGORIES, + tunnelOverrides = config.tunnelOverrides.filterNot { pkg -> + RoutingPresets.ALL_CATEGORIES.any { RoutingPresets.matchesCategory(it, pkg) } + }.toSet(), + ) + } + } + + /** Adds a package pattern; false if the input is invalid. */ + fun addPattern(input: String): Boolean + { + val pattern = PackagePatterns.normalize(input) ?: return false + updateConfig { it.copy(packagePatterns = it.packagePatterns + pattern) } + return true + } + + fun removePattern(pattern: String) + { + updateConfig { it.copy(packagePatterns = it.packagePatterns - pattern) } + } + + /** + * Imports the split-tunneling apps of a profile file (JSON import): the + * per-profile setting no longer exists, so the apps join the global + * direct list ("excluded-apps" as is, "apps" as every other app). + */ + fun importProfileApps(handling: SelectedAppsHandling, selected: Set) + { + val added = RoutingMigration.profileAppsToDirect( + handling, selected, + if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY) loadInstalledPackages() else emptySet(), + context.packageName) + if (added.isNotEmpty()) + { + updateConfig { it.copy(enabled = true, directPackages = it.directPackages + added, tunnelOverrides = it.tunnelOverrides - added) } + } + } + + /** The one-time "your 'only selected apps' profile was converted" notice has been shown. */ + fun consumeMigrationNotice() + { + if (migratedOnlyProfiles != 0) + { + migratedOnlyProfiles = 0 + prefs.edit().putInt(KEY_NOTICE_ONLY, 0).apply() + publish() } } @@ -318,7 +384,8 @@ class RoutingManager private constructor(private val context: Context) */ fun computeExcludedPackages(): Set { - if (!config.enabled || config.ruleSets.none { it.enabled }) + val current = config + if (!current.enabled) { return emptySet() } @@ -337,7 +404,7 @@ class RoutingManager private constructor(private val context: Context) return packages } } - val packages = effectiveExcludes(config.ruleSets, loadInstalledPackages()) + val packages = effectiveExcludes(current, loadInstalledPackages()) packageCache = version to packages return packages } @@ -349,10 +416,213 @@ class RoutingManager private constructor(private val context: Context) packageCache = null } - private fun loadInstalledPackages(): Set + private fun loadInstalledPackages(): Set = loadInternetPackages(context.packageManager) + + private fun publish() { - val pm = context.packageManager + _state.value = RoutingState(config = config, migratedOnlyProfiles = migratedOnlyProfiles) + } + + private fun scheduleReconnect() + { + handler.removeCallbacks(reconnectRunnable) + handler.postDelayed(reconnectRunnable, RECONNECT_DEBOUNCE_MS) + } + + /* ------------------------- persistence + migration ------------------------- */ + + private fun loadConfig(): RoutingConfig + { + val json = prefs.getString(KEY_CONFIG, null) ?: return RoutingConfig() return try + { + val obj = JSONObject(json) + if (obj.optInt("version", 1) >= CONFIG_VERSION) + { + parseConfig(obj) + } + else + { + migrateLegacyConfig(json, obj) + } + } + catch (_: JSONException) + { + RoutingConfig() + } + } + + private fun parseConfig(obj: JSONObject) = RoutingConfig( + enabled = obj.optBoolean("enabled", true), + directPackages = obj.optJSONArray("directPackages").toStringSet(), + tunnelOverrides = obj.optJSONArray("tunnelOverrides").toStringSet(), + enabledCategories = obj.optJSONArray("enabledCategories").toStringSet(), + packagePatterns = obj.optJSONArray("packagePatterns").toStringSet(), + forceTunnelPrefixes = parseForcePrefixes(obj), + version = CONFIG_VERSION, + ) + + private fun parseForcePrefixes(obj: JSONObject): List = + obj.optJSONArray("forceTunnelPrefixes")?.let { arr -> + (0 until arr.length()).mapNotNull { i -> + arr.optJSONObject(i)?.let { o -> + ForceTunnelPrefix( + id = o.optString("id"), + prefix = o.optString("prefix"), + enabled = o.optBoolean("enabled", true), + ) + } + } + } ?: emptyList() + + /** Rule sets (version 1) -> direct apps + package patterns; the old JSON is kept in a backup key. */ + private fun migrateLegacyConfig(json: String, obj: JSONObject): RoutingConfig + { + if (!prefs.contains(KEY_CONFIG_BACKUP)) + { + prefs.edit().putString(KEY_CONFIG_BACKUP, json).commit() + } + val ruleSets = obj.optJSONArray("ruleSets")?.let { arr -> + (0 until arr.length()).mapNotNull { i -> + arr.optJSONObject(i)?.let { o -> + RuleSet( + id = o.optString("id"), + name = o.optString("name"), + enabled = o.optBoolean("enabled", true), + packages = o.optJSONArray("packages").toStringSet(), + prefixes = o.optJSONArray("prefixes").toStringSet(), + keywords = o.optJSONArray("keywords").toStringSet(), + ) + } + } + } ?: emptyList() + val migrated = RoutingMigration.migrateRuleSets(ruleSets) + val result = RoutingConfig( + enabled = obj.optBoolean("enabled", true), + directPackages = migrated.directPackages, + enabledCategories = migrated.categories, + packagePatterns = migrated.patterns, + forceTunnelPrefixes = parseForcePrefixes(obj), + ) + Log.i(TAG, "Migrated ${ruleSets.size} rule sets to ${result.directPackages.size} apps, " + + "${result.packagePatterns.size} patterns, ${result.enabledCategories.size} categories") + saveConfig(result, commit = true) + return result + } + + /** + * Per-profile app handling is gone: EXCLUDE lists become direct apps, an + * ONLY list becomes "every other installed internet app" (snapshot), and + * the profile is reset to DISABLE. Runs once (flag in prefs); a failure + * leaves the flag unset so the next start retries. + */ + private fun migrateProfiles() + { + if (prefs.getBoolean(KEY_PROFILES_MIGRATED, false)) + { + return + } + try + { + val source = VpnProfileSqlDataSource() + source.open() + val direct = HashSet() + var onlyConverted = 0 + try + { + val installed by lazy { loadInstalledPackages() } + for (profile in source.allVpnProfiles) + { + val handling = profile.selectedAppsHandling + if (handling == SelectedAppsHandling.SELECTED_APPS_DISABLE) + { + continue + } + val added = RoutingMigration.profileAppsToDirect( + handling, profile.selectedAppsSet, if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY) installed else emptySet(), + context.packageName) + direct.addAll(added) + if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY && added.isNotEmpty()) + { + onlyConverted++ + } + profile.setSelectedAppsHandling(SelectedAppsHandling.SELECTED_APPS_DISABLE) + source.updateVpnProfile(profile) + } + } + finally + { + source.close() + } + if (direct.isNotEmpty()) + { /* the old per-profile excludes applied regardless of the global switch */ + config = config.copy(enabled = true, directPackages = config.directPackages + direct, + tunnelOverrides = config.tunnelOverrides - direct) + saveConfig(config, commit = true) + } + prefs.edit() + .putBoolean(KEY_PROFILES_MIGRATED, true) + .putInt(KEY_NOTICE_ONLY, onlyConverted) + .commit() + Log.i(TAG, "Migrated profile apps: ${direct.size} direct apps, $onlyConverted 'only' profiles converted") + } + catch (e: Throwable) + { + Log.w(TAG, "Profile app migration failed, will retry on next start", e) + } + } + + private fun saveConfig(config: RoutingConfig, commit: Boolean = false) + { + val obj = JSONObject() + obj.put("version", CONFIG_VERSION) + obj.put("enabled", config.enabled) + obj.put("directPackages", JSONArray(config.directPackages.sorted())) + obj.put("tunnelOverrides", JSONArray(config.tunnelOverrides.sorted())) + obj.put("enabledCategories", JSONArray(config.enabledCategories.sorted())) + obj.put("packagePatterns", JSONArray(config.packagePatterns.sorted())) + val forcePrefixes = JSONArray() + for (entry in config.forceTunnelPrefixes) + { + val o = JSONObject() + o.put("id", entry.id) + o.put("prefix", entry.prefix) + o.put("enabled", entry.enabled) + forcePrefixes.put(o) + } + obj.put("forceTunnelPrefixes", forcePrefixes) + val editor = prefs.edit().putString(KEY_CONFIG, obj.toString()) + if (commit) editor.commit() else editor.apply() + } + + private fun JSONArray?.toStringSet(): Set = + this?.let { arr -> (0 until arr.length()).mapNotNull { i -> arr.optString(i).takeIf { it.isNotBlank() } }.toSet() } ?: emptySet() + + companion object + { + private const val TAG = "RoutingManager" + private const val PREFS_NAME = "one.shx.strongswan.ext.ROUTING" + private const val KEY_CONFIG = "config" + private const val KEY_CONFIG_BACKUP = "config_v1_backup" + private const val KEY_PROFILES_MIGRATED = "profiles_migrated_v2" + private const val KEY_NOTICE_ONLY = "notice_only_profiles" + private const val RECONNECT_DEBOUNCE_MS = 2000L + + /** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */ + const val CONFIG_VERSION = 2 + + /** Validates and canonicalizes a CIDR/address; null if invalid. */ + fun normalizePrefix(cidr: String): String? = try + { + org.strongswan.android.utils.IPRange(cidr.trim()).toString() + } + catch (_: Exception) + { + null + } + + /** All installed apps that hold the INTERNET permission. */ + fun loadInternetPackages(pm: PackageManager): Set = try { pm.getInstalledApplications(PackageManager.GET_META_DATA) .asSequence() @@ -365,193 +635,59 @@ class RoutingManager private constructor(private val context: Context) Log.w(TAG, "Failed to list installed packages", e) emptySet() } - } - private fun publish() - { - _state.value = RoutingState(config = config) - } - - private fun scheduleReconnect() - { - handler.removeCallbacks(reconnectRunnable) - handler.postDelayed(reconnectRunnable, RECONNECT_DEBOUNCE_MS) - } - - private fun loadConfig(): RoutingConfig - { - val json = prefs.getString(KEY_CONFIG, null) ?: return RoutingConfig() - return try + /** Built-in categories that are enabled in [config] and match [pkg], visible ones first. */ + private fun matchingCategory(pkg: String, config: RoutingConfig): String? { - val obj = JSONObject(json) - RoutingConfig( - enabled = obj.optBoolean("enabled", true), - ruleSets = obj.optJSONArray("ruleSets")?.let { arr -> - (0 until arr.length()).mapNotNull { i -> - arr.optJSONObject(i)?.let { o -> - RuleSet( - id = o.optString("id"), - name = o.optString("name"), - enabled = o.optBoolean("enabled", true), - packages = o.optJSONArray("packages").toStringSet(), - prefixes = o.optJSONArray("prefixes").toStringSet(), - keywords = o.optJSONArray("keywords").toStringSet(), - ) - } - } - } ?: emptyList(), - forceTunnelPrefixes = obj.optJSONArray("forceTunnelPrefixes")?.let { arr -> - (0 until arr.length()).mapNotNull { i -> - arr.optJSONObject(i)?.let { o -> - ForceTunnelPrefix( - id = o.optString("id"), - prefix = o.optString("prefix"), - enabled = o.optBoolean("enabled", true), - ) - } - } - } ?: emptyList(), - ) - } - catch (_: JSONException) - { - RoutingConfig() - } - } - - private fun saveConfig(config: RoutingConfig) - { - val obj = JSONObject() - obj.put("enabled", config.enabled) - val sets = JSONArray() - for (ruleSet in config.ruleSets) - { - val o = JSONObject() - o.put("id", ruleSet.id) - o.put("name", ruleSet.name) - o.put("enabled", ruleSet.enabled) - o.put("packages", JSONArray(ruleSet.packages.sorted())) - o.put("prefixes", JSONArray(ruleSet.prefixes.sorted())) - o.put("keywords", JSONArray(ruleSet.keywords.map { it.lowercase() }.sorted())) - sets.put(o) - } - obj.put("ruleSets", sets) - val forcePrefixes = JSONArray() - for (entry in config.forceTunnelPrefixes) - { - val o = JSONObject() - o.put("id", entry.id) - o.put("prefix", entry.prefix) - o.put("enabled", entry.enabled) - forcePrefixes.put(o) - } - obj.put("forceTunnelPrefixes", forcePrefixes) - prefs.edit().putString(KEY_CONFIG, obj.toString()).apply() - } - - private fun JSONArray?.toStringSet(): Set = - this?.let { arr -> (0 until arr.length()).mapNotNull { i -> arr.optString(i).takeIf { it.isNotBlank() } }.toSet() } ?: emptySet() - - companion object - { - private const val TAG = "RoutingManager" - private const val PREFS_NAME = "one.shx.strongswan.ext.ROUTING" - private const val KEY_CONFIG = "config" - private const val RECONNECT_DEBOUNCE_MS = 2000L - - /** Validates and canonicalizes a CIDR/address; null if invalid. */ - fun normalizePrefix(cidr: String): String? = try - { - org.strongswan.android.utils.IPRange(cidr.trim()).toString() - } - catch (_: Exception) - { - null + val visible = RoutingPresets.CATEGORIES.firstOrNull { + it in config.enabledCategories && RoutingPresets.matchesCategory(it, pkg) + } + if (visible != null) + { + return visible + } + return RoutingPresets.NAMESPACE.takeIf { + it in config.enabledCategories && RoutingPresets.matchesCategory(it, pkg) + } } /** - * All packages matched by the enabled rule sets: explicit packages, - * prefixes and keywords matched against the installed packages. + * Why a category or pattern covers [pkg] (ignoring personal overrides + * and the explicit list), or null if none does. */ - fun effectiveExcludes(ruleSets: List, installed: Set): Set + fun templateMatch(pkg: String, config: RoutingConfig): TemplateMatch? + { + val pattern = config.packagePatterns.firstOrNull { PackagePatterns.matches(it, pkg) } + val category = matchingCategory(pkg, config) + return if (category == null && pattern == null) null else TemplateMatch(category, pattern) + } + + /** + * All packages that bypass the tunnel: explicit direct apps plus the + * installed apps matched by enabled categories and package patterns, + * minus personal "via VPN" overrides. Explicit apps that are not + * installed are dropped (unless the installed list is unavailable). + */ + fun effectiveExcludes(config: RoutingConfig, installed: Set): Set { val result = HashSet() - for (ruleSet in ruleSets) + result.addAll(if (installed.isEmpty()) config.directPackages else config.directPackages.filter { it in installed }) + result.addAll(PackagePatterns.matchAll(config.packagePatterns, installed)) + for (category in config.enabledCategories) { - if (!ruleSet.enabled) + val presets = RoutingPresets.presetsOf(category) + for (pkg in installed) { - continue + if (presets.any { it.matches(pkg) }) + { + result.add(pkg) + } } - result.addAll(ruleSet.packages) - result.addAll(matchPrefixes(ruleSet.prefixes, installed)) - result.addAll(matchKeywords(ruleSet.keywords, installed)) } + result.removeAll(config.tunnelOverrides) return result } - /** Installed packages whose name starts with any of the prefixes. */ - fun matchPrefixes(prefixes: Set, installed: Set): Set = - if (prefixes.isEmpty()) - { - emptySet() - } - else - { - installed.filter { pkg -> prefixes.any { pkg.startsWith(it) } }.toSet() - } - - /** Installed packages whose name contains any keyword (case-insensitive, anywhere). */ - fun matchKeywords(keywords: Set, installed: Set): Set = - if (keywords.isEmpty()) - { - emptySet() - } - else - { - val lower = keywords.map { it.lowercase() } - installed.filter { pkg -> lower.any { pkg.lowercase().contains(it) } }.toSet() - } - - /** - * Merges the global excludes into a profile's per-app split tunneling - * settings (applied in-memory when the TUN is built, the stored profile - * is untouched): - * - EXCLUDE (incl. DISABLE, converted by the caller): global packages - * are added to the excluded set; - * - ONLY: global packages are removed from the allowed set - in ONLY - * mode everything else goes direct anyway, so this alone is enough. - * - * @return the effective handling (DISABLE is normalized to EXCLUDE) - */ - @JvmStatic - fun applyGlobalExcludes(handling: SelectedAppsHandling, selected: MutableSet, global: Set): SelectedAppsHandling - { - if (global.isEmpty()) - { - return if (handling == SelectedAppsHandling.SELECTED_APPS_DISABLE) - { - SelectedAppsHandling.SELECTED_APPS_EXCLUDE - } - else - { - handling - } - } - return when (handling) - { - SelectedAppsHandling.SELECTED_APPS_ONLY -> - { - selected.removeAll(global) - SelectedAppsHandling.SELECTED_APPS_ONLY - } - else -> - { - selected.addAll(global) - SelectedAppsHandling.SELECTED_APPS_EXCLUDE - } - } - } - @Volatile private var instance: RoutingManager? = null diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingMigration.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingMigration.kt new file mode 100644 index 000000000..66dd4672f --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingMigration.kt @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic.routing + +import org.strongswan.android.data.VpnProfile.SelectedAppsHandling + +/** + * Pure conversion rules from the pre-2.16 settings (named rule sets and + * per-profile split-tunneling apps) to the single global direct-apps list. + * No Android dependencies, so they are unit tested; [RoutingManager] applies + * them once at startup. + */ +object RoutingMigration +{ + data class RuleSetResult( + val directPackages: Set, + val patterns: Set, + val categories: Set, + ) + + /** + * Enabled rule sets become explicit packages plus package patterns. A + * built-in category whose every preset is fully present among the rule + * sets (what the old "all RF apps" action produced) turns into the + * category itself - same coverage, but shown as one switch. Disabled rule + * sets are dropped (the old JSON is kept in a backup key). + */ + fun migrateRuleSets(ruleSets: List): RuleSetResult + { + val active = ruleSets.filter { it.enabled } + val direct = HashSet() + val patterns = LinkedHashSet() + for (ruleSet in active) + { + direct.addAll(ruleSet.packages) + ruleSet.prefixes.forEach { patterns.add(PackagePatterns.fromPrefix(it)) } + ruleSet.keywords.forEach { patterns.add(PackagePatterns.fromKeyword(it)) } + } + val categories = LinkedHashSet() + for (category in RoutingPresets.ALL_CATEGORIES) + { + val presets = RoutingPresets.presetsOf(category) + if (presets.isNotEmpty() && presets.all { preset -> active.any { it.covers(preset) } }) + { + categories.add(category) + for (preset in presets) + { + preset.prefixes.forEach { patterns.remove(PackagePatterns.fromPrefix(it)) } + preset.keywords.forEach { patterns.remove(PackagePatterns.fromKeyword(it)) } + } + } + } + return RuleSetResult(direct, patterns, categories) + } + + private fun RuleSet.covers(preset: RoutingPresets.Preset): Boolean + { + if (preset.prefixes.isEmpty() && preset.keywords.isEmpty()) + { + return false + } + val lowerKeywords = keywords.map { it.lowercase() }.toSet() + return prefixes.containsAll(preset.prefixes) && lowerKeywords.containsAll(preset.keywords.map { it.lowercase() }) + } + + /** + * Packages a profile's old per-app setting sends direct (outside the VPN): + * - EXCLUDE: the listed apps; + * - ONLY: "all installed internet apps except the listed ones" snapshot + * (an empty ONLY list never took effect in the old code, so it yields + * nothing); + * - DISABLE: nothing. + * The app's own package is never listed (it is always excluded anyway). + */ + fun profileAppsToDirect( + handling: SelectedAppsHandling, + selected: Set, + installed: Set, + ownPackage: String, + ): Set = when (handling) + { + SelectedAppsHandling.SELECTED_APPS_EXCLUDE -> selected - ownPackage + SelectedAppsHandling.SELECTED_APPS_ONLY -> + if (selected.isEmpty()) emptySet() else installed - selected - ownPackage + else -> emptySet() + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingPresets.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingPresets.kt index 930be1021..31f7c424f 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingPresets.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingPresets.kt @@ -14,10 +14,9 @@ package org.strongswan.android.logic.routing -import java.util.UUID - /** - * Built-in rule set templates. + * Built-in app templates, grouped into categories the user can switch on in + * one tap ("Banks", "Marketplaces", ...). * - Prefixes match package names, so "ru." catches every package in the ru.* * reverse-domain namespace. * - Keywords match anywhere in the package name (case-insensitive), so @@ -28,75 +27,112 @@ import java.util.UUID */ object RoutingPresets { - data class Preset(val name: String, val prefixes: List = emptyList(), val keywords: List = emptyList()) + /* category ids; the namespace-wide preset lives in the hidden NAMESPACE + * category that only "all RF apps" switches on */ + const val BANKS = "banks" + const val GOV = "gov" + const val SHOP = "shop" + const val SOCIAL = "social" + const val TRAVEL = "travel" + const val SERVICES = "services" + const val NAMESPACE = "ru_namespace" + + /** User-visible categories in display order (the hidden [NAMESPACE] one is excluded). */ + val CATEGORIES: List = listOf(BANKS, GOV, SHOP, SOCIAL, TRAVEL, SERVICES) + + /** Everything "all RF apps" switches on. */ + val ALL_CATEGORIES: Set = (CATEGORIES + NAMESPACE).toSet() + + data class Preset( + val name: String, + val category: String, + val prefixes: List = emptyList(), + val keywords: List = emptyList(), + ) + + /** Whether a package belongs to the preset (prefix, or keyword anywhere, case-insensitive). */ + fun Preset.matches(pkg: String): Boolean + { + if (prefixes.any { pkg.startsWith(it) }) + { + return true + } + val lower = pkg.lowercase() + return keywords.any { lower.contains(it.lowercase()) } + } + + /** Presets of one category, in declaration order. */ + fun presetsOf(category: String): List = RF_BUNDLE.filter { it.category == category } + + /** Whether the package matches any preset of [category] (the namespace one included). */ + fun matchesCategory(category: String, pkg: String): Boolean = + presetsOf(category).any { it.matches(pkg) } + + /** First user-visible category the package belongs to, or null. */ + fun categoryOf(pkg: String): String? = + CATEGORIES.firstOrNull { matchesCategory(it, pkg) } val RF_BUNDLE: List = listOf( /* namespace-wide */ - Preset("Все RU.*", prefixes = listOf("ru.")), + Preset("Все RU.*", NAMESPACE, prefixes = listOf("ru.")), /* ecosystems */ - Preset("Яндекс", prefixes = listOf("ru.yandex."), keywords = listOf("yandex")), - Preset("VK", prefixes = listOf("com.vkontakte", "ru.vk", "com.vk"), keywords = listOf("vkontakte")), - Preset("Одноклассники", prefixes = listOf("ru.ok.")), - Preset("Дзен", keywords = listOf("dzen")), - Preset("Rutube", keywords = listOf("rutube")), - Preset("Звук", keywords = listOf("zvuk", "zvooq")), - Preset("Иви", prefixes = listOf("ru.ivi")), + Preset("Яндекс", SOCIAL, prefixes = listOf("ru.yandex."), keywords = listOf("yandex")), + Preset("VK", SOCIAL, prefixes = listOf("com.vkontakte", "ru.vk", "com.vk"), keywords = listOf("vkontakte")), + Preset("Одноклассники", SOCIAL, prefixes = listOf("ru.ok.")), + Preset("Дзен", SOCIAL, keywords = listOf("dzen")), + Preset("Rutube", SOCIAL, keywords = listOf("rutube")), + Preset("Звук", SOCIAL, keywords = listOf("zvuk", "zvooq")), + Preset("Иви", SOCIAL, prefixes = listOf("ru.ivi")), /* banks & finance */ - Preset("Сбер", keywords = listOf("sber")), - Preset("Т-Банк", keywords = listOf("tinkoff", "tbank")), - Preset("ВТБ", keywords = listOf("vtb")), - Preset("Альфа-Банк", keywords = listOf("alfabank", "alpha-bank")), - Preset("Газпромбанк", keywords = listOf("gazprombank")), - Preset("Совкомбанк", keywords = listOf("sovcombank")), - Preset("Почта Банк", keywords = listOf("pochtabank")), - Preset("РСХБ", keywords = listOf("rshb", "rosselkhozbank")), - Preset("ОТП-Банк", prefixes = listOf("ru.otpbank"), keywords = listOf("otpbank")), - Preset("МКБ", prefixes = listOf("ru.mkb")), - Preset("Уралсиб", keywords = listOf("uralsib")), - Preset("ЮMoney", keywords = listOf("yoomoney")), - Preset("Золотая Корона", keywords = listOf("koronapay")), - Preset("Мой налог", keywords = listOf("nalog")), + Preset("Сбер", BANKS, keywords = listOf("sber")), + Preset("Т-Банк", BANKS, keywords = listOf("tinkoff", "tbank")), + Preset("ВТБ", BANKS, keywords = listOf("vtb")), + Preset("Альфа-Банк", BANKS, keywords = listOf("alfabank", "alpha-bank")), + Preset("Газпромбанк", BANKS, keywords = listOf("gazprombank")), + Preset("Совкомбанк", BANKS, keywords = listOf("sovcombank")), + Preset("Почта Банк", BANKS, keywords = listOf("pochtabank")), + Preset("РСХБ", BANKS, keywords = listOf("rshb", "rosselkhozbank")), + Preset("ОТП-Банк", BANKS, prefixes = listOf("ru.otpbank"), keywords = listOf("otpbank")), + Preset("МКБ", BANKS, prefixes = listOf("ru.mkb")), + Preset("Уралсиб", BANKS, keywords = listOf("uralsib")), + Preset("ЮMoney", BANKS, keywords = listOf("yoomoney")), + Preset("Золотая Корона", BANKS, keywords = listOf("koronapay")), + /* government */ + Preset("Госуслуги", GOV, prefixes = listOf("ru.rostel"), keywords = listOf("gosuslugi")), + Preset("Мой налог", GOV, keywords = listOf("nalog")), /* retail & marketplaces */ - Preset("Ozon", keywords = listOf("ozon")), - Preset("Wildberries", keywords = listOf("wildberries")), - Preset("Мегамаркет", keywords = listOf("megamarket")), - Preset("Пятёрочка", keywords = listOf("pyaterochka")), - Preset("Перекрёсток", keywords = listOf("perekrestok")), - Preset("Магнит", keywords = listOf("magnit")), - Preset("Лента", keywords = listOf("lenta")), - Preset("М.Видео", keywords = listOf("mvideo")), - Preset("Спортмастер", keywords = listOf("sportmaster")), - Preset("Лемана ПРО", keywords = listOf("leroymerlin", "lemana")), - Preset("Самокат", keywords = listOf("samokat")), - Preset("ВкусВилл", keywords = listOf("vkusvill")), + Preset("Ozon", SHOP, keywords = listOf("ozon")), + Preset("Wildberries", SHOP, keywords = listOf("wildberries")), + Preset("Мегамаркет", SHOP, keywords = listOf("megamarket")), + Preset("Пятёрочка", SHOP, keywords = listOf("pyaterochka")), + Preset("Перекрёсток", SHOP, keywords = listOf("perekrestok")), + Preset("Магнит", SHOP, keywords = listOf("magnit")), + Preset("Лента", SHOP, keywords = listOf("lenta")), + Preset("М.Видео", SHOP, keywords = listOf("mvideo")), + Preset("Спортмастер", SHOP, keywords = listOf("sportmaster")), + Preset("Лемана ПРО", SHOP, keywords = listOf("leroymerlin", "lemana")), + Preset("Самокат", SHOP, keywords = listOf("samokat")), + Preset("ВкусВилл", SHOP, keywords = listOf("vkusvill")), /* telecom */ - Preset("МТС", prefixes = listOf("ru.mts", "com.mts")), - Preset("Мегафон", keywords = listOf("megafon")), - Preset("Билайн", keywords = listOf("beeline")), - Preset("T2/Tele2", prefixes = listOf("ru.t2"), keywords = listOf("tele2")), - Preset("Yota", prefixes = listOf("ru.yota")), + Preset("МТС", SOCIAL, prefixes = listOf("ru.mts", "com.mts")), + Preset("Мегафон", SOCIAL, keywords = listOf("megafon")), + Preset("Билайн", SOCIAL, keywords = listOf("beeline")), + Preset("T2/Tele2", SOCIAL, prefixes = listOf("ru.t2"), keywords = listOf("tele2")), + Preset("Yota", SOCIAL, prefixes = listOf("ru.yota")), /* transport & travel */ - Preset("РЖД", keywords = listOf("rzd")), - Preset("Аэрофлот", keywords = listOf("aeroflot")), - Preset("Туту.ру", keywords = listOf("tutu.ru")), - Preset("Aviasales", keywords = listOf("aviasales")), - Preset("Ситимобил", keywords = listOf("sitimobil")), - Preset("Делимобиль", keywords = listOf("delimobil")), + Preset("РЖД", TRAVEL, keywords = listOf("rzd")), + Preset("Аэрофлот", TRAVEL, keywords = listOf("aeroflot")), + Preset("Туту.ру", TRAVEL, keywords = listOf("tutu.ru")), + Preset("Aviasales", TRAVEL, keywords = listOf("aviasales")), + Preset("Ситимобил", TRAVEL, keywords = listOf("sitimobil")), + Preset("Делимобиль", TRAVEL, keywords = listOf("delimobil")), /* services */ - Preset("2ГИС", keywords = listOf("2gis", "dublgis")), - Preset("ЦИАН", prefixes = listOf("ru.cian")), - Preset("Дром", keywords = listOf("drom")), - Preset("Юла", keywords = listOf("youla")), - Preset("HeadHunter", prefixes = listOf("ru.hh"), keywords = listOf("headhunter")), - Preset("Почта России", keywords = listOf("pochta", "russianpost")), - Preset("СДЭК", keywords = listOf("cdek")), + Preset("2ГИС", SERVICES, keywords = listOf("2gis", "dublgis")), + Preset("ЦИАН", SERVICES, prefixes = listOf("ru.cian")), + Preset("Дром", SERVICES, keywords = listOf("drom")), + Preset("Юла", SERVICES, keywords = listOf("youla")), + Preset("HeadHunter", SERVICES, prefixes = listOf("ru.hh"), keywords = listOf("headhunter")), + Preset("Почта России", SERVICES, keywords = listOf("pochta", "russianpost")), + Preset("СДЭК", SERVICES, keywords = listOf("cdek")), ) } - -fun RoutingPresets.Preset.asRuleSet(): RuleSet = RuleSet( - id = UUID.randomUUID().toString(), - name = name, - enabled = true, - prefixes = prefixes.toSet(), - keywords = keywords.map { it.lowercase() }.toSet(), -) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileImportActivity.java b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileImportActivity.java index 65abdcf63..23a700c55 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileImportActivity.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileImportActivity.java @@ -41,6 +41,7 @@ import org.strongswan.android.data.VpnProfileSource; import org.strongswan.android.data.VpnType; import org.strongswan.android.data.VpnType.VpnTypeFeature; import org.strongswan.android.logic.TrustedCertificateManager; +import org.strongswan.android.logic.routing.RoutingManager; import org.strongswan.android.security.TrustedCertificateEntry; import org.strongswan.android.ui.compose.ImportUiModel; import org.strongswan.android.ui.compose.ImportScreenKt; @@ -629,6 +630,11 @@ public class VpnProfileImportActivity extends AppCompatActivity if (verifyInput()) { updateProfileData(); + if (mProfile.getSelectedAppsHandling() != SelectedAppsHandling.SELECTED_APPS_DISABLE) + { /* per-profile apps are gone: they join the global direct-apps list */ + RoutingManager.getInstance(this).importProfileApps(mProfile.getSelectedAppsHandling(), mProfile.getSelectedAppsSet()); + mProfile.setSelectedAppsHandling(SelectedAppsHandling.SELECTED_APPS_DISABLE); + } if (mExisting != null) { mProfile.setUUID(mExisting.getUUID()); diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/PackagePatternsTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/PackagePatternsTest.kt new file mode 100644 index 000000000..b90a72232 --- /dev/null +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/PackagePatternsTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic.routing + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class PackagePatternsTest +{ + @Test + fun bareInputBecomesPrefixPattern() + { + assertThat(PackagePatterns.normalize(" RU.Ozon ")).isEqualTo("ru.ozon*") + } + + @Test + fun starsAreCollapsedAndKept() + { + assertThat(PackagePatterns.normalize("**yandex**")).isEqualTo("*yandex*") + } + + @Test + fun tooBroadOrInvalidInputIsRejected() + { + assertThat(PackagePatterns.normalize("")).isNull() + assertThat(PackagePatterns.normalize("*")).isNull() + assertThat(PackagePatterns.normalize("**")).isNull() + assertThat(PackagePatterns.normalize("a b")).isNull() + assertThat(PackagePatterns.normalize("ru/x")).isNull() + } + + @Test + fun prefixKeywordSuffixAndExactMatching() + { + assertThat(PackagePatterns.matches("ru.*", "ru.ozon.app")).isTrue() + assertThat(PackagePatterns.matches("ru.*", "com.ru.app")).isFalse() + assertThat(PackagePatterns.matches("*yandex*", "com.Yandex.browser")).isTrue() + assertThat(PackagePatterns.matches("*.beta", "org.app.beta")).isTrue() + assertThat(PackagePatterns.matches("*.beta", "org.app.beta2")).isFalse() + assertThat(PackagePatterns.matches("org.app", "org.app")).isTrue() + assertThat(PackagePatterns.matches("org.app", "org.app2")).isFalse() + } + + @Test + fun matchAllCollectsOnlyMatchingPackages() + { + val installed = setOf("ru.a", "ru.b", "com.c") + assertThat(PackagePatterns.matchAll(setOf("ru.*"), installed)).containsExactlyInAnyOrder("ru.a", "ru.b") + assertThat(PackagePatterns.matchAll(emptySet(), installed)).isEmpty() + } +} diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/RoutingManagerTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/RoutingManagerTest.kt index ebcc7f214..a5a6a6cab 100644 --- a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/RoutingManagerTest.kt +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/RoutingManagerTest.kt @@ -16,7 +16,6 @@ package org.strongswan.android.logic.routing import org.assertj.core.api.Assertions.assertThat import org.junit.Test -import org.strongswan.android.data.VpnProfile.SelectedAppsHandling class RoutingManagerTest { @@ -27,143 +26,107 @@ class RoutingManagerTest "com.sberbank.mobile", "ru.ozon.app", "org.mozilla.firefox", + "org.telegram.messenger", ) - /* ---------------- matchPrefixes ---------------- */ - - @Test - fun prefixMatchesInstalledPackages() - { - val matched = RoutingManager.matchPrefixes(setOf("ru.yandex."), installed) - assertThat(matched).containsExactlyInAnyOrder("ru.yandex.taxi", "ru.yandex.mail") - } - - @Test - fun prefixDoesNotMatchDifferentReverseDomain() - { - val matched = RoutingManager.matchPrefixes(setOf("ru.yandex."), installed) - assertThat(matched).doesNotContain("com.yandex.browser") - } - - @Test - fun emptyPrefixesMatchNothing() - { - assertThat(RoutingManager.matchPrefixes(emptySet(), installed)).isEmpty() - } - - /* ---------------- matchKeywords ---------------- */ - - @Test - fun keywordMatchesAnywhereInPackageName() - { - val matched = RoutingManager.matchKeywords(setOf("yandex"), installed) - assertThat(matched).contains("com.yandex.browser") - assertThat(matched).contains("ru.yandex.taxi") - } - - @Test - fun keywordMatchingIsCaseInsensitive() - { - assertThat(RoutingManager.matchKeywords(setOf("YANDEX"), installed)) - .contains("com.yandex.browser") - } - - @Test - fun emptyKeywordsMatchNothing() - { - assertThat(RoutingManager.matchKeywords(emptySet(), installed)).isEmpty() - } - /* ---------------- effectiveExcludes ---------------- */ @Test - fun disabledRuleSetsAreIgnored() + fun emptyConfigExcludesNothing() { - val ruleSets = listOf( - RuleSet(id = "1", name = "Yandex", enabled = false, prefixes = setOf("ru.yandex.")), - RuleSet(id = "2", name = "Ozon", enabled = true, prefixes = setOf("ru.ozon")), - ) - assertThat(RoutingManager.effectiveExcludes(ruleSets, installed)).containsExactly("ru.ozon.app") + assertThat(RoutingManager.effectiveExcludes(RoutingConfig(), installed)).isEmpty() } @Test - fun explicitPackagesAndPrefixesCombine() + fun explicitDirectAppsAreIncluded() { - val ruleSets = listOf( - RuleSet(id = "1", name = "Mix", enabled = true, packages = setOf("org.mozilla.firefox"), prefixes = setOf("com.sberbank")), + val config = RoutingConfig(directPackages = setOf("org.mozilla.firefox")) + assertThat(RoutingManager.effectiveExcludes(config, installed)).containsExactly("org.mozilla.firefox") + } + + @Test + fun uninstalledDirectAppsAreDropped() + { + val config = RoutingConfig(directPackages = setOf("org.mozilla.firefox", "gone.app")) + assertThat(RoutingManager.effectiveExcludes(config, installed)).containsExactly("org.mozilla.firefox") + } + + @Test + fun directAppsSurviveWhenInstalledListIsUnavailable() + { + val config = RoutingConfig(directPackages = setOf("org.mozilla.firefox")) + assertThat(RoutingManager.effectiveExcludes(config, emptySet())).containsExactly("org.mozilla.firefox") + } + + @Test + fun enabledCategoryMatchesInstalledApps() + { + val config = RoutingConfig(enabledCategories = setOf(RoutingPresets.BANKS, RoutingPresets.SHOP)) + assertThat(RoutingManager.effectiveExcludes(config, installed)) + .containsExactlyInAnyOrder("com.sberbank.mobile", "ru.ozon.app") + } + + @Test + fun namespaceCategoryMatchesWholeRuNamespace() + { + val config = RoutingConfig(enabledCategories = setOf(RoutingPresets.NAMESPACE)) + assertThat(RoutingManager.effectiveExcludes(config, installed)) + .containsExactlyInAnyOrder("ru.yandex.taxi", "ru.yandex.mail", "ru.ozon.app") + } + + @Test + fun patternsMatchPrefixAndKeyword() + { + val config = RoutingConfig(packagePatterns = setOf("ru.yandex.*", "*sber*")) + assertThat(RoutingManager.effectiveExcludes(config, installed)) + .containsExactlyInAnyOrder("ru.yandex.taxi", "ru.yandex.mail", "com.sberbank.mobile") + } + + @Test + fun tunnelOverrideWinsOverCategoriesPatternsAndExplicitList() + { + val config = RoutingConfig( + directPackages = setOf("org.mozilla.firefox"), + enabledCategories = setOf(RoutingPresets.NAMESPACE), + packagePatterns = setOf("*yandex*"), + tunnelOverrides = setOf("ru.yandex.taxi", "com.yandex.browser", "org.mozilla.firefox"), ) - val excludes = RoutingManager.effectiveExcludes(ruleSets, installed) - assertThat(excludes).containsExactlyInAnyOrder("org.mozilla.firefox", "com.sberbank.mobile") + val excludes = RoutingManager.effectiveExcludes(config, installed) + assertThat(excludes).containsExactlyInAnyOrder("ru.yandex.mail", "ru.ozon.app") } @Test fun duplicateMatchesCollapse() { - val ruleSets = listOf( - RuleSet(id = "1", name = "A", enabled = true, packages = setOf("ru.ozon.app")), - RuleSet(id = "2", name = "B", enabled = true, prefixes = setOf("ru.ozon")), + val config = RoutingConfig( + directPackages = setOf("ru.ozon.app"), + enabledCategories = setOf(RoutingPresets.SHOP), + packagePatterns = setOf("ru.ozon*"), ) - assertThat(RoutingManager.effectiveExcludes(ruleSets, installed)).containsExactly("ru.ozon.app") + assertThat(RoutingManager.effectiveExcludes(config, installed)).containsExactly("ru.ozon.app") + } + + /* ---------------- templateMatch ---------------- */ + + @Test + fun templateMatchReportsCategoryBeforeNamespace() + { + val config = RoutingConfig(enabledCategories = setOf(RoutingPresets.SHOP, RoutingPresets.NAMESPACE)) + val match = RoutingManager.templateMatch("ru.ozon.app", config) + assertThat(match?.category).isEqualTo(RoutingPresets.SHOP) } @Test - fun keywordsAndPrefixesCombine() + fun templateMatchIsNullWhenNothingCoversThePackage() { - val ruleSets = listOf( - RuleSet(id = "1", name = "Ru", enabled = true, prefixes = setOf("ru.")), - RuleSet(id = "2", name = "Yandex", enabled = true, keywords = setOf("yandex")), - ) - val excludes = RoutingManager.effectiveExcludes(ruleSets, installed) - /* prefix ru.* plus keyword matching com.yandex.browser */ - assertThat(excludes).containsExactlyInAnyOrder( - "ru.yandex.taxi", "ru.yandex.mail", "ru.ozon.app", "com.yandex.browser", - ) - assertThat(excludes).doesNotContain("org.mozilla.firefox") - } - - /* ---------------- applyGlobalExcludes ---------------- */ - - @Test - fun disableProfileBecomesExcludeWithGlobalApps() - { - val selected = mutableSetOf() - val handling = RoutingManager.applyGlobalExcludes( - SelectedAppsHandling.SELECTED_APPS_DISABLE, selected, setOf("ru.yandex.taxi"), - ) - assertThat(handling).isEqualTo(SelectedAppsHandling.SELECTED_APPS_EXCLUDE) - assertThat(selected).containsExactly("ru.yandex.taxi") + val config = RoutingConfig(enabledCategories = setOf(RoutingPresets.BANKS), packagePatterns = setOf("ru.yandex.*")) + assertThat(RoutingManager.templateMatch("org.mozilla.firefox", config)).isNull() + assertThat(RoutingManager.templateMatch("ru.yandex.taxi", config)?.pattern).isEqualTo("ru.yandex.*") } @Test - fun excludeProfileGainsGlobalApps() + fun disabledCategoryDoesNotCover() { - val selected = mutableSetOf("com.example.existing") - val handling = RoutingManager.applyGlobalExcludes( - SelectedAppsHandling.SELECTED_APPS_EXCLUDE, selected, setOf("ru.yandex.taxi"), - ) - assertThat(handling).isEqualTo(SelectedAppsHandling.SELECTED_APPS_EXCLUDE) - assertThat(selected).containsExactlyInAnyOrder("com.example.existing", "ru.yandex.taxi") - } - - @Test - fun onlyProfileDropsGlobalAppsFromAllowedSet() - { - val selected = mutableSetOf("ru.yandex.taxi", "org.mozilla.firefox") - val handling = RoutingManager.applyGlobalExcludes( - SelectedAppsHandling.SELECTED_APPS_ONLY, selected, setOf("ru.yandex.taxi"), - ) - assertThat(handling).isEqualTo(SelectedAppsHandling.SELECTED_APPS_ONLY) - assertThat(selected).containsExactly("org.mozilla.firefox") - } - - @Test - fun emptyGlobalSetKeepsEverything() - { - val selected = mutableSetOf("com.example.app") - val handling = RoutingManager.applyGlobalExcludes( - SelectedAppsHandling.SELECTED_APPS_EXCLUDE, selected, emptySet(), - ) - assertThat(handling).isEqualTo(SelectedAppsHandling.SELECTED_APPS_EXCLUDE) - assertThat(selected).containsExactly("com.example.app") + assertThat(RoutingManager.templateMatch("com.sberbank.mobile", RoutingConfig())).isNull() } } diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/RoutingMigrationTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/RoutingMigrationTest.kt new file mode 100644 index 000000000..9f549033f --- /dev/null +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/RoutingMigrationTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic.routing + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.strongswan.android.data.VpnProfile.SelectedAppsHandling + +class RoutingMigrationTest +{ + private val own = "one.shx.strongswan.ext" + private val installed = setOf("a.one", "b.two", "c.three", own) + + /* ---------------- rule sets -> direct apps + patterns ---------------- */ + + @Test + fun ruleSetPackagesBecomeDirectApps() + { + val result = RoutingMigration.migrateRuleSets(listOf( + RuleSet("1", "Mine", packages = setOf("a.one", "b.two")), + )) + assertThat(result.directPackages).containsExactlyInAnyOrder("a.one", "b.two") + assertThat(result.patterns).isEmpty() + assertThat(result.categories).isEmpty() + } + + @Test + fun prefixesAndKeywordsBecomePatterns() + { + val result = RoutingMigration.migrateRuleSets(listOf( + RuleSet("1", "Custom", prefixes = setOf("ru.custom."), keywords = setOf("MyBank")), + )) + assertThat(result.patterns).containsExactlyInAnyOrder("ru.custom.*", "*mybank*") + } + + @Test + fun disabledRuleSetsAreDropped() + { + val result = RoutingMigration.migrateRuleSets(listOf( + RuleSet("1", "Off", enabled = false, packages = setOf("a.one"), prefixes = setOf("ru.")), + )) + assertThat(result.directPackages).isEmpty() + assertThat(result.patterns).isEmpty() + } + + @Test + fun fullyCoveredCategoryBecomesCategoryAndLosesItsPatterns() + { + val ruleSets = RoutingPresets.presetsOf(RoutingPresets.GOV).map { + RuleSet(it.name, it.name, prefixes = it.prefixes.toSet(), keywords = it.keywords.toSet()) + } + RuleSet("x", "Custom", prefixes = setOf("org.custom.")) + val result = RoutingMigration.migrateRuleSets(ruleSets) + assertThat(result.categories).containsExactly(RoutingPresets.GOV) + assertThat(result.patterns).containsExactly("org.custom.*") + } + + @Test + fun partiallyCoveredCategoryStaysAsPatterns() + { + val sber = RoutingPresets.presetsOf(RoutingPresets.BANKS).first() + val result = RoutingMigration.migrateRuleSets(listOf( + RuleSet("1", sber.name, keywords = sber.keywords.toSet()), + )) + assertThat(result.categories).isEmpty() + assertThat(result.patterns).isNotEmpty() + } + + @Test + fun migratedRuleSetsKeepTheirEffectiveCoverage() + { + val ruleSets = listOf( + RuleSet("1", "Yandex", prefixes = setOf("ru.yandex."), keywords = setOf("yandex")), + RuleSet("2", "Mine", packages = setOf("org.mozilla.firefox")), + ) + val pkgs = setOf("ru.yandex.taxi", "com.yandex.browser", "org.mozilla.firefox", "org.other") + val migrated = RoutingMigration.migrateRuleSets(ruleSets) + val config = RoutingConfig( + directPackages = migrated.directPackages, + enabledCategories = migrated.categories, + packagePatterns = migrated.patterns, + ) + assertThat(RoutingManager.effectiveExcludes(config, pkgs)) + .containsExactlyInAnyOrder("ru.yandex.taxi", "com.yandex.browser", "org.mozilla.firefox") + } + + /* ---------------- profile app lists -> direct apps ---------------- */ + + @Test + fun excludeProfileListBecomesDirectApps() + { + val direct = RoutingMigration.profileAppsToDirect( + SelectedAppsHandling.SELECTED_APPS_EXCLUDE, setOf("a.one", own), emptySet(), own) + assertThat(direct).containsExactly("a.one") + } + + @Test + fun onlyProfileBecomesEveryOtherInstalledApp() + { + val direct = RoutingMigration.profileAppsToDirect( + SelectedAppsHandling.SELECTED_APPS_ONLY, setOf("a.one"), installed, own) + assertThat(direct).containsExactlyInAnyOrder("b.two", "c.three") + } + + @Test + fun emptyOnlyProfileYieldsNothingBecauseTheOldCodeIgnoredIt() + { + val direct = RoutingMigration.profileAppsToDirect( + SelectedAppsHandling.SELECTED_APPS_ONLY, emptySet(), installed, own) + assertThat(direct).isEmpty() + } + + @Test + fun disabledProfileYieldsNothing() + { + val direct = RoutingMigration.profileAppsToDirect( + SelectedAppsHandling.SELECTED_APPS_DISABLE, setOf("a.one"), installed, own) + assertThat(direct).isEmpty() + } + + @Test + fun mergingTheSameMigrationTwiceChangesNothing() + { + val first = RoutingMigration.profileAppsToDirect( + SelectedAppsHandling.SELECTED_APPS_ONLY, setOf("a.one"), installed, own) + val config = RoutingConfig(directPackages = first) + val again = config.copy(directPackages = config.directPackages + first) + assertThat(again).isEqualTo(config) + } +}