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:
1 parent
584004a25c
commit
389712bba3
9 files changed
+982
-497
No files matched your search
+15
-53
@@ -51,7 +51,6 @@ import android.util.Log;
|
|||||||
|
|
||||||
import org.strongswan.android.R;
|
import org.strongswan.android.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)
|
||||||
|
|||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.routing
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-defined package name patterns ("package templates" in the UI):
|
||||||
|
* - "ru.*" - package names starting with "ru."
|
||||||
|
* - "*yandex*" - package names containing "yandex" anywhere
|
||||||
|
* - "*.beta" - package names ending with ".beta"
|
||||||
|
* - "ru.ozon.app" - without a star: prefix match, same as "ru.ozon.app*"
|
||||||
|
* Matching is case-insensitive.
|
||||||
|
*/
|
||||||
|
object PackagePatterns
|
||||||
|
{
|
||||||
|
private val ALLOWED = Regex("[a-z0-9._*-]+")
|
||||||
|
private val STARS = Regex("\\*+")
|
||||||
|
|
||||||
|
/** Canonical form of user input, or null if it is empty, too broad or has invalid characters. */
|
||||||
|
fun normalize(input: String): String?
|
||||||
|
{
|
||||||
|
val text = input.trim().lowercase()
|
||||||
|
if (text.isEmpty() || !ALLOWED.matches(text))
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val collapsed = text.replace(STARS, "*")
|
||||||
|
if (collapsed.replace("*", "").isEmpty())
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return if (collapsed.contains('*')) collapsed else "$collapsed*"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun matches(pattern: String, pkg: String): Boolean
|
||||||
|
{
|
||||||
|
val core = pattern.trim('*').lowercase()
|
||||||
|
if (core.isEmpty())
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val name = pkg.lowercase()
|
||||||
|
val starts = pattern.startsWith("*")
|
||||||
|
val ends = pattern.endsWith("*")
|
||||||
|
return when
|
||||||
|
{
|
||||||
|
starts && ends -> name.contains(core)
|
||||||
|
ends -> name.startsWith(core)
|
||||||
|
starts -> name.endsWith(core)
|
||||||
|
else -> name == core
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Packages (of [installed]) matched by any of the patterns. */
|
||||||
|
fun matchAll(patterns: Set<String>, installed: Set<String>): Set<String> =
|
||||||
|
if (patterns.isEmpty())
|
||||||
|
{
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
installed.filterTo(HashSet()) { pkg -> patterns.any { matches(it, pkg) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromPrefix(prefix: String): String = "$prefix*"
|
||||||
|
|
||||||
|
fun fromKeyword(keyword: String): String = "*${keyword.lowercase()}*"
|
||||||
|
}
|
||||||
+402
-266
@@ -15,9 +15,11 @@
|
|||||||
package org.strongswan.android.logic.routing
|
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
|
||||||
|
|
||||||
|
|||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.routing
|
||||||
|
|
||||||
|
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure conversion rules from the pre-2.16 settings (named rule sets and
|
||||||
|
* per-profile split-tunneling apps) to the single global direct-apps list.
|
||||||
|
* No Android dependencies, so they are unit tested; [RoutingManager] applies
|
||||||
|
* them once at startup.
|
||||||
|
*/
|
||||||
|
object RoutingMigration
|
||||||
|
{
|
||||||
|
data class RuleSetResult(
|
||||||
|
val directPackages: Set<String>,
|
||||||
|
val patterns: Set<String>,
|
||||||
|
val categories: Set<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enabled rule sets become explicit packages plus package patterns. A
|
||||||
|
* built-in category whose every preset is fully present among the rule
|
||||||
|
* sets (what the old "all RF apps" action produced) turns into the
|
||||||
|
* category itself - same coverage, but shown as one switch. Disabled rule
|
||||||
|
* sets are dropped (the old JSON is kept in a backup key).
|
||||||
|
*/
|
||||||
|
fun migrateRuleSets(ruleSets: List<RuleSet>): RuleSetResult
|
||||||
|
{
|
||||||
|
val active = ruleSets.filter { it.enabled }
|
||||||
|
val direct = HashSet<String>()
|
||||||
|
val patterns = LinkedHashSet<String>()
|
||||||
|
for (ruleSet in active)
|
||||||
|
{
|
||||||
|
direct.addAll(ruleSet.packages)
|
||||||
|
ruleSet.prefixes.forEach { patterns.add(PackagePatterns.fromPrefix(it)) }
|
||||||
|
ruleSet.keywords.forEach { patterns.add(PackagePatterns.fromKeyword(it)) }
|
||||||
|
}
|
||||||
|
val categories = LinkedHashSet<String>()
|
||||||
|
for (category in RoutingPresets.ALL_CATEGORIES)
|
||||||
|
{
|
||||||
|
val presets = RoutingPresets.presetsOf(category)
|
||||||
|
if (presets.isNotEmpty() && presets.all { preset -> active.any { it.covers(preset) } })
|
||||||
|
{
|
||||||
|
categories.add(category)
|
||||||
|
for (preset in presets)
|
||||||
|
{
|
||||||
|
preset.prefixes.forEach { patterns.remove(PackagePatterns.fromPrefix(it)) }
|
||||||
|
preset.keywords.forEach { patterns.remove(PackagePatterns.fromKeyword(it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RuleSetResult(direct, patterns, categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RuleSet.covers(preset: RoutingPresets.Preset): Boolean
|
||||||
|
{
|
||||||
|
if (preset.prefixes.isEmpty() && preset.keywords.isEmpty())
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val lowerKeywords = keywords.map { it.lowercase() }.toSet()
|
||||||
|
return prefixes.containsAll(preset.prefixes) && lowerKeywords.containsAll(preset.keywords.map { it.lowercase() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Packages a profile's old per-app setting sends direct (outside the VPN):
|
||||||
|
* - EXCLUDE: the listed apps;
|
||||||
|
* - ONLY: "all installed internet apps except the listed ones" snapshot
|
||||||
|
* (an empty ONLY list never took effect in the old code, so it yields
|
||||||
|
* nothing);
|
||||||
|
* - DISABLE: nothing.
|
||||||
|
* The app's own package is never listed (it is always excluded anyway).
|
||||||
|
*/
|
||||||
|
fun profileAppsToDirect(
|
||||||
|
handling: SelectedAppsHandling,
|
||||||
|
selected: Set<String>,
|
||||||
|
installed: Set<String>,
|
||||||
|
ownPackage: String,
|
||||||
|
): Set<String> = when (handling)
|
||||||
|
{
|
||||||
|
SelectedAppsHandling.SELECTED_APPS_EXCLUDE -> selected - ownPackage
|
||||||
|
SelectedAppsHandling.SELECTED_APPS_ONLY ->
|
||||||
|
if (selected.isEmpty()) emptySet() else installed - selected - ownPackage
|
||||||
|
else -> emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
-64
@@ -14,10 +14,9 @@
|
|||||||
|
|
||||||
package org.strongswan.android.logic.routing
|
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(),
|
|
||||||
)
|
|
||||||
+6
@@ -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());
|
||||||
|
|||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.routing
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PackagePatternsTest
|
||||||
|
{
|
||||||
|
@Test
|
||||||
|
fun bareInputBecomesPrefixPattern()
|
||||||
|
{
|
||||||
|
assertThat(PackagePatterns.normalize(" RU.Ozon ")).isEqualTo("ru.ozon*")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun starsAreCollapsedAndKept()
|
||||||
|
{
|
||||||
|
assertThat(PackagePatterns.normalize("**yandex**")).isEqualTo("*yandex*")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tooBroadOrInvalidInputIsRejected()
|
||||||
|
{
|
||||||
|
assertThat(PackagePatterns.normalize("")).isNull()
|
||||||
|
assertThat(PackagePatterns.normalize("*")).isNull()
|
||||||
|
assertThat(PackagePatterns.normalize("**")).isNull()
|
||||||
|
assertThat(PackagePatterns.normalize("a b")).isNull()
|
||||||
|
assertThat(PackagePatterns.normalize("ru/x")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun prefixKeywordSuffixAndExactMatching()
|
||||||
|
{
|
||||||
|
assertThat(PackagePatterns.matches("ru.*", "ru.ozon.app")).isTrue()
|
||||||
|
assertThat(PackagePatterns.matches("ru.*", "com.ru.app")).isFalse()
|
||||||
|
assertThat(PackagePatterns.matches("*yandex*", "com.Yandex.browser")).isTrue()
|
||||||
|
assertThat(PackagePatterns.matches("*.beta", "org.app.beta")).isTrue()
|
||||||
|
assertThat(PackagePatterns.matches("*.beta", "org.app.beta2")).isFalse()
|
||||||
|
assertThat(PackagePatterns.matches("org.app", "org.app")).isTrue()
|
||||||
|
assertThat(PackagePatterns.matches("org.app", "org.app2")).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun matchAllCollectsOnlyMatchingPackages()
|
||||||
|
{
|
||||||
|
val installed = setOf("ru.a", "ru.b", "com.c")
|
||||||
|
assertThat(PackagePatterns.matchAll(setOf("ru.*"), installed)).containsExactlyInAnyOrder("ru.a", "ru.b")
|
||||||
|
assertThat(PackagePatterns.matchAll(emptySet(), installed)).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
-114
@@ -16,7 +16,6 @@ package org.strongswan.android.logic.routing
|
|||||||
|
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
import org.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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.routing
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
|
||||||
|
|
||||||
|
class RoutingMigrationTest
|
||||||
|
{
|
||||||
|
private val own = "one.shx.strongswan.ext"
|
||||||
|
private val installed = setOf("a.one", "b.two", "c.three", own)
|
||||||
|
|
||||||
|
/* ---------------- rule sets -> direct apps + patterns ---------------- */
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ruleSetPackagesBecomeDirectApps()
|
||||||
|
{
|
||||||
|
val result = RoutingMigration.migrateRuleSets(listOf(
|
||||||
|
RuleSet("1", "Mine", packages = setOf("a.one", "b.two")),
|
||||||
|
))
|
||||||
|
assertThat(result.directPackages).containsExactlyInAnyOrder("a.one", "b.two")
|
||||||
|
assertThat(result.patterns).isEmpty()
|
||||||
|
assertThat(result.categories).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun prefixesAndKeywordsBecomePatterns()
|
||||||
|
{
|
||||||
|
val result = RoutingMigration.migrateRuleSets(listOf(
|
||||||
|
RuleSet("1", "Custom", prefixes = setOf("ru.custom."), keywords = setOf("MyBank")),
|
||||||
|
))
|
||||||
|
assertThat(result.patterns).containsExactlyInAnyOrder("ru.custom.*", "*mybank*")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun disabledRuleSetsAreDropped()
|
||||||
|
{
|
||||||
|
val result = RoutingMigration.migrateRuleSets(listOf(
|
||||||
|
RuleSet("1", "Off", enabled = false, packages = setOf("a.one"), prefixes = setOf("ru.")),
|
||||||
|
))
|
||||||
|
assertThat(result.directPackages).isEmpty()
|
||||||
|
assertThat(result.patterns).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fullyCoveredCategoryBecomesCategoryAndLosesItsPatterns()
|
||||||
|
{
|
||||||
|
val ruleSets = RoutingPresets.presetsOf(RoutingPresets.GOV).map {
|
||||||
|
RuleSet(it.name, it.name, prefixes = it.prefixes.toSet(), keywords = it.keywords.toSet())
|
||||||
|
} + RuleSet("x", "Custom", prefixes = setOf("org.custom."))
|
||||||
|
val result = RoutingMigration.migrateRuleSets(ruleSets)
|
||||||
|
assertThat(result.categories).containsExactly(RoutingPresets.GOV)
|
||||||
|
assertThat(result.patterns).containsExactly("org.custom.*")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun partiallyCoveredCategoryStaysAsPatterns()
|
||||||
|
{
|
||||||
|
val sber = RoutingPresets.presetsOf(RoutingPresets.BANKS).first()
|
||||||
|
val result = RoutingMigration.migrateRuleSets(listOf(
|
||||||
|
RuleSet("1", sber.name, keywords = sber.keywords.toSet()),
|
||||||
|
))
|
||||||
|
assertThat(result.categories).isEmpty()
|
||||||
|
assertThat(result.patterns).isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun migratedRuleSetsKeepTheirEffectiveCoverage()
|
||||||
|
{
|
||||||
|
val ruleSets = listOf(
|
||||||
|
RuleSet("1", "Yandex", prefixes = setOf("ru.yandex."), keywords = setOf("yandex")),
|
||||||
|
RuleSet("2", "Mine", packages = setOf("org.mozilla.firefox")),
|
||||||
|
)
|
||||||
|
val pkgs = setOf("ru.yandex.taxi", "com.yandex.browser", "org.mozilla.firefox", "org.other")
|
||||||
|
val migrated = RoutingMigration.migrateRuleSets(ruleSets)
|
||||||
|
val config = RoutingConfig(
|
||||||
|
directPackages = migrated.directPackages,
|
||||||
|
enabledCategories = migrated.categories,
|
||||||
|
packagePatterns = migrated.patterns,
|
||||||
|
)
|
||||||
|
assertThat(RoutingManager.effectiveExcludes(config, pkgs))
|
||||||
|
.containsExactlyInAnyOrder("ru.yandex.taxi", "com.yandex.browser", "org.mozilla.firefox")
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- profile app lists -> direct apps ---------------- */
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun excludeProfileListBecomesDirectApps()
|
||||||
|
{
|
||||||
|
val direct = RoutingMigration.profileAppsToDirect(
|
||||||
|
SelectedAppsHandling.SELECTED_APPS_EXCLUDE, setOf("a.one", own), emptySet(), own)
|
||||||
|
assertThat(direct).containsExactly("a.one")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onlyProfileBecomesEveryOtherInstalledApp()
|
||||||
|
{
|
||||||
|
val direct = RoutingMigration.profileAppsToDirect(
|
||||||
|
SelectedAppsHandling.SELECTED_APPS_ONLY, setOf("a.one"), installed, own)
|
||||||
|
assertThat(direct).containsExactlyInAnyOrder("b.two", "c.three")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyOnlyProfileYieldsNothingBecauseTheOldCodeIgnoredIt()
|
||||||
|
{
|
||||||
|
val direct = RoutingMigration.profileAppsToDirect(
|
||||||
|
SelectedAppsHandling.SELECTED_APPS_ONLY, emptySet(), installed, own)
|
||||||
|
assertThat(direct).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun disabledProfileYieldsNothing()
|
||||||
|
{
|
||||||
|
val direct = RoutingMigration.profileAppsToDirect(
|
||||||
|
SelectedAppsHandling.SELECTED_APPS_DISABLE, setOf("a.one"), installed, own)
|
||||||
|
assertThat(direct).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergingTheSameMigrationTwiceChangesNothing()
|
||||||
|
{
|
||||||
|
val first = RoutingMigration.profileAppsToDirect(
|
||||||
|
SelectedAppsHandling.SELECTED_APPS_ONLY, setOf("a.one"), installed, own)
|
||||||
|
val config = RoutingConfig(directPackages = first)
|
||||||
|
val again = config.copy(directPackages = config.directPackages + first)
|
||||||
|
assertThat(again).isEqualTo(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user