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:
DenozordecandClaude Opus 5.5 committed 2026-10-10 01:47:58 +07:00
1 parent 584004a25c
commit 389712bba3
9 files changed
+982 -497

No files matched your search

@@ -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)
@@ -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()}*"
}
@@ -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
@@ -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()
}
}
@@ -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(),
)
@@ -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());
@@ -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()
}
}
@@ -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()
}
}
@@ -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)
}
}