android: global direct-apps routing model, migration, single-source CharonVpnService excludes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.R;
import org.strongswan.android.data.VpnProfile; import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
import org.strongswan.android.data.VpnProfileDataSource; import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.data.VpnProfileSource; import org.strongswan.android.data.VpnProfileSource;
import org.strongswan.android.data.VpnType.VpnTypeFeature; import org.strongswan.android.data.VpnType.VpnTypeFeature;
@@ -92,6 +91,7 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
import java.util.SortedSet; import java.util.SortedSet;
import java.util.TreeSet;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@@ -2098,7 +2098,6 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
* the OS routes here */ * the OS routes here */
private final boolean mUserspaceRouting; private final boolean mUserspaceRouting;
private final int mSplitTunneling; private final int mSplitTunneling;
private final SelectedAppsHandling mAppHandling;
private final SortedSet<String> mSelectedApps; private final SortedSet<String> mSelectedApps;
private final List<InetAddress> mDnsServers = new ArrayList<>(); private final List<InetAddress> mDnsServers = new ArrayList<>();
/* addresses of the system's private DNS server (empty if none is /* 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()); mExcludedSubnets = IPRangeSet.fromString(profile.getExcludedSubnets());
Integer splitTunneling = profile.getSplitTunneling(); Integer splitTunneling = profile.getSplitTunneling();
mSplitTunneling = splitTunneling != null ? splitTunneling : 0; mSplitTunneling = splitTunneling != null ? splitTunneling : 0;
SelectedAppsHandling appHandling = profile.getSelectedAppsHandling(); /* the only per-app routing is the global one (Routing tab): its
mSelectedApps = profile.getSelectedAppsSet(); * packages bypass the tunnel. The old per-profile handling is gone
/* exclude our own app, otherwise the fetcher is blocked */ * (migrated by RoutingManager); our own app is always excluded,
switch (appHandling) * otherwise the fetchers are blocked */
{ mSelectedApps = new TreeSet<>(RoutingManager.getInstance(CharonVpnService.this).computeExcludedPackages());
case SELECTED_APPS_DISABLE: mSelectedApps.add(getPackageName());
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;
/* RU geoip exclusions: a large presorted list. It is never passed /* RU geoip exclusions: a large presorted list. It is never passed
* to VpnService's excludeRoute() - on some devices (One UI was * 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); applyRoutesWithGeo(builder, rangesV6, mUserspaceRouting ? Collections.<IPRange>emptyList() : geoV6);
} }
/* apply selected applications */ /* apply the directly routed applications (never empty: our own package) */
if (mSelectedApps.size() > 0) for (String app : mSelectedApps)
{ {
switch (mAppHandling) try
{ {
case SELECTED_APPS_EXCLUDE: builder.addDisallowedApplication(app);
for (String app : mSelectedApps) }
{ catch (PackageManager.NameNotFoundException e)
try {
{ // app was uninstalled since the list was built
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;
} }
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && mProxyServer != null) 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 package org.strongswan.android.logic.routing
import android.app.Service import android.app.Service
import android.content.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Handler import android.os.Handler
@@ -34,10 +36,15 @@ import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
import org.strongswan.android.data.VpnProfileSqlDataSource
import org.strongswan.android.logic.VpnStateService import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.routing.RoutingPresets.matches
import java.util.UUID 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( data class RuleSet(
val id: String, val id: String,
val name: String, val name: String,
@@ -58,25 +65,51 @@ data class ForceTunnelPrefix(
val enabled: Boolean = true, 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( data class RoutingConfig(
val enabled: Boolean = true, 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 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( data class RoutingState(
val config: RoutingConfig = RoutingConfig(), 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 * Global per-app routing: one list of applications that must NOT use the VPN
* the VPN tunnel (they go direct), independent of the per-profile split * tunnel (they go direct). [CharonVpnService.BuilderCache] merges the
* tunneling settings. [CharonVpnService.BuilderCache] merges the effective * effective package list into the TUN builder on every (re)connect.
* package list into the TUN builder on every (re)connect.
* *
* When rules change while the VPN is connected the connection is restarted * When the list changes while the VPN is connected the connection is
* automatically (debounced), because per-app routing only applies when the * restarted automatically (debounced), because per-app routing only applies
* TUN interface is created. * 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) 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 val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var config = RoutingConfig() private var config = RoutingConfig()
private var service: VpnStateService? = null 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 @Volatile
private var configVersion = 0L 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 init
{ {
config = loadConfig() config = loadConfig()
migrateProfiles()
migratedOnlyProfiles = prefs.getInt(KEY_NOTICE_ONLY, 0)
publish() publish()
} }
fun start() fun start()
{ {
context.bindService(Intent(context, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE) 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) fun updateConfig(transform: (RoutingConfig) -> RoutingConfig)
@@ -149,90 +201,104 @@ class RoutingManager private constructor(private val context: Context)
updateConfig { it.copy(enabled = enabled) } 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 * The per-app switch: "direct" adds the app (or drops its tunnel
{ * override when a category/pattern already covers it); "via VPN" removes
val id = UUID.randomUUID().toString() * it, keeping a personal override when a category/pattern covers it.
updateConfig { config -> */
config.copy(ruleSets = config.ruleSets + RuleSet(id = id, name = "")) fun setAppDirect(pkg: String, direct: Boolean)
}
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)
{ {
updateConfig { config -> updateConfig { config ->
config.copy(ruleSets = config.ruleSets.map { ruleSet -> val covered = templateMatch(pkg, config) != null
if (ruleSet.id != id) if (direct)
{
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 index = sets.indexOfFirst { it.name == preset.name } config.copy(
if (index < 0) enabled = true,
{ tunnelOverrides = config.tunnelOverrides - pkg,
sets.add(preset.asRuleSet()) directPackages = if (covered) config.directPackages else config.directPackages + pkg,
} )
else if (!sets[index].enabled)
{
sets[index] = sets[index].copy(enabled = true)
}
} }
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> fun computeExcludedPackages(): Set<String>
{ {
if (!config.enabled || config.ruleSets.none { it.enabled }) val current = config
if (!current.enabled)
{ {
return emptySet() return emptySet()
} }
@@ -337,7 +404,7 @@ class RoutingManager private constructor(private val context: Context)
return packages return packages
} }
} }
val packages = effectiveExcludes(config.ruleSets, loadInstalledPackages()) val packages = effectiveExcludes(current, loadInstalledPackages())
packageCache = version to packages packageCache = version to packages
return packages return packages
} }
@@ -349,10 +416,213 @@ class RoutingManager private constructor(private val context: Context)
packageCache = null 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 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) pm.getInstalledApplications(PackageManager.GET_META_DATA)
.asSequence() .asSequence()
@@ -365,193 +635,59 @@ class RoutingManager private constructor(private val context: Context)
Log.w(TAG, "Failed to list installed packages", e) Log.w(TAG, "Failed to list installed packages", e)
emptySet() emptySet()
} }
}
private fun publish() /** Built-in categories that are enabled in [config] and match [pkg], visible ones first. */
{ private fun matchingCategory(pkg: String, config: RoutingConfig): String?
_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
{ {
val obj = JSONObject(json) val visible = RoutingPresets.CATEGORIES.firstOrNull {
RoutingConfig( it in config.enabledCategories && RoutingPresets.matchesCategory(it, pkg)
enabled = obj.optBoolean("enabled", true), }
ruleSets = obj.optJSONArray("ruleSets")?.let { arr -> if (visible != null)
(0 until arr.length()).mapNotNull { i -> {
arr.optJSONObject(i)?.let { o -> return visible
RuleSet( }
id = o.optString("id"), return RoutingPresets.NAMESPACE.takeIf {
name = o.optString("name"), it in config.enabledCategories && RoutingPresets.matchesCategory(it, pkg)
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
} }
/** /**
* All packages matched by the enabled rule sets: explicit packages, * Why a category or pattern covers [pkg] (ignoring personal overrides
* prefixes and keywords matched against the installed packages. * 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>() 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 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 @Volatile
private var instance: RoutingManager? = null 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 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.* * - Prefixes match package names, so "ru." catches every package in the ru.*
* reverse-domain namespace. * reverse-domain namespace.
* - Keywords match anywhere in the package name (case-insensitive), so * - Keywords match anywhere in the package name (case-insensitive), so
@@ -28,75 +27,112 @@ import java.util.UUID
*/ */
object RoutingPresets 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( val RF_BUNDLE: List<Preset> = listOf(
/* namespace-wide */ /* namespace-wide */
Preset("Все RU.*", prefixes = listOf("ru.")), Preset("Все RU.*", NAMESPACE, prefixes = listOf("ru.")),
/* ecosystems */ /* ecosystems */
Preset("Яндекс", prefixes = listOf("ru.yandex."), keywords = listOf("yandex")), Preset("Яндекс", SOCIAL, prefixes = listOf("ru.yandex."), keywords = listOf("yandex")),
Preset("VK", prefixes = listOf("com.vkontakte", "ru.vk", "com.vk"), keywords = listOf("vkontakte")), Preset("VK", SOCIAL, prefixes = listOf("com.vkontakte", "ru.vk", "com.vk"), keywords = listOf("vkontakte")),
Preset("Одноклассники", prefixes = listOf("ru.ok.")), Preset("Одноклассники", SOCIAL, prefixes = listOf("ru.ok.")),
Preset("Дзен", keywords = listOf("dzen")), Preset("Дзен", SOCIAL, keywords = listOf("dzen")),
Preset("Rutube", keywords = listOf("rutube")), Preset("Rutube", SOCIAL, keywords = listOf("rutube")),
Preset("Звук", keywords = listOf("zvuk", "zvooq")), Preset("Звук", SOCIAL, keywords = listOf("zvuk", "zvooq")),
Preset("Иви", prefixes = listOf("ru.ivi")), Preset("Иви", SOCIAL, prefixes = listOf("ru.ivi")),
/* banks & finance */ /* banks & finance */
Preset("Сбер", keywords = listOf("sber")), Preset("Сбер", BANKS, keywords = listOf("sber")),
Preset("Т-Банк", keywords = listOf("tinkoff", "tbank")), Preset("Т-Банк", BANKS, keywords = listOf("tinkoff", "tbank")),
Preset("ВТБ", keywords = listOf("vtb")), Preset("ВТБ", BANKS, keywords = listOf("vtb")),
Preset("Альфа-Банк", keywords = listOf("alfabank", "alpha-bank")), Preset("Альфа-Банк", BANKS, keywords = listOf("alfabank", "alpha-bank")),
Preset("Газпромбанк", keywords = listOf("gazprombank")), Preset("Газпромбанк", BANKS, keywords = listOf("gazprombank")),
Preset("Совкомбанк", keywords = listOf("sovcombank")), Preset("Совкомбанк", BANKS, keywords = listOf("sovcombank")),
Preset("Почта Банк", keywords = listOf("pochtabank")), Preset("Почта Банк", BANKS, keywords = listOf("pochtabank")),
Preset("РСХБ", keywords = listOf("rshb", "rosselkhozbank")), Preset("РСХБ", BANKS, keywords = listOf("rshb", "rosselkhozbank")),
Preset("ОТП-Банк", prefixes = listOf("ru.otpbank"), keywords = listOf("otpbank")), Preset("ОТП-Банк", BANKS, prefixes = listOf("ru.otpbank"), keywords = listOf("otpbank")),
Preset("МКБ", prefixes = listOf("ru.mkb")), Preset("МКБ", BANKS, prefixes = listOf("ru.mkb")),
Preset("Уралсиб", keywords = listOf("uralsib")), Preset("Уралсиб", BANKS, keywords = listOf("uralsib")),
Preset("ЮMoney", keywords = listOf("yoomoney")), Preset("ЮMoney", BANKS, keywords = listOf("yoomoney")),
Preset("Золотая Корона", keywords = listOf("koronapay")), Preset("Золотая Корона", BANKS, keywords = listOf("koronapay")),
Preset("Мой налог", keywords = listOf("nalog")), /* government */
Preset("Госуслуги", GOV, prefixes = listOf("ru.rostel"), keywords = listOf("gosuslugi")),
Preset("Мой налог", GOV, keywords = listOf("nalog")),
/* retail & marketplaces */ /* retail & marketplaces */
Preset("Ozon", keywords = listOf("ozon")), Preset("Ozon", SHOP, keywords = listOf("ozon")),
Preset("Wildberries", keywords = listOf("wildberries")), Preset("Wildberries", SHOP, keywords = listOf("wildberries")),
Preset("Мегамаркет", keywords = listOf("megamarket")), Preset("Мегамаркет", SHOP, keywords = listOf("megamarket")),
Preset("Пятёрочка", keywords = listOf("pyaterochka")), Preset("Пятёрочка", SHOP, keywords = listOf("pyaterochka")),
Preset("Перекрёсток", keywords = listOf("perekrestok")), Preset("Перекрёсток", SHOP, keywords = listOf("perekrestok")),
Preset("Магнит", keywords = listOf("magnit")), Preset("Магнит", SHOP, keywords = listOf("magnit")),
Preset("Лента", keywords = listOf("lenta")), Preset("Лента", SHOP, keywords = listOf("lenta")),
Preset("М.Видео", keywords = listOf("mvideo")), Preset("М.Видео", SHOP, keywords = listOf("mvideo")),
Preset("Спортмастер", keywords = listOf("sportmaster")), Preset("Спортмастер", SHOP, keywords = listOf("sportmaster")),
Preset("Лемана ПРО", keywords = listOf("leroymerlin", "lemana")), Preset("Лемана ПРО", SHOP, keywords = listOf("leroymerlin", "lemana")),
Preset("Самокат", keywords = listOf("samokat")), Preset("Самокат", SHOP, keywords = listOf("samokat")),
Preset("ВкусВилл", keywords = listOf("vkusvill")), Preset("ВкусВилл", SHOP, keywords = listOf("vkusvill")),
/* telecom */ /* telecom */
Preset("МТС", prefixes = listOf("ru.mts", "com.mts")), Preset("МТС", SOCIAL, prefixes = listOf("ru.mts", "com.mts")),
Preset("Мегафон", keywords = listOf("megafon")), Preset("Мегафон", SOCIAL, keywords = listOf("megafon")),
Preset("Билайн", keywords = listOf("beeline")), Preset("Билайн", SOCIAL, keywords = listOf("beeline")),
Preset("T2/Tele2", prefixes = listOf("ru.t2"), keywords = listOf("tele2")), Preset("T2/Tele2", SOCIAL, prefixes = listOf("ru.t2"), keywords = listOf("tele2")),
Preset("Yota", prefixes = listOf("ru.yota")), Preset("Yota", SOCIAL, prefixes = listOf("ru.yota")),
/* transport & travel */ /* transport & travel */
Preset("РЖД", keywords = listOf("rzd")), Preset("РЖД", TRAVEL, keywords = listOf("rzd")),
Preset("Аэрофлот", keywords = listOf("aeroflot")), Preset("Аэрофлот", TRAVEL, keywords = listOf("aeroflot")),
Preset("Туту.ру", keywords = listOf("tutu.ru")), Preset("Туту.ру", TRAVEL, keywords = listOf("tutu.ru")),
Preset("Aviasales", keywords = listOf("aviasales")), Preset("Aviasales", TRAVEL, keywords = listOf("aviasales")),
Preset("Ситимобил", keywords = listOf("sitimobil")), Preset("Ситимобил", TRAVEL, keywords = listOf("sitimobil")),
Preset("Делимобиль", keywords = listOf("delimobil")), Preset("Делимобиль", TRAVEL, keywords = listOf("delimobil")),
/* services */ /* services */
Preset("2ГИС", keywords = listOf("2gis", "dublgis")), Preset("2ГИС", SERVICES, keywords = listOf("2gis", "dublgis")),
Preset("ЦИАН", prefixes = listOf("ru.cian")), Preset("ЦИАН", SERVICES, prefixes = listOf("ru.cian")),
Preset("Дром", keywords = listOf("drom")), Preset("Дром", SERVICES, keywords = listOf("drom")),
Preset("Юла", keywords = listOf("youla")), Preset("Юла", SERVICES, keywords = listOf("youla")),
Preset("HeadHunter", prefixes = listOf("ru.hh"), keywords = listOf("headhunter")), Preset("HeadHunter", SERVICES, prefixes = listOf("ru.hh"), keywords = listOf("headhunter")),
Preset("Почта России", keywords = listOf("pochta", "russianpost")), Preset("Почта России", SERVICES, keywords = listOf("pochta", "russianpost")),
Preset("СДЭК", keywords = listOf("cdek")), 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;
import org.strongswan.android.data.VpnType.VpnTypeFeature; import org.strongswan.android.data.VpnType.VpnTypeFeature;
import org.strongswan.android.logic.TrustedCertificateManager; import org.strongswan.android.logic.TrustedCertificateManager;
import org.strongswan.android.logic.routing.RoutingManager;
import org.strongswan.android.security.TrustedCertificateEntry; import org.strongswan.android.security.TrustedCertificateEntry;
import org.strongswan.android.ui.compose.ImportUiModel; import org.strongswan.android.ui.compose.ImportUiModel;
import org.strongswan.android.ui.compose.ImportScreenKt; import org.strongswan.android.ui.compose.ImportScreenKt;
@@ -629,6 +630,11 @@ public class VpnProfileImportActivity extends AppCompatActivity
if (verifyInput()) if (verifyInput())
{ {
updateProfileData(); 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) if (mExisting != null)
{ {
mProfile.setUUID(mExisting.getUUID()); 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.assertj.core.api.Assertions.assertThat
import org.junit.Test import org.junit.Test
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
class RoutingManagerTest class RoutingManagerTest
{ {
@@ -27,143 +26,107 @@ class RoutingManagerTest
"com.sberbank.mobile", "com.sberbank.mobile",
"ru.ozon.app", "ru.ozon.app",
"org.mozilla.firefox", "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 ---------------- */ /* ---------------- effectiveExcludes ---------------- */
@Test @Test
fun disabledRuleSetsAreIgnored() fun emptyConfigExcludesNothing()
{ {
val ruleSets = listOf( assertThat(RoutingManager.effectiveExcludes(RoutingConfig(), installed)).isEmpty()
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")
} }
@Test @Test
fun explicitPackagesAndPrefixesCombine() fun explicitDirectAppsAreIncluded()
{ {
val ruleSets = listOf( val config = RoutingConfig(directPackages = setOf("org.mozilla.firefox"))
RuleSet(id = "1", name = "Mix", enabled = true, packages = setOf("org.mozilla.firefox"), prefixes = setOf("com.sberbank")), 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) val excludes = RoutingManager.effectiveExcludes(config, installed)
assertThat(excludes).containsExactlyInAnyOrder("org.mozilla.firefox", "com.sberbank.mobile") assertThat(excludes).containsExactlyInAnyOrder("ru.yandex.mail", "ru.ozon.app")
} }
@Test @Test
fun duplicateMatchesCollapse() fun duplicateMatchesCollapse()
{ {
val ruleSets = listOf( val config = RoutingConfig(
RuleSet(id = "1", name = "A", enabled = true, packages = setOf("ru.ozon.app")), directPackages = setOf("ru.ozon.app"),
RuleSet(id = "2", name = "B", enabled = true, prefixes = setOf("ru.ozon")), 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 @Test
fun keywordsAndPrefixesCombine() fun templateMatchIsNullWhenNothingCoversThePackage()
{ {
val ruleSets = listOf( val config = RoutingConfig(enabledCategories = setOf(RoutingPresets.BANKS), packagePatterns = setOf("ru.yandex.*"))
RuleSet(id = "1", name = "Ru", enabled = true, prefixes = setOf("ru.")), assertThat(RoutingManager.templateMatch("org.mozilla.firefox", config)).isNull()
RuleSet(id = "2", name = "Yandex", enabled = true, keywords = setOf("yandex")), assertThat(RoutingManager.templateMatch("ru.yandex.taxi", config)?.pattern).isEqualTo("ru.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")
} }
@Test @Test
fun excludeProfileGainsGlobalApps() fun disabledCategoryDoesNotCover()
{ {
val selected = mutableSetOf("com.example.existing") assertThat(RoutingManager.templateMatch("com.sberbank.mobile", RoutingConfig())).isNull()
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")
} }
} }
@@ -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)
}
}