android: global direct-apps routing model, migration, single-source CharonVpnService excludes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
584004a25c
commit
389712bba3
9 files changed
+982
-497
No files matched your search
+15
-53
@@ -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<String> mSelectedApps;
|
||||
private final List<InetAddress> 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<String> 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.<IPRange>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)
|
||||
|
||||
+79
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* 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<String>, installed: Set<String>): Set<String> =
|
||||
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()}*"
|
||||
}
|
||||
+402
-266
@@ -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<RuleSet> = emptyList(),
|
||||
/** Apps the user switched to "direct" one by one. */
|
||||
val directPackages: Set<String> = emptySet(),
|
||||
/** Apps switched back to "via VPN" although a category or pattern covers them. */
|
||||
val tunnelOverrides: Set<String> = emptySet(),
|
||||
/** Enabled built-in categories ([RoutingPresets] ids). */
|
||||
val enabledCategories: Set<String> = emptySet(),
|
||||
/** User package patterns, see [PackagePatterns]. */
|
||||
val packagePatterns: Set<String> = emptySet(),
|
||||
val forceTunnelPrefixes: List<ForceTunnelPrefix> = 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<String>)
|
||||
{
|
||||
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<String>)
|
||||
{
|
||||
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<String>
|
||||
{
|
||||
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<String>
|
||||
private fun loadInstalledPackages(): Set<String> = 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<ForceTunnelPrefix> =
|
||||
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<String>()
|
||||
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<String> =
|
||||
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<String> = 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<String> =
|
||||
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<RuleSet>, installed: Set<String>): Set<String>
|
||||
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<String>): Set<String>
|
||||
{
|
||||
val result = HashSet<String>()
|
||||
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<String>, installed: Set<String>): Set<String> =
|
||||
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<String>, installed: Set<String>): Set<String> =
|
||||
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<String>, global: Set<String>): 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
|
||||
|
||||
|
||||
+99
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* 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<String>,
|
||||
val patterns: Set<String>,
|
||||
val categories: Set<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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<RuleSet>): RuleSetResult
|
||||
{
|
||||
val active = ruleSets.filter { it.enabled }
|
||||
val direct = HashSet<String>()
|
||||
val patterns = LinkedHashSet<String>()
|
||||
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<String>()
|
||||
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<String>,
|
||||
installed: Set<String>,
|
||||
ownPackage: String,
|
||||
): Set<String> = when (handling)
|
||||
{
|
||||
SelectedAppsHandling.SELECTED_APPS_EXCLUDE -> selected - ownPackage
|
||||
SelectedAppsHandling.SELECTED_APPS_ONLY ->
|
||||
if (selected.isEmpty()) emptySet() else installed - selected - ownPackage
|
||||
else -> emptySet()
|
||||
}
|
||||
}
|
||||
+100
-64
@@ -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<String> = emptyList(), val keywords: List<String> = 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<String> = listOf(BANKS, GOV, SHOP, SOCIAL, TRAVEL, SERVICES)
|
||||
|
||||
/** Everything "all RF apps" switches on. */
|
||||
val ALL_CATEGORIES: Set<String> = (CATEGORIES + NAMESPACE).toSet()
|
||||
|
||||
data class Preset(
|
||||
val name: String,
|
||||
val category: String,
|
||||
val prefixes: List<String> = emptyList(),
|
||||
val keywords: List<String> = 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<Preset> = 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<Preset> = 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(),
|
||||
)
|
||||
+6
@@ -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());
|
||||
|
||||
+63
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
+77
-114
@@ -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<String>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
+141
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user