Compare commits

6 Commits
Author SHA1 Message Date
DenozordecandClaude Opus 5.5 5e38fc2f76 android: keep the BETA badge on one line on narrow mode cards
Android / pre-check (push) Failing after 5s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 4s
CIFuzz / fuzzing (address) (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 4s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 3s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:55:19 +07:00
DenozordecandClaude Opus 5.5 c23d4dad8b android: 2.16.0 - Routing 2.0: one direct-apps list, inline RU mode, no per-profile app picker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:48:33 +07:00
DenozordecandClaude Opus 5.5 e2ce8420a7 android: remove per-profile app picker, rule-set UI and SelectedApplicationsActivity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:48:00 +07:00
DenozordecandClaude Opus 5.5 488c0aa210 android: Routing tab redesign - RU sites card, direct apps screen, advanced pages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:47:59 +07:00
DenozordecandClaude Opus 5.5 48f1ed7fb8 android: routing 2.0 strings (en/ru/de), drop obsolete rule-set strings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:47:59 +07:00
DenozordecandClaude Opus 5.5 389712bba3 android: global direct-apps routing model, migration, single-source CharonVpnService excludes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:47:58 +07:00
35 changed files with 3374 additions and 2539 deletions

No files matched your search

+2 -2
View File
@@ -45,8 +45,8 @@ android {
minSdk = 24
targetSdk = 36
versionCode 161
versionName "2.15.1"
versionCode 162
versionName "2.16.0"
/* Base URL of the update storage, trailing slash included */
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
@@ -0,0 +1,91 @@
/*
* 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.ui.compose
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.strongswan.android.R
import org.strongswan.android.logic.routing.TemplateMatch
import org.strongswan.android.ui.compose.routing.AppReason
import org.strongswan.android.ui.compose.routing.AppsFilter
import org.strongswan.android.ui.compose.routing.DirectAppRow
import org.strongswan.android.ui.compose.routing.DirectAppsContent
import org.strongswan.android.ui.compose.routing.DirectAppsGroup
import org.strongswan.android.ui.compose.routing.DirectAppsUiState
import org.strongswan.android.ui.compose.routing.QuickChipState
import org.strongswan.android.ui.compose.theme.ShxTheme
/** Smoke test of the stateless "Apps direct" screen: filter chips, quick-add chips and an app row. */
class DirectAppsScreenTest
{
@get:Rule
val composeRule = createComposeRule()
private fun string(id: Int, vararg args: Any): String =
InstrumentationRegistry.getInstrumentation().targetContext.getString(id, *args)
private val state = DirectAppsUiState(
loading = false,
groups = listOf(
DirectAppsGroup(
category = "banks",
rows = listOf(
DirectAppRow("com.example.bank", "Example Bank", true, AppReason.Template(TemplateMatch(category = "banks"))),
),
),
DirectAppsGroup(
category = null,
rows = listOf(DirectAppRow("com.example.chat", "Example Chat", false, AppReason.Tunnel)),
),
),
directCount = 1,
tunnelCount = 1,
filter = AppsFilter.ALL,
quickChips = listOf(QuickChipState("banks", true), QuickChipState("shop", false)),
)
@Test
fun showsFiltersQuickChipsAndRows()
{
var toggled: Pair<String, Boolean>? = null
composeRule.setContent {
ShxTheme {
DirectAppsContent(
state = state,
onBack = {},
onQuery = {},
onFilter = {},
onShowSystem = {},
onToggleApp = { pkg, direct -> toggled = pkg to direct },
onToggleCategory = { _, _ -> },
onAllRf = {},
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithText(string(R.string.apps_filter_direct, 1)).assertIsDisplayed()
composeRule.onNodeWithText(string(R.string.apps_filter_vpn, 1)).assertIsDisplayed()
composeRule.onNodeWithText(string(R.string.routing_apps_add_all_rf)).assertIsDisplayed()
composeRule.onNodeWithText("Example Bank").assertIsDisplayed()
composeRule.onNodeWithText("Example Chat").performClick()
assertEquals("com.example.chat" to true, toggled)
}
}
@@ -108,10 +108,6 @@
android:name=".ui.TrustedCertificatesActivity"
android:label="@string/trusted_certs_title" >
</activity>
<activity
android:name=".ui.SelectedApplicationsActivity"
android:label="@string/profile_select_apps" >
</activity>
<activity
android:name=".ui.LogActivity"
android:label="@string/log_title" >
@@ -51,7 +51,6 @@ import android.util.Log;
import org.strongswan.android.R;
import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.data.VpnProfileSource;
import org.strongswan.android.data.VpnType.VpnTypeFeature;
@@ -92,6 +91,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -2098,7 +2098,6 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
* the OS routes here */
private final boolean mUserspaceRouting;
private final int mSplitTunneling;
private final SelectedAppsHandling mAppHandling;
private final SortedSet<String> mSelectedApps;
private final List<InetAddress> mDnsServers = new ArrayList<>();
/* addresses of the system's private DNS server (empty if none is
@@ -2145,26 +2144,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
mExcludedSubnets = IPRangeSet.fromString(profile.getExcludedSubnets());
Integer splitTunneling = profile.getSplitTunneling();
mSplitTunneling = splitTunneling != null ? splitTunneling : 0;
SelectedAppsHandling appHandling = profile.getSelectedAppsHandling();
mSelectedApps = profile.getSelectedAppsSet();
/* exclude our own app, otherwise the fetcher is blocked */
switch (appHandling)
{
case SELECTED_APPS_DISABLE:
appHandling = SelectedAppsHandling.SELECTED_APPS_EXCLUDE;
mSelectedApps.clear();
/* fall-through */
case SELECTED_APPS_EXCLUDE:
mSelectedApps.add(getPackageName());
break;
case SELECTED_APPS_ONLY:
mSelectedApps.remove(getPackageName());
break;
}
/* global routing rules: packages that must bypass the tunnel */
Set<String> globalExcludes = RoutingManager.getInstance(CharonVpnService.this).computeExcludedPackages();
appHandling = RoutingManager.applyGlobalExcludes(appHandling, mSelectedApps, globalExcludes);
mAppHandling = appHandling;
/* the only per-app routing is the global one (Routing tab): its
* packages bypass the tunnel. The old per-profile handling is gone
* (migrated by RoutingManager); our own app is always excluded,
* otherwise the fetchers are blocked */
mSelectedApps = new TreeSet<>(RoutingManager.getInstance(CharonVpnService.this).computeExcludedPackages());
mSelectedApps.add(getPackageName());
/* RU geoip exclusions: a large presorted list. It is never passed
* to VpnService's excludeRoute() - on some devices (One UI was
@@ -2512,39 +2497,16 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
applyRoutesWithGeo(builder, rangesV6, mUserspaceRouting ? Collections.<IPRange>emptyList() : geoV6);
}
/* apply selected applications */
if (mSelectedApps.size() > 0)
/* apply the directly routed applications (never empty: our own package) */
for (String app : mSelectedApps)
{
switch (mAppHandling)
try
{
case SELECTED_APPS_EXCLUDE:
for (String app : mSelectedApps)
{
try
{
builder.addDisallowedApplication(app);
}
catch (PackageManager.NameNotFoundException e)
{
// possible if not configured via GUI or app was uninstalled
}
}
break;
case SELECTED_APPS_ONLY:
for (String app : mSelectedApps)
{
try
{
builder.addAllowedApplication(app);
}
catch (PackageManager.NameNotFoundException e)
{
// possible if not configured via GUI or app was uninstalled
}
}
break;
default:
break;
builder.addDisallowedApplication(app);
}
catch (PackageManager.NameNotFoundException e)
{
// app was uninstalled since the list was built
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && mProxyServer != null)
@@ -0,0 +1,79 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.routing
/**
* User-defined package name patterns ("package templates" in the UI):
* - "ru.*" - package names starting with "ru."
* - "*yandex*" - package names containing "yandex" anywhere
* - "*.beta" - package names ending with ".beta"
* - "ru.ozon.app" - without a star: prefix match, same as "ru.ozon.app*"
* Matching is case-insensitive.
*/
object PackagePatterns
{
private val ALLOWED = Regex("[a-z0-9._*-]+")
private val STARS = Regex("\\*+")
/** Canonical form of user input, or null if it is empty, too broad or has invalid characters. */
fun normalize(input: String): String?
{
val text = input.trim().lowercase()
if (text.isEmpty() || !ALLOWED.matches(text))
{
return null
}
val collapsed = text.replace(STARS, "*")
if (collapsed.replace("*", "").isEmpty())
{
return null
}
return if (collapsed.contains('*')) collapsed else "$collapsed*"
}
fun matches(pattern: String, pkg: String): Boolean
{
val core = pattern.trim('*').lowercase()
if (core.isEmpty())
{
return false
}
val name = pkg.lowercase()
val starts = pattern.startsWith("*")
val ends = pattern.endsWith("*")
return when
{
starts && ends -> name.contains(core)
ends -> name.startsWith(core)
starts -> name.endsWith(core)
else -> name == core
}
}
/** Packages (of [installed]) matched by any of the patterns. */
fun matchAll(patterns: Set<String>, installed: Set<String>): Set<String> =
if (patterns.isEmpty())
{
emptySet()
}
else
{
installed.filterTo(HashSet()) { pkg -> patterns.any { matches(it, pkg) } }
}
fun fromPrefix(prefix: String): String = "$prefix*"
fun fromKeyword(keyword: String): String = "*${keyword.lowercase()}*"
}
@@ -15,9 +15,11 @@
package org.strongswan.android.logic.routing
import android.app.Service
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.Handler
@@ -34,10 +36,15 @@ import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
import org.strongswan.android.data.VpnProfileSqlDataSource
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.routing.RoutingPresets.matches
import java.util.UUID
/** A named set of applications that bypass the VPN tunnel. */
/**
* A named set of applications that bypassed the VPN tunnel (pre-2.16 model).
* Only used to migrate old settings, see [RoutingMigration.migrateRuleSets].
*/
data class RuleSet(
val id: String,
val name: String,
@@ -58,25 +65,51 @@ data class ForceTunnelPrefix(
val enabled: Boolean = true,
)
/**
* The one global answer to "what goes around the VPN" for apps:
* [effectiveExcludes] = ([directPackages] + apps matched by [enabledCategories]
* and [packagePatterns]) - [tunnelOverrides].
*/
data class RoutingConfig(
val enabled: Boolean = true,
val ruleSets: List<RuleSet> = emptyList(),
/** Apps the user switched to "direct" one by one. */
val directPackages: Set<String> = emptySet(),
/** Apps switched back to "via VPN" although a category or pattern covers them. */
val tunnelOverrides: Set<String> = emptySet(),
/** Enabled built-in categories ([RoutingPresets] ids). */
val enabledCategories: Set<String> = emptySet(),
/** User package patterns, see [PackagePatterns]. */
val packagePatterns: Set<String> = emptySet(),
val forceTunnelPrefixes: List<ForceTunnelPrefix> = emptyList(),
val version: Int = RoutingManager.CONFIG_VERSION,
)
/** Why a package is (not) direct, for the per-app reason line. */
data class TemplateMatch(
/** Built-in category id (may be [RoutingPresets.NAMESPACE]) or null. */
val category: String? = null,
/** User pattern or null. */
val pattern: String? = null,
)
data class RoutingState(
val config: RoutingConfig = RoutingConfig(),
/** Number of profiles whose "only selected apps" list was converted at migration (one-time notice). */
val migratedOnlyProfiles: Int = 0,
)
/**
* Global per-app routing: named rule sets of applications that must NOT use
* the VPN tunnel (they go direct), independent of the per-profile split
* tunneling settings. [CharonVpnService.BuilderCache] merges the effective
* package list into the TUN builder on every (re)connect.
* Global per-app routing: one list of applications that must NOT use the VPN
* tunnel (they go direct). [CharonVpnService.BuilderCache] merges the
* effective package list into the TUN builder on every (re)connect.
*
* When rules change while the VPN is connected the connection is restarted
* automatically (debounced), because per-app routing only applies when the
* TUN interface is created.
* When the list changes while the VPN is connected the connection is
* restarted automatically (debounced), because per-app routing only applies
* when the TUN interface is created.
*
* The constructor migrates pre-2.16 settings (rule sets, per-profile app
* lists) once, so it has to run before the first VPN start - it does, from
* StrongSwanApplication.onCreate.
*/
class RoutingManager private constructor(private val context: Context)
{
@@ -85,8 +118,9 @@ class RoutingManager private constructor(private val context: Context)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var config = RoutingConfig()
private var service: VpnStateService? = null
private var migratedOnlyProfiles = 0
/* package cache: expanded prefixes against installed apps, keyed by config version */
/* package cache: expanded patterns against installed apps, keyed by config version */
@Volatile
private var configVersion = 0L
@@ -118,15 +152,33 @@ class RoutingManager private constructor(private val context: Context)
}
}
/* new/removed apps change what categories and patterns match */
private val packagesReceiver = object : BroadcastReceiver()
{
override fun onReceive(context: Context?, intent: Intent?)
{
invalidateCache()
scope.launch { computeExcludedPackages() }
}
}
init
{
config = loadConfig()
migrateProfiles()
migratedOnlyProfiles = prefs.getInt(KEY_NOTICE_ONLY, 0)
publish()
}
fun start()
{
context.bindService(Intent(context, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE)
val filter = IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
}
context.registerReceiver(packagesReceiver, filter)
}
fun updateConfig(transform: (RoutingConfig) -> RoutingConfig)
@@ -149,90 +201,104 @@ class RoutingManager private constructor(private val context: Context)
updateConfig { it.copy(enabled = enabled) }
}
/** Adds an empty rule set and returns its id so the UI can expand and focus it. */
fun addRuleSet(): String
{
val id = UUID.randomUUID().toString()
updateConfig { config ->
config.copy(ruleSets = config.ruleSets + RuleSet(id = id, name = ""))
}
return id
}
fun deleteRuleSet(id: String)
{
updateConfig { config -> config.copy(ruleSets = config.ruleSets.filter { it.id != id }) }
}
/** Re-inserts a just-deleted rule set (the snackbar "Undo" action). */
fun restoreRuleSet(ruleSet: RuleSet)
{
updateConfig { config -> config.copy(ruleSets = config.ruleSets + ruleSet) }
}
/** A rename never affects routing, so it must not schedule a reconnect. */
fun updateRuleSetName(id: String, name: String)
{
config = config.copy(ruleSets = config.ruleSets.map { if (it.id == id) it.copy(name = name) else it })
saveConfig(config)
publish()
}
fun setRuleSetEnabled(id: String, enabled: Boolean)
{
updateConfig { config -> config.copy(ruleSets = config.ruleSets.map { if (it.id == id) it.copy(enabled = enabled) else it }) }
}
fun updateRuleSetPackages(id: String, packages: Set<String>)
{
updateConfig { config -> config.copy(ruleSets = config.ruleSets.map { if (it.id == id) it.copy(packages = packages) else it }) }
}
/** Toggles a preset on a rule set: adds its prefixes/keywords, or removes them if all present. */
fun togglePreset(id: String, preset: RoutingPresets.Preset)
/**
* The per-app switch: "direct" adds the app (or drops its tunnel
* override when a category/pattern already covers it); "via VPN" removes
* it, keeping a personal override when a category/pattern covers it.
*/
fun setAppDirect(pkg: String, direct: Boolean)
{
updateConfig { config ->
config.copy(ruleSets = config.ruleSets.map { ruleSet ->
if (ruleSet.id != id)
{
ruleSet
}
else if (ruleSet.prefixes.containsAll(preset.prefixes) && ruleSet.keywords.containsAll(preset.keywords))
{
ruleSet.copy(
prefixes = ruleSet.prefixes - preset.prefixes.toSet(),
keywords = ruleSet.keywords - preset.keywords,
)
}
else
{
ruleSet.copy(
prefixes = ruleSet.prefixes + preset.prefixes,
keywords = ruleSet.keywords + preset.keywords.map { it.lowercase() },
)
}
})
}
}
/** Quick action: enable every preset of the RF bundle as its own rule set. */
fun applyRfBundle()
{
updateConfig { config ->
val sets = config.ruleSets.toMutableList()
for (preset in RoutingPresets.RF_BUNDLE)
val covered = templateMatch(pkg, config) != null
if (direct)
{
val index = sets.indexOfFirst { it.name == preset.name }
if (index < 0)
{
sets.add(preset.asRuleSet())
}
else if (!sets[index].enabled)
{
sets[index] = sets[index].copy(enabled = true)
}
config.copy(
enabled = true,
tunnelOverrides = config.tunnelOverrides - pkg,
directPackages = if (covered) config.directPackages else config.directPackages + pkg,
)
}
config.copy(enabled = true, ruleSets = sets)
else
{
config.copy(
directPackages = config.directPackages - pkg,
tunnelOverrides = if (covered) config.tunnelOverrides + pkg else config.tunnelOverrides,
)
}
}
}
/** Switches a built-in category on or off. Switching on also clears personal "via VPN" exceptions inside it. */
fun setCategoryEnabled(category: String, enabled: Boolean)
{
updateConfig { config ->
if (enabled)
{
config.copy(
enabled = true,
enabledCategories = config.enabledCategories + category,
tunnelOverrides = config.tunnelOverrides.filterNot { RoutingPresets.matchesCategory(category, it) }.toSet(),
)
}
else
{
config.copy(enabledCategories = config.enabledCategories - category)
}
}
}
/** Quick action: every RF category plus the ru.* namespace ("all RF apps"). */
fun enableAllRfApps()
{
updateConfig { config ->
config.copy(
enabled = true,
enabledCategories = config.enabledCategories + RoutingPresets.ALL_CATEGORIES,
tunnelOverrides = config.tunnelOverrides.filterNot { pkg ->
RoutingPresets.ALL_CATEGORIES.any { RoutingPresets.matchesCategory(it, pkg) }
}.toSet(),
)
}
}
/** Adds a package pattern; false if the input is invalid. */
fun addPattern(input: String): Boolean
{
val pattern = PackagePatterns.normalize(input) ?: return false
updateConfig { it.copy(packagePatterns = it.packagePatterns + pattern) }
return true
}
fun removePattern(pattern: String)
{
updateConfig { it.copy(packagePatterns = it.packagePatterns - pattern) }
}
/**
* Imports the split-tunneling apps of a profile file (JSON import): the
* per-profile setting no longer exists, so the apps join the global
* direct list ("excluded-apps" as is, "apps" as every other app).
*/
fun importProfileApps(handling: SelectedAppsHandling, selected: Set<String>)
{
val added = RoutingMigration.profileAppsToDirect(
handling, selected,
if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY) loadInstalledPackages() else emptySet(),
context.packageName)
if (added.isNotEmpty())
{
updateConfig { it.copy(enabled = true, directPackages = it.directPackages + added, tunnelOverrides = it.tunnelOverrides - added) }
}
}
/** The one-time "your 'only selected apps' profile was converted" notice has been shown. */
fun consumeMigrationNotice()
{
if (migratedOnlyProfiles != 0)
{
migratedOnlyProfiles = 0
prefs.edit().putInt(KEY_NOTICE_ONLY, 0).apply()
publish()
}
}
@@ -318,7 +384,8 @@ class RoutingManager private constructor(private val context: Context)
*/
fun computeExcludedPackages(): Set<String>
{
if (!config.enabled || config.ruleSets.none { it.enabled })
val current = config
if (!current.enabled)
{
return emptySet()
}
@@ -337,7 +404,7 @@ class RoutingManager private constructor(private val context: Context)
return packages
}
}
val packages = effectiveExcludes(config.ruleSets, loadInstalledPackages())
val packages = effectiveExcludes(current, loadInstalledPackages())
packageCache = version to packages
return packages
}
@@ -349,10 +416,213 @@ class RoutingManager private constructor(private val context: Context)
packageCache = null
}
private fun loadInstalledPackages(): Set<String>
private fun loadInstalledPackages(): Set<String> = loadInternetPackages(context.packageManager)
private fun publish()
{
val pm = context.packageManager
_state.value = RoutingState(config = config, migratedOnlyProfiles = migratedOnlyProfiles)
}
private fun scheduleReconnect()
{
handler.removeCallbacks(reconnectRunnable)
handler.postDelayed(reconnectRunnable, RECONNECT_DEBOUNCE_MS)
}
/* ------------------------- persistence + migration ------------------------- */
private fun loadConfig(): RoutingConfig
{
val json = prefs.getString(KEY_CONFIG, null) ?: return RoutingConfig()
return try
{
val obj = JSONObject(json)
if (obj.optInt("version", 1) >= CONFIG_VERSION)
{
parseConfig(obj)
}
else
{
migrateLegacyConfig(json, obj)
}
}
catch (_: JSONException)
{
RoutingConfig()
}
}
private fun parseConfig(obj: JSONObject) = RoutingConfig(
enabled = obj.optBoolean("enabled", true),
directPackages = obj.optJSONArray("directPackages").toStringSet(),
tunnelOverrides = obj.optJSONArray("tunnelOverrides").toStringSet(),
enabledCategories = obj.optJSONArray("enabledCategories").toStringSet(),
packagePatterns = obj.optJSONArray("packagePatterns").toStringSet(),
forceTunnelPrefixes = parseForcePrefixes(obj),
version = CONFIG_VERSION,
)
private fun parseForcePrefixes(obj: JSONObject): List<ForceTunnelPrefix> =
obj.optJSONArray("forceTunnelPrefixes")?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optJSONObject(i)?.let { o ->
ForceTunnelPrefix(
id = o.optString("id"),
prefix = o.optString("prefix"),
enabled = o.optBoolean("enabled", true),
)
}
}
} ?: emptyList()
/** Rule sets (version 1) -> direct apps + package patterns; the old JSON is kept in a backup key. */
private fun migrateLegacyConfig(json: String, obj: JSONObject): RoutingConfig
{
if (!prefs.contains(KEY_CONFIG_BACKUP))
{
prefs.edit().putString(KEY_CONFIG_BACKUP, json).commit()
}
val ruleSets = obj.optJSONArray("ruleSets")?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optJSONObject(i)?.let { o ->
RuleSet(
id = o.optString("id"),
name = o.optString("name"),
enabled = o.optBoolean("enabled", true),
packages = o.optJSONArray("packages").toStringSet(),
prefixes = o.optJSONArray("prefixes").toStringSet(),
keywords = o.optJSONArray("keywords").toStringSet(),
)
}
}
} ?: emptyList()
val migrated = RoutingMigration.migrateRuleSets(ruleSets)
val result = RoutingConfig(
enabled = obj.optBoolean("enabled", true),
directPackages = migrated.directPackages,
enabledCategories = migrated.categories,
packagePatterns = migrated.patterns,
forceTunnelPrefixes = parseForcePrefixes(obj),
)
Log.i(TAG, "Migrated ${ruleSets.size} rule sets to ${result.directPackages.size} apps, " +
"${result.packagePatterns.size} patterns, ${result.enabledCategories.size} categories")
saveConfig(result, commit = true)
return result
}
/**
* Per-profile app handling is gone: EXCLUDE lists become direct apps, an
* ONLY list becomes "every other installed internet app" (snapshot), and
* the profile is reset to DISABLE. Runs once (flag in prefs); a failure
* leaves the flag unset so the next start retries.
*/
private fun migrateProfiles()
{
if (prefs.getBoolean(KEY_PROFILES_MIGRATED, false))
{
return
}
try
{
val source = VpnProfileSqlDataSource()
source.open()
val direct = HashSet<String>()
var onlyConverted = 0
try
{
val installed by lazy { loadInstalledPackages() }
for (profile in source.allVpnProfiles)
{
val handling = profile.selectedAppsHandling
if (handling == SelectedAppsHandling.SELECTED_APPS_DISABLE)
{
continue
}
val added = RoutingMigration.profileAppsToDirect(
handling, profile.selectedAppsSet, if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY) installed else emptySet(),
context.packageName)
direct.addAll(added)
if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY && added.isNotEmpty())
{
onlyConverted++
}
profile.setSelectedAppsHandling(SelectedAppsHandling.SELECTED_APPS_DISABLE)
source.updateVpnProfile(profile)
}
}
finally
{
source.close()
}
if (direct.isNotEmpty())
{ /* the old per-profile excludes applied regardless of the global switch */
config = config.copy(enabled = true, directPackages = config.directPackages + direct,
tunnelOverrides = config.tunnelOverrides - direct)
saveConfig(config, commit = true)
}
prefs.edit()
.putBoolean(KEY_PROFILES_MIGRATED, true)
.putInt(KEY_NOTICE_ONLY, onlyConverted)
.commit()
Log.i(TAG, "Migrated profile apps: ${direct.size} direct apps, $onlyConverted 'only' profiles converted")
}
catch (e: Throwable)
{
Log.w(TAG, "Profile app migration failed, will retry on next start", e)
}
}
private fun saveConfig(config: RoutingConfig, commit: Boolean = false)
{
val obj = JSONObject()
obj.put("version", CONFIG_VERSION)
obj.put("enabled", config.enabled)
obj.put("directPackages", JSONArray(config.directPackages.sorted()))
obj.put("tunnelOverrides", JSONArray(config.tunnelOverrides.sorted()))
obj.put("enabledCategories", JSONArray(config.enabledCategories.sorted()))
obj.put("packagePatterns", JSONArray(config.packagePatterns.sorted()))
val forcePrefixes = JSONArray()
for (entry in config.forceTunnelPrefixes)
{
val o = JSONObject()
o.put("id", entry.id)
o.put("prefix", entry.prefix)
o.put("enabled", entry.enabled)
forcePrefixes.put(o)
}
obj.put("forceTunnelPrefixes", forcePrefixes)
val editor = prefs.edit().putString(KEY_CONFIG, obj.toString())
if (commit) editor.commit() else editor.apply()
}
private fun JSONArray?.toStringSet(): Set<String> =
this?.let { arr -> (0 until arr.length()).mapNotNull { i -> arr.optString(i).takeIf { it.isNotBlank() } }.toSet() } ?: emptySet()
companion object
{
private const val TAG = "RoutingManager"
private const val PREFS_NAME = "one.shx.strongswan.ext.ROUTING"
private const val KEY_CONFIG = "config"
private const val KEY_CONFIG_BACKUP = "config_v1_backup"
private const val KEY_PROFILES_MIGRATED = "profiles_migrated_v2"
private const val KEY_NOTICE_ONLY = "notice_only_profiles"
private const val RECONNECT_DEBOUNCE_MS = 2000L
/** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */
const val CONFIG_VERSION = 2
/** Validates and canonicalizes a CIDR/address; null if invalid. */
fun normalizePrefix(cidr: String): String? = try
{
org.strongswan.android.utils.IPRange(cidr.trim()).toString()
}
catch (_: Exception)
{
null
}
/** All installed apps that hold the INTERNET permission. */
fun loadInternetPackages(pm: PackageManager): Set<String> = try
{
pm.getInstalledApplications(PackageManager.GET_META_DATA)
.asSequence()
@@ -365,193 +635,59 @@ class RoutingManager private constructor(private val context: Context)
Log.w(TAG, "Failed to list installed packages", e)
emptySet()
}
}
private fun publish()
{
_state.value = RoutingState(config = config)
}
private fun scheduleReconnect()
{
handler.removeCallbacks(reconnectRunnable)
handler.postDelayed(reconnectRunnable, RECONNECT_DEBOUNCE_MS)
}
private fun loadConfig(): RoutingConfig
{
val json = prefs.getString(KEY_CONFIG, null) ?: return RoutingConfig()
return try
/** Built-in categories that are enabled in [config] and match [pkg], visible ones first. */
private fun matchingCategory(pkg: String, config: RoutingConfig): String?
{
val obj = JSONObject(json)
RoutingConfig(
enabled = obj.optBoolean("enabled", true),
ruleSets = obj.optJSONArray("ruleSets")?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optJSONObject(i)?.let { o ->
RuleSet(
id = o.optString("id"),
name = o.optString("name"),
enabled = o.optBoolean("enabled", true),
packages = o.optJSONArray("packages").toStringSet(),
prefixes = o.optJSONArray("prefixes").toStringSet(),
keywords = o.optJSONArray("keywords").toStringSet(),
)
}
}
} ?: emptyList(),
forceTunnelPrefixes = obj.optJSONArray("forceTunnelPrefixes")?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optJSONObject(i)?.let { o ->
ForceTunnelPrefix(
id = o.optString("id"),
prefix = o.optString("prefix"),
enabled = o.optBoolean("enabled", true),
)
}
}
} ?: emptyList(),
)
}
catch (_: JSONException)
{
RoutingConfig()
}
}
private fun saveConfig(config: RoutingConfig)
{
val obj = JSONObject()
obj.put("enabled", config.enabled)
val sets = JSONArray()
for (ruleSet in config.ruleSets)
{
val o = JSONObject()
o.put("id", ruleSet.id)
o.put("name", ruleSet.name)
o.put("enabled", ruleSet.enabled)
o.put("packages", JSONArray(ruleSet.packages.sorted()))
o.put("prefixes", JSONArray(ruleSet.prefixes.sorted()))
o.put("keywords", JSONArray(ruleSet.keywords.map { it.lowercase() }.sorted()))
sets.put(o)
}
obj.put("ruleSets", sets)
val forcePrefixes = JSONArray()
for (entry in config.forceTunnelPrefixes)
{
val o = JSONObject()
o.put("id", entry.id)
o.put("prefix", entry.prefix)
o.put("enabled", entry.enabled)
forcePrefixes.put(o)
}
obj.put("forceTunnelPrefixes", forcePrefixes)
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
}
private fun JSONArray?.toStringSet(): Set<String> =
this?.let { arr -> (0 until arr.length()).mapNotNull { i -> arr.optString(i).takeIf { it.isNotBlank() } }.toSet() } ?: emptySet()
companion object
{
private const val TAG = "RoutingManager"
private const val PREFS_NAME = "one.shx.strongswan.ext.ROUTING"
private const val KEY_CONFIG = "config"
private const val RECONNECT_DEBOUNCE_MS = 2000L
/** Validates and canonicalizes a CIDR/address; null if invalid. */
fun normalizePrefix(cidr: String): String? = try
{
org.strongswan.android.utils.IPRange(cidr.trim()).toString()
}
catch (_: Exception)
{
null
val visible = RoutingPresets.CATEGORIES.firstOrNull {
it in config.enabledCategories && RoutingPresets.matchesCategory(it, pkg)
}
if (visible != null)
{
return visible
}
return RoutingPresets.NAMESPACE.takeIf {
it in config.enabledCategories && RoutingPresets.matchesCategory(it, pkg)
}
}
/**
* All packages matched by the enabled rule sets: explicit packages,
* prefixes and keywords matched against the installed packages.
* Why a category or pattern covers [pkg] (ignoring personal overrides
* and the explicit list), or null if none does.
*/
fun effectiveExcludes(ruleSets: List<RuleSet>, installed: Set<String>): Set<String>
fun templateMatch(pkg: String, config: RoutingConfig): TemplateMatch?
{
val pattern = config.packagePatterns.firstOrNull { PackagePatterns.matches(it, pkg) }
val category = matchingCategory(pkg, config)
return if (category == null && pattern == null) null else TemplateMatch(category, pattern)
}
/**
* All packages that bypass the tunnel: explicit direct apps plus the
* installed apps matched by enabled categories and package patterns,
* minus personal "via VPN" overrides. Explicit apps that are not
* installed are dropped (unless the installed list is unavailable).
*/
fun effectiveExcludes(config: RoutingConfig, installed: Set<String>): Set<String>
{
val result = HashSet<String>()
for (ruleSet in ruleSets)
result.addAll(if (installed.isEmpty()) config.directPackages else config.directPackages.filter { it in installed })
result.addAll(PackagePatterns.matchAll(config.packagePatterns, installed))
for (category in config.enabledCategories)
{
if (!ruleSet.enabled)
val presets = RoutingPresets.presetsOf(category)
for (pkg in installed)
{
continue
if (presets.any { it.matches(pkg) })
{
result.add(pkg)
}
}
result.addAll(ruleSet.packages)
result.addAll(matchPrefixes(ruleSet.prefixes, installed))
result.addAll(matchKeywords(ruleSet.keywords, installed))
}
result.removeAll(config.tunnelOverrides)
return result
}
/** Installed packages whose name starts with any of the prefixes. */
fun matchPrefixes(prefixes: Set<String>, installed: Set<String>): Set<String> =
if (prefixes.isEmpty())
{
emptySet()
}
else
{
installed.filter { pkg -> prefixes.any { pkg.startsWith(it) } }.toSet()
}
/** Installed packages whose name contains any keyword (case-insensitive, anywhere). */
fun matchKeywords(keywords: Set<String>, installed: Set<String>): Set<String> =
if (keywords.isEmpty())
{
emptySet()
}
else
{
val lower = keywords.map { it.lowercase() }
installed.filter { pkg -> lower.any { pkg.lowercase().contains(it) } }.toSet()
}
/**
* Merges the global excludes into a profile's per-app split tunneling
* settings (applied in-memory when the TUN is built, the stored profile
* is untouched):
* - EXCLUDE (incl. DISABLE, converted by the caller): global packages
* are added to the excluded set;
* - ONLY: global packages are removed from the allowed set - in ONLY
* mode everything else goes direct anyway, so this alone is enough.
*
* @return the effective handling (DISABLE is normalized to EXCLUDE)
*/
@JvmStatic
fun applyGlobalExcludes(handling: SelectedAppsHandling, selected: MutableSet<String>, global: Set<String>): SelectedAppsHandling
{
if (global.isEmpty())
{
return if (handling == SelectedAppsHandling.SELECTED_APPS_DISABLE)
{
SelectedAppsHandling.SELECTED_APPS_EXCLUDE
}
else
{
handling
}
}
return when (handling)
{
SelectedAppsHandling.SELECTED_APPS_ONLY ->
{
selected.removeAll(global)
SelectedAppsHandling.SELECTED_APPS_ONLY
}
else ->
{
selected.addAll(global)
SelectedAppsHandling.SELECTED_APPS_EXCLUDE
}
}
}
@Volatile
private var instance: RoutingManager? = null
@@ -0,0 +1,99 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.routing
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
/**
* Pure conversion rules from the pre-2.16 settings (named rule sets and
* per-profile split-tunneling apps) to the single global direct-apps list.
* No Android dependencies, so they are unit tested; [RoutingManager] applies
* them once at startup.
*/
object RoutingMigration
{
data class RuleSetResult(
val directPackages: Set<String>,
val patterns: Set<String>,
val categories: Set<String>,
)
/**
* Enabled rule sets become explicit packages plus package patterns. A
* built-in category whose every preset is fully present among the rule
* sets (what the old "all RF apps" action produced) turns into the
* category itself - same coverage, but shown as one switch. Disabled rule
* sets are dropped (the old JSON is kept in a backup key).
*/
fun migrateRuleSets(ruleSets: List<RuleSet>): RuleSetResult
{
val active = ruleSets.filter { it.enabled }
val direct = HashSet<String>()
val patterns = LinkedHashSet<String>()
for (ruleSet in active)
{
direct.addAll(ruleSet.packages)
ruleSet.prefixes.forEach { patterns.add(PackagePatterns.fromPrefix(it)) }
ruleSet.keywords.forEach { patterns.add(PackagePatterns.fromKeyword(it)) }
}
val categories = LinkedHashSet<String>()
for (category in RoutingPresets.ALL_CATEGORIES)
{
val presets = RoutingPresets.presetsOf(category)
if (presets.isNotEmpty() && presets.all { preset -> active.any { it.covers(preset) } })
{
categories.add(category)
for (preset in presets)
{
preset.prefixes.forEach { patterns.remove(PackagePatterns.fromPrefix(it)) }
preset.keywords.forEach { patterns.remove(PackagePatterns.fromKeyword(it)) }
}
}
}
return RuleSetResult(direct, patterns, categories)
}
private fun RuleSet.covers(preset: RoutingPresets.Preset): Boolean
{
if (preset.prefixes.isEmpty() && preset.keywords.isEmpty())
{
return false
}
val lowerKeywords = keywords.map { it.lowercase() }.toSet()
return prefixes.containsAll(preset.prefixes) && lowerKeywords.containsAll(preset.keywords.map { it.lowercase() })
}
/**
* Packages a profile's old per-app setting sends direct (outside the VPN):
* - EXCLUDE: the listed apps;
* - ONLY: "all installed internet apps except the listed ones" snapshot
* (an empty ONLY list never took effect in the old code, so it yields
* nothing);
* - DISABLE: nothing.
* The app's own package is never listed (it is always excluded anyway).
*/
fun profileAppsToDirect(
handling: SelectedAppsHandling,
selected: Set<String>,
installed: Set<String>,
ownPackage: String,
): Set<String> = when (handling)
{
SelectedAppsHandling.SELECTED_APPS_EXCLUDE -> selected - ownPackage
SelectedAppsHandling.SELECTED_APPS_ONLY ->
if (selected.isEmpty()) emptySet() else installed - selected - ownPackage
else -> emptySet()
}
}
@@ -14,10 +14,9 @@
package org.strongswan.android.logic.routing
import java.util.UUID
/**
* Built-in rule set templates.
* Built-in app templates, grouped into categories the user can switch on in
* one tap ("Banks", "Marketplaces", ...).
* - Prefixes match package names, so "ru." catches every package in the ru.*
* reverse-domain namespace.
* - Keywords match anywhere in the package name (case-insensitive), so
@@ -28,75 +27,112 @@ import java.util.UUID
*/
object RoutingPresets
{
data class Preset(val name: String, val prefixes: List<String> = emptyList(), val keywords: List<String> = emptyList())
/* category ids; the namespace-wide preset lives in the hidden NAMESPACE
* category that only "all RF apps" switches on */
const val BANKS = "banks"
const val GOV = "gov"
const val SHOP = "shop"
const val SOCIAL = "social"
const val TRAVEL = "travel"
const val SERVICES = "services"
const val NAMESPACE = "ru_namespace"
/** User-visible categories in display order (the hidden [NAMESPACE] one is excluded). */
val CATEGORIES: List<String> = listOf(BANKS, GOV, SHOP, SOCIAL, TRAVEL, SERVICES)
/** Everything "all RF apps" switches on. */
val ALL_CATEGORIES: Set<String> = (CATEGORIES + NAMESPACE).toSet()
data class Preset(
val name: String,
val category: String,
val prefixes: List<String> = emptyList(),
val keywords: List<String> = emptyList(),
)
/** Whether a package belongs to the preset (prefix, or keyword anywhere, case-insensitive). */
fun Preset.matches(pkg: String): Boolean
{
if (prefixes.any { pkg.startsWith(it) })
{
return true
}
val lower = pkg.lowercase()
return keywords.any { lower.contains(it.lowercase()) }
}
/** Presets of one category, in declaration order. */
fun presetsOf(category: String): List<Preset> = RF_BUNDLE.filter { it.category == category }
/** Whether the package matches any preset of [category] (the namespace one included). */
fun matchesCategory(category: String, pkg: String): Boolean =
presetsOf(category).any { it.matches(pkg) }
/** First user-visible category the package belongs to, or null. */
fun categoryOf(pkg: String): String? =
CATEGORIES.firstOrNull { matchesCategory(it, pkg) }
val RF_BUNDLE: List<Preset> = listOf(
/* namespace-wide */
Preset("Все RU.*", prefixes = listOf("ru.")),
Preset("Все RU.*", NAMESPACE, prefixes = listOf("ru.")),
/* ecosystems */
Preset("Яндекс", prefixes = listOf("ru.yandex."), keywords = listOf("yandex")),
Preset("VK", prefixes = listOf("com.vkontakte", "ru.vk", "com.vk"), keywords = listOf("vkontakte")),
Preset("Одноклассники", prefixes = listOf("ru.ok.")),
Preset("Дзен", keywords = listOf("dzen")),
Preset("Rutube", keywords = listOf("rutube")),
Preset("Звук", keywords = listOf("zvuk", "zvooq")),
Preset("Иви", prefixes = listOf("ru.ivi")),
Preset("Яндекс", SOCIAL, prefixes = listOf("ru.yandex."), keywords = listOf("yandex")),
Preset("VK", SOCIAL, prefixes = listOf("com.vkontakte", "ru.vk", "com.vk"), keywords = listOf("vkontakte")),
Preset("Одноклассники", SOCIAL, prefixes = listOf("ru.ok.")),
Preset("Дзен", SOCIAL, keywords = listOf("dzen")),
Preset("Rutube", SOCIAL, keywords = listOf("rutube")),
Preset("Звук", SOCIAL, keywords = listOf("zvuk", "zvooq")),
Preset("Иви", SOCIAL, prefixes = listOf("ru.ivi")),
/* banks & finance */
Preset("Сбер", keywords = listOf("sber")),
Preset("Т-Банк", keywords = listOf("tinkoff", "tbank")),
Preset("ВТБ", keywords = listOf("vtb")),
Preset("Альфа-Банк", keywords = listOf("alfabank", "alpha-bank")),
Preset("Газпромбанк", keywords = listOf("gazprombank")),
Preset("Совкомбанк", keywords = listOf("sovcombank")),
Preset("Почта Банк", keywords = listOf("pochtabank")),
Preset("РСХБ", keywords = listOf("rshb", "rosselkhozbank")),
Preset("ОТП-Банк", prefixes = listOf("ru.otpbank"), keywords = listOf("otpbank")),
Preset("МКБ", prefixes = listOf("ru.mkb")),
Preset("Уралсиб", keywords = listOf("uralsib")),
Preset("ЮMoney", keywords = listOf("yoomoney")),
Preset("Золотая Корона", keywords = listOf("koronapay")),
Preset("Мой налог", keywords = listOf("nalog")),
Preset("Сбер", BANKS, keywords = listOf("sber")),
Preset("Т-Банк", BANKS, keywords = listOf("tinkoff", "tbank")),
Preset("ВТБ", BANKS, keywords = listOf("vtb")),
Preset("Альфа-Банк", BANKS, keywords = listOf("alfabank", "alpha-bank")),
Preset("Газпромбанк", BANKS, keywords = listOf("gazprombank")),
Preset("Совкомбанк", BANKS, keywords = listOf("sovcombank")),
Preset("Почта Банк", BANKS, keywords = listOf("pochtabank")),
Preset("РСХБ", BANKS, keywords = listOf("rshb", "rosselkhozbank")),
Preset("ОТП-Банк", BANKS, prefixes = listOf("ru.otpbank"), keywords = listOf("otpbank")),
Preset("МКБ", BANKS, prefixes = listOf("ru.mkb")),
Preset("Уралсиб", BANKS, keywords = listOf("uralsib")),
Preset("ЮMoney", BANKS, keywords = listOf("yoomoney")),
Preset("Золотая Корона", BANKS, keywords = listOf("koronapay")),
/* government */
Preset("Госуслуги", GOV, prefixes = listOf("ru.rostel"), keywords = listOf("gosuslugi")),
Preset("Мой налог", GOV, keywords = listOf("nalog")),
/* retail & marketplaces */
Preset("Ozon", keywords = listOf("ozon")),
Preset("Wildberries", keywords = listOf("wildberries")),
Preset("Мегамаркет", keywords = listOf("megamarket")),
Preset("Пятёрочка", keywords = listOf("pyaterochka")),
Preset("Перекрёсток", keywords = listOf("perekrestok")),
Preset("Магнит", keywords = listOf("magnit")),
Preset("Лента", keywords = listOf("lenta")),
Preset("М.Видео", keywords = listOf("mvideo")),
Preset("Спортмастер", keywords = listOf("sportmaster")),
Preset("Лемана ПРО", keywords = listOf("leroymerlin", "lemana")),
Preset("Самокат", keywords = listOf("samokat")),
Preset("ВкусВилл", keywords = listOf("vkusvill")),
Preset("Ozon", SHOP, keywords = listOf("ozon")),
Preset("Wildberries", SHOP, keywords = listOf("wildberries")),
Preset("Мегамаркет", SHOP, keywords = listOf("megamarket")),
Preset("Пятёрочка", SHOP, keywords = listOf("pyaterochka")),
Preset("Перекрёсток", SHOP, keywords = listOf("perekrestok")),
Preset("Магнит", SHOP, keywords = listOf("magnit")),
Preset("Лента", SHOP, keywords = listOf("lenta")),
Preset("М.Видео", SHOP, keywords = listOf("mvideo")),
Preset("Спортмастер", SHOP, keywords = listOf("sportmaster")),
Preset("Лемана ПРО", SHOP, keywords = listOf("leroymerlin", "lemana")),
Preset("Самокат", SHOP, keywords = listOf("samokat")),
Preset("ВкусВилл", SHOP, keywords = listOf("vkusvill")),
/* telecom */
Preset("МТС", prefixes = listOf("ru.mts", "com.mts")),
Preset("Мегафон", keywords = listOf("megafon")),
Preset("Билайн", keywords = listOf("beeline")),
Preset("T2/Tele2", prefixes = listOf("ru.t2"), keywords = listOf("tele2")),
Preset("Yota", prefixes = listOf("ru.yota")),
Preset("МТС", SOCIAL, prefixes = listOf("ru.mts", "com.mts")),
Preset("Мегафон", SOCIAL, keywords = listOf("megafon")),
Preset("Билайн", SOCIAL, keywords = listOf("beeline")),
Preset("T2/Tele2", SOCIAL, prefixes = listOf("ru.t2"), keywords = listOf("tele2")),
Preset("Yota", SOCIAL, prefixes = listOf("ru.yota")),
/* transport & travel */
Preset("РЖД", keywords = listOf("rzd")),
Preset("Аэрофлот", keywords = listOf("aeroflot")),
Preset("Туту.ру", keywords = listOf("tutu.ru")),
Preset("Aviasales", keywords = listOf("aviasales")),
Preset("Ситимобил", keywords = listOf("sitimobil")),
Preset("Делимобиль", keywords = listOf("delimobil")),
Preset("РЖД", TRAVEL, keywords = listOf("rzd")),
Preset("Аэрофлот", TRAVEL, keywords = listOf("aeroflot")),
Preset("Туту.ру", TRAVEL, keywords = listOf("tutu.ru")),
Preset("Aviasales", TRAVEL, keywords = listOf("aviasales")),
Preset("Ситимобил", TRAVEL, keywords = listOf("sitimobil")),
Preset("Делимобиль", TRAVEL, keywords = listOf("delimobil")),
/* services */
Preset("2ГИС", keywords = listOf("2gis", "dublgis")),
Preset("ЦИАН", prefixes = listOf("ru.cian")),
Preset("Дром", keywords = listOf("drom")),
Preset("Юла", keywords = listOf("youla")),
Preset("HeadHunter", prefixes = listOf("ru.hh"), keywords = listOf("headhunter")),
Preset("Почта России", keywords = listOf("pochta", "russianpost")),
Preset("СДЭК", keywords = listOf("cdek")),
Preset("2ГИС", SERVICES, keywords = listOf("2gis", "dublgis")),
Preset("ЦИАН", SERVICES, prefixes = listOf("ru.cian")),
Preset("Дром", SERVICES, keywords = listOf("drom")),
Preset("Юла", SERVICES, keywords = listOf("youla")),
Preset("HeadHunter", SERVICES, prefixes = listOf("ru.hh"), keywords = listOf("headhunter")),
Preset("Почта России", SERVICES, keywords = listOf("pochta", "russianpost")),
Preset("СДЭК", SERVICES, keywords = listOf("cdek")),
)
}
fun RoutingPresets.Preset.asRuleSet(): RuleSet = RuleSet(
id = UUID.randomUUID().toString(),
name = name,
enabled = true,
prefixes = prefixes.toSet(),
keywords = keywords.map { it.lowercase() }.toSet(),
)
@@ -21,6 +21,8 @@ import android.os.Bundle;
import org.strongswan.android.logic.updates.UpdateManager;
import org.strongswan.android.ui.compose.ShxAppKt;
import org.strongswan.android.ui.compose.ShxNavigation;
import org.strongswan.android.ui.compose.ShxTab;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.splashscreen.SplashScreen;
@@ -35,6 +37,11 @@ public class MainActivity extends AppCompatActivity
*/
public static final String EXTRA_INSTALL_UPDATE = "org.strongswan.android.extra.INSTALL_UPDATE";
/**
* Set as an intent extra to open the Routing tab (e.g. from the profile form)
*/
public static final String EXTRA_OPEN_ROUTING = "org.strongswan.android.extra.OPEN_ROUTING";
/**
* Use "bring your own device" (BYOD) features
*/
@@ -47,6 +54,7 @@ public class MainActivity extends AppCompatActivity
SplashScreen.Companion.installSplashScreen(this);
super.onCreate(savedInstanceState);
handleInstallUpdate(getIntent());
handleOpenRouting(getIntent());
ShxAppKt.installShxUi(this);
}
@@ -64,6 +72,16 @@ public class MainActivity extends AppCompatActivity
{
super.onNewIntent(intent);
handleInstallUpdate(intent);
handleOpenRouting(intent);
}
private void handleOpenRouting(Intent intent)
{
if (intent != null && intent.getBooleanExtra(EXTRA_OPEN_ROUTING, false))
{
intent.removeExtra(EXTRA_OPEN_ROUTING);
ShxNavigation.INSTANCE.request(ShxTab.ROUTING);
}
}
private void handleInstallUpdate(Intent intent)
@@ -1,213 +0,0 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.ui.adapter.SelectedApplicationEntry
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.theme.ShxTheme
import java.util.TreeSet
class SelectedApplicationsActivity : AppCompatActivity()
{
private val selection = TreeSet<String>()
private var readOnly = false
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
readOnly = intent.getBooleanExtra(VpnProfileDataSource.KEY_READ_ONLY, false)
val initial = savedInstanceState?.getStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
?: intent.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
?: arrayListOf()
selection.addAll(initial)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true)
{
override fun handleOnBackPressed()
{
prepareResult()
finish()
}
})
setContent {
ShxTheme {
SelectedAppsScreen(
initial = selection,
readOnly = readOnly,
onBack = {
prepareResult()
finish()
},
onChange = {
selection.clear()
selection.addAll(it)
},
)
}
}
}
override fun onSaveInstanceState(outState: Bundle)
{
super.onSaveInstanceState(outState)
outState.putStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(selection))
}
private fun prepareResult()
{
val data = Intent()
data.putExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(selection))
setResult(RESULT_OK, data)
}
}
@Composable
private fun SelectedAppsScreen(
initial: Set<String>,
readOnly: Boolean,
onBack: () -> Unit,
onChange: (Set<String>) -> Unit,
)
{
val context = LocalContext.current
var query by remember { mutableStateOf("") }
var apps by remember { mutableStateOf<List<SelectedApplicationEntry>>(emptyList()) }
var selected by remember { mutableStateOf(initial.toSet()) }
var loading by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
val loaded = withContext(Dispatchers.IO) {
val pm = context.packageManager
val list = ArrayList<SelectedApplicationEntry>()
for (info in pm.getInstalledApplications(PackageManager.GET_META_DATA))
{
if (pm.checkPermission(Manifest.permission.INTERNET, info.packageName) == PackageManager.PERMISSION_GRANTED)
{
val entry = SelectedApplicationEntry(pm, info)
entry.isSelected = selected.contains(info.packageName)
list.add(entry)
}
}
list.sorted()
}
apps = loaded
loading = false
}
fun toggle(pkg: String)
{
if (readOnly) return
selected = if (selected.contains(pkg)) selected - pkg else selected + pkg
onChange(selected)
}
/* pure system apps are hidden by default (they are rarely worth excluding); a selected one always shows */
var showSystem by remember { mutableStateOf(false) }
val filtered = remember(apps, query, showSystem, selected) {
apps.filter { entry ->
val flags = entry.info.flags
val system = (flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0 &&
(flags and android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) == 0
(showSystem || !system || selected.contains(entry.info.packageName)) &&
(query.isBlank() || entry.toString().contains(query, ignoreCase = true) ||
entry.info.packageName.contains(query, ignoreCase = true))
}
}
SecondaryScaffold(title = stringResource(R.string.profile_select_apps), onBack = onBack) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text(stringResource(R.string.search)) },
singleLine = true,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm).fillMaxWidth(),
)
androidx.compose.foundation.layout.Row(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { showSystem = !showSystem }
.padding(horizontal = ShxSpacing.lg),
)
{
Text(stringResource(R.string.apps_show_system), modifier = Modifier.weight(1f))
androidx.compose.material3.Switch(checked = showSystem, onCheckedChange = null)
}
if (loading)
{
LoadingIndicator(Modifier.padding(ShxSpacing.xl).size(32.dp))
}
else
{
LazyColumn(Modifier.fillMaxSize()) {
items(filtered, key = { it.info.packageName }) { entry ->
val pkg = entry.info.packageName
val checked = selected.contains(pkg)
ListItem(
headlineContent = { Text(entry.toString()) },
supportingContent = { Text(pkg) },
leadingContent = {
val bmp = remember(pkg) { entry.icon.toBitmap() }
Image(bitmap = bmp.asImageBitmap(), contentDescription = null, modifier = Modifier.size(40.dp))
},
trailingContent = {
Checkbox(checked = checked, onCheckedChange = { toggle(pkg) }, enabled = !readOnly)
},
modifier = Modifier.clickable(enabled = !readOnly) { toggle(pkg) },
)
}
}
}
}
}
}
@@ -41,6 +41,7 @@ import org.strongswan.android.data.VpnProfileSource;
import org.strongswan.android.data.VpnType;
import org.strongswan.android.data.VpnType.VpnTypeFeature;
import org.strongswan.android.logic.TrustedCertificateManager;
import org.strongswan.android.logic.routing.RoutingManager;
import org.strongswan.android.security.TrustedCertificateEntry;
import org.strongswan.android.ui.compose.ImportUiModel;
import org.strongswan.android.ui.compose.ImportScreenKt;
@@ -629,6 +630,11 @@ public class VpnProfileImportActivity extends AppCompatActivity
if (verifyInput())
{
updateProfileData();
if (mProfile.getSelectedAppsHandling() != SelectedAppsHandling.SELECTED_APPS_DISABLE)
{ /* per-profile apps are gone: they join the global direct-apps list */
RoutingManager.getInstance(this).importProfileApps(mProfile.getSelectedAppsHandling(), mProfile.getSelectedAppsSet());
mProfile.setSelectedAppsHandling(SelectedAppsHandling.SELECTED_APPS_DISABLE);
}
if (mExisting != null)
{
mProfile.setUUID(mExisting.getUUID());
@@ -1,73 +0,0 @@
/*
* Copyright (C) 2017 Tobias Brunner
*
* Copyright (C) secunet Security Networks AG
*
* 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.ui.adapter;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.graphics.drawable.Drawable;
import java.text.Collator;
import androidx.annotation.NonNull;
public class SelectedApplicationEntry implements Comparable<SelectedApplicationEntry>
{
private final ApplicationInfo mInfo;
private final Drawable mIcon;
private final String mName;
private boolean mSelected;
public SelectedApplicationEntry(PackageManager packageManager, ApplicationInfo info)
{
mInfo = info;
CharSequence name = info.loadLabel(packageManager);
mName = name == null ? info.packageName : name.toString();
mIcon = info.loadIcon(packageManager);
}
public void setSelected(boolean selected)
{
mSelected = selected;
}
public boolean isSelected()
{
return mSelected;
}
public ApplicationInfo getInfo()
{
return mInfo;
}
public Drawable getIcon()
{
return mIcon;
}
@Override
public String toString()
{
return mName;
}
@Override
public int compareTo(@NonNull SelectedApplicationEntry another)
{
return Collator.getInstance().compare(toString(), another.toString());
}
}
@@ -1,179 +0,0 @@
/*
* 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.
*/
package org.strongswan.android.ui.compose
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingPresets
import org.strongswan.android.ui.compose.theme.ShxSpacing
/**
* Searchable list of the built-in presets shown inside the rule set editor
* sheet. Selection toggles immediately; "Done"/back returns to the editor.
*/
@Composable
fun PresetPickerContent(
ruleSet: RuleSet,
onToggle: (RoutingPresets.Preset) -> Unit,
onBack: () -> Unit,
)
{
var query by remember { mutableStateOf("") }
val filtered = remember(query)
{
val q = query.trim()
if (q.isEmpty())
{
RoutingPresets.RF_BUNDLE
}
else
{
RoutingPresets.RF_BUNDLE.filter { it.name.contains(q, ignoreCase = true) }
}
}
Column(Modifier.fillMaxWidth())
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = ShxSpacing.md, end = ShxSpacing.md, bottom = ShxSpacing.xs),
)
{
IconButton(onClick = onBack)
{
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
}
Text(
text = stringResource(R.string.routing_presets_label),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onBack)
{
Text(stringResource(R.string.action_done))
}
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text(stringResource(R.string.routing_presets_search)) },
singleLine = true,
leadingIcon = {
Icon(Icons.Rounded.Search, contentDescription = null)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.xl),
shape = MaterialTheme.shapes.medium,
)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 460.dp)
.padding(top = ShxSpacing.xs),
)
{
items(filtered, key = { it.name })
{ preset ->
val selected = ruleSet.prefixes.containsAll(preset.prefixes) &&
ruleSet.keywords.containsAll(preset.keywords)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onToggle(preset) }
.padding(horizontal = ShxSpacing.xl, vertical = 6.dp),
)
{
Column(
Modifier
.weight(1f)
.padding(vertical = 6.dp),
)
{
Text(preset.name, style = MaterialTheme.typography.bodyLarge)
Text(
preset.patternSummary(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Checkbox(checked = selected, onCheckedChange = { onToggle(preset) })
}
}
if (filtered.isEmpty())
{
item {
Text(
text = stringResource(R.string.routing_presets_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.lg),
)
}
}
}
}
}
/** "3 prefixes · 2 keywords" summary, skipping empty groups. */
@Composable
private fun RoutingPresets.Preset.patternSummary(): String
{
val parts = listOf(
if (prefixes.isEmpty()) null else stringResource(R.string.routing_preset_prefixes, prefixes.size),
if (keywords.isEmpty()) null else stringResource(R.string.routing_preset_keywords, keywords.size),
).filterNotNull()
return if (parts.isEmpty())
{
stringResource(R.string.routing_preset_no_patterns)
}
else
{
parts.joinToString(separator = " · ")
}
}
@@ -10,6 +10,7 @@
package org.strongswan.android.ui.compose
import org.strongswan.android.ui.compose.components.ShxSectionCard
import org.strongswan.android.ui.compose.components.ShxSettingRow
import android.app.Activity
import android.content.Intent
import android.security.KeyChain
@@ -43,6 +44,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem
@@ -85,17 +87,15 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.data.VpnType
import org.strongswan.android.data.VpnType.VpnTypeFeature
import org.strongswan.android.ui.SelectedApplicationsActivity
import org.strongswan.android.ui.MainActivity
import org.strongswan.android.ui.TrustedCertificatesActivity
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.utils.Constants
import org.strongswan.android.utils.IPRangeSet
import org.strongswan.android.utils.Utils
import java.util.TreeSet
data class ProfileFormState(
val name: String = "",
@@ -123,8 +123,6 @@ data class ProfileFormState(
val excludedSubnets: String = "",
val blockIpv4: Boolean = false,
val blockIpv6: Boolean = false,
val appsHandling: SelectedAppsHandling = SelectedAppsHandling.SELECTED_APPS_DISABLE,
val selectedApps: Set<String> = emptySet(),
val ikeProposal: String = "",
val espProposal: String = "",
val proxyHost: String = "",
@@ -177,8 +175,6 @@ fun ProfileFormState.toProfile(existing: VpnProfile?): VpnProfile
if (blockIpv4) st = st or VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4
if (blockIpv6) st = st or VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6
profile.splitTunneling = if (st == 0) null else st
profile.selectedAppsHandling = appsHandling
profile.setSelectedApps(TreeSet(selectedApps))
profile.ikeProposal = ikeProposal.trim().ifEmpty { null }
profile.espProposal = espProposal.trim().ifEmpty { null }
profile.proxyHost = proxyHost.trim().ifEmpty { null }
@@ -217,8 +213,6 @@ fun VpnProfile.toFormState(): ProfileFormState
excludedSubnets = excludedSubnets ?: "",
blockIpv4 = st and VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4 != 0,
blockIpv6 = st and VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6 != 0,
appsHandling = selectedAppsHandling,
selectedApps = selectedAppsSet ?: emptySet(),
ikeProposal = ikeProposal ?: "",
espProposal = espProposal ?: "",
proxyHost = proxyHost ?: "",
@@ -228,7 +222,6 @@ fun VpnProfile.toFormState(): ProfileFormState
advanced = remoteId != null || localId != null || dnsServers != null || getMTU() != null
|| port != null || getNATKeepAlive() != null || flags != 0 || st != 0
|| includedSubnets != null || excludedSubnets != null
|| selectedAppsHandling != SelectedAppsHandling.SELECTED_APPS_DISABLE
|| ikeProposal != null || espProposal != null || proxyHost != null,
)
}
@@ -308,9 +301,7 @@ fun ProfileFormScreen(
messages?.collect { snackbarHostState.showSnackbar(context.getString(it)) }
}
val types = stringArrayResource(R.array.vpn_types)
val appsHandling = stringArrayResource(R.array.apps_handling)
var typeExpanded by remember { mutableStateOf(false) }
var appsExpanded by remember { mutableStateOf(false) }
var showPassword by remember { mutableStateOf(false) }
val enabled = !state.readOnly
@@ -322,14 +313,6 @@ fun ProfileFormScreen(
onChange(latest.value.copy(caAlias = alias, caLabel = alias ?: "", caAuto = alias == null))
}
}
val pickApps = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK)
{
val list = result.data?.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST) ?: arrayListOf()
onChange(latest.value.copy(selectedApps = list.toSet()))
}
}
Scaffold(
snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) },
modifier = Modifier.windowInsetsPadding(
@@ -577,38 +560,14 @@ fun ProfileFormScreen(
checked = state.blockIpv6,
enabled = enabled,
) { onChange(state.copy(blockIpv6 = it)) }
ExposedDropdownMenuBox(expanded = appsExpanded, onExpandedChange = { if (enabled) appsExpanded = it }) {
OutlinedTextField(
value = appsHandling.getOrElse(state.appsHandling.ordinal) { "" },
onValueChange = {},
readOnly = true,
enabled = enabled,
label = { Text(stringResource(R.string.profile_select_apps)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(appsExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = appsExpanded, onDismissRequest = { appsExpanded = false }) {
SelectedAppsHandling.values().forEachIndexed { index, handling ->
DropdownMenuItem(
text = { Text(appsHandling.getOrElse(index) { handling.name }) },
onClick = {
onChange(state.copy(appsHandling = handling))
appsExpanded = false
},
)
}
}
}
if (state.appsHandling != SelectedAppsHandling.SELECTED_APPS_DISABLE)
{
TextButton(onClick = {
val intent = Intent(context, SelectedApplicationsActivity::class.java)
intent.putStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(state.selectedApps))
pickApps.launch(intent)
}, enabled = enabled) {
Text(stringResource(R.string.profile_select_apps) + " (${state.selectedApps.size})")
}
}
ShxSettingRow(
icon = Icons.Rounded.AltRoute,
title = stringResource(R.string.profile_routing_link_title),
subtitle = stringResource(R.string.profile_routing_link_summary),
onClick = {
context.startActivity(Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_ROUTING, true))
},
)
OutlinedTextField(state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
label = { Text(stringResource(R.string.profile_proposals_ike_label)) },
isError = state.ikeProposalError != null,
@@ -4,103 +4,61 @@
* 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.
* 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.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import android.app.Activity
import android.content.Intent
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.Block
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Lan
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Route
import androidx.compose.material.icons.rounded.Rule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material.icons.rounded.Shield
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
@@ -108,51 +66,75 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.routing.ForceTunnelPrefix
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.logic.routing.RuIpListState
import org.strongswan.android.logic.routing.RuIpListStatus
import org.strongswan.android.utils.Constants
import org.strongswan.android.ui.RouteCheckActivity
import org.strongswan.android.ui.SelectedApplicationsActivity
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.routing.RoutingHeroCard
import org.strongswan.android.ui.compose.routing.RuToggleRow
import org.strongswan.android.ui.compose.routing.RuModeSelector
import org.strongswan.android.ui.compose.routing.RuDetailsDisclosure
import org.strongswan.android.ui.compose.routing.GeoipChipsRow
import org.strongswan.android.ui.compose.routing.RfActionRow
import org.strongswan.android.ui.compose.routing.RuleSetRow
import org.strongswan.android.ui.compose.routing.AddRuleRow
import org.strongswan.android.ui.compose.routing.ForcePrefixRow
import org.strongswan.android.ui.compose.routing.AddForcePrefixRow
import org.strongswan.android.ui.compose.components.PredictiveBackScale
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import org.strongswan.android.ui.compose.routing.AdvancedPage
import org.strongswan.android.ui.compose.routing.AdvancedSection
import org.strongswan.android.ui.compose.routing.DeadHostsPage
import org.strongswan.android.ui.compose.routing.DirectAppsCard
import org.strongswan.android.ui.compose.routing.DirectAppsScreen
import org.strongswan.android.ui.compose.routing.ForcePrefixDialog
import org.strongswan.android.ui.compose.routing.ForcePrefixPage
import org.strongswan.android.ui.compose.routing.PatternDialog
import org.strongswan.android.ui.compose.routing.PatternsPage
import org.strongswan.android.ui.compose.routing.RoutingHeroCard
import org.strongswan.android.ui.compose.routing.RuSitesCard
import org.strongswan.android.ui.compose.routing.routingSummary
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** In-tab pages of the Routing tab; "back" returns to the main page, not out of the tab. */
private enum class RoutingPage { MAIN, APPS, FORCE, PATTERNS, DEAD }
@Composable
fun RoutingScreen()
{
val snackbarHostState = remember { SnackbarHostState() }
var page by rememberSaveable { mutableStateOf(RoutingPage.MAIN) }
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<androidx.compose.ui.unit.IntOffset>()
Scaffold(
snackbarHost = { ShxSnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.background,
) { _ ->
RoutingContent(snackbarHostState)
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter)
{
PredictiveBackScale(
enabled = page != RoutingPage.MAIN,
onBack = { page = RoutingPage.MAIN },
modifier = Modifier
.widthIn(max = 640.dp)
.fillMaxSize(),
)
{
AnimatedContent(
targetState = page,
transitionSpec = {
val forward = targetState != RoutingPage.MAIN
(fadeIn(effects) + slideInHorizontally(spatial) { if (forward) it / 8 else -it / 8 }) togetherWith
(fadeOut(effects) + slideOutHorizontally(spatial) { if (forward) -it / 8 else it / 8 })
},
label = "routingPage",
) { current ->
RoutingPageContent(current, snackbarHostState, onNavigate = { page = it }, onBack = { page = RoutingPage.MAIN })
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoutingContent(snackbarHostState: SnackbarHostState)
private fun RoutingPageContent(
page: RoutingPage,
snackbarHostState: SnackbarHostState,
onNavigate: (RoutingPage) -> Unit,
onBack: () -> Unit,
)
{
val context = LocalContext.current
val manager = remember { RoutingManager.getInstance(context) }
@@ -160,58 +142,40 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
val state by manager.state.collectAsStateWithLifecycle()
val ruState by ruList.state.collectAsStateWithLifecycle()
val config = state.config
var editingId by rememberSaveable { mutableStateOf<String?>(null) }
var focusName by rememberSaveable { mutableStateOf(false) }
var pickTarget by remember { mutableStateOf<String?>(null) }
/* force-prefix dialog: null = hidden, "" = add, otherwise the entry id */
var forceDialogId by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val pickApps = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val id = pickTarget
if (result.resultCode == Activity.RESULT_OK && id != null)
{
val packages = result.data?.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
?: arrayListOf()
manager.updateRuleSetPackages(id, packages.toSet())
}
pickTarget = null
}
/* force-prefix dialog: null = hidden, "" = add, otherwise the entry id */
var forceDialogId by remember { mutableStateOf<String?>(null) }
var patternDialog by remember { mutableStateOf(false) }
/* real number of apps excluded from the tunnel: prefixes/keywords expanded
/* real packages that bypass the tunnel: categories and templates expanded
* against installed apps (cached per config version inside the manager) */
var excludedApps by remember { mutableStateOf(0) }
LaunchedEffect(config)
{
excludedApps = if (config.enabled)
val directPackages by produceState(initialValue = emptyList<String>(), config) {
value = if (config.enabled)
{
withContext(Dispatchers.IO) { manager.computeExcludedPackages().size }
withContext(Dispatchers.IO) { manager.computeExcludedPackages().filter { it != context.packageName }.sorted() }
}
else
{
0
emptyList()
}
}
fun addNewSet()
/* dead direct destinations the engine keeps refusing */
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>())
{
editingId = manager.addRuleSet()
focusName = true
}
fun deleteSetWithUndo(ruleSet: RuleSet)
{
scope.launch {
manager.deleteRuleSet(ruleSet.id)
val result = snackbarHostState.showSnackbar(
message = context.getString(R.string.routing_delete_snackbar),
actionLabel = context.getString(R.string.action_undo),
duration = SnackbarDuration.Long,
)
if (result == SnackbarResult.ActionPerformed)
while (true)
{
val raw = try
{
manager.restoreRuleSet(ruleSet)
withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() }
}
catch (_: Throwable)
{
null
}
value = HomeViewModel.parseDeadTargets(raw)
delay(5_000)
}
}
@@ -231,307 +195,52 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
}
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = ShxSpacing.xl),
)
when (page)
{
TopAppBar(
title = { Text(stringResource(R.string.routing_title), fontWeight = FontWeight.SemiBold) },
actions = {
IconButton(onClick = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) })
{
Icon(
Icons.Rounded.Route,
contentDescription = stringResource(R.string.route_check_title),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
RoutingPage.APPS -> DirectAppsScreen(onBack = onBack)
RoutingPage.FORCE -> ForcePrefixPage(
prefixes = config.forceTunnelPrefixes,
onToggle = manager::setForcePrefixEnabled,
onEdit = { forceDialogId = it },
onDelete = manager::deleteForcePrefix,
onAdd = { forceDialogId = "" },
onBack = onBack,
)
/* ---- hero: master toggle + live summary ---- */
RoutingHeroCard(
RoutingPage.PATTERNS -> PatternsPage(
patterns = config.packagePatterns.sorted(),
onDelete = manager::removePattern,
onAdd = { patternDialog = true },
onBack = onBack,
)
RoutingPage.DEAD -> DeadHostsPage(targets = deadTargets, onBack = onBack)
RoutingPage.MAIN -> MainPage(
enabled = config.enabled,
setCount = config.ruleSets.count { it.enabled },
appCount = excludedApps,
onToggle = { manager.setGlobalEnabled(it) },
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
)
/* kill switch + excluded apps: excluded apps are added to the VPN's disallowed list
* (CharonVpnService.addDisallowedApplication), so the system lockdown does not
* cover them - on a tunnel drop they keep using the normal network */
if (config.enabled && excludedApps > 0 && remember { context.isVpnLockdownEnabled() })
{
Text(
text = stringResource(R.string.routing_killswitch_note, excludedApps),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.lg + ShxSpacing.xs, vertical = ShxSpacing.sm),
)
}
/* ---- dead direct destinations the engine keeps refusing ---- */
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>())
{
while (true)
{
val raw = try
{
withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() }
}
catch (_: Throwable)
{
null
}
value = HomeViewModel.parseDeadTargets(raw)
delay(5_000)
}
}
if (deadTargets.isNotEmpty())
{
ShxSection(title = stringResource(R.string.dead_targets_title))
{
Text(
text = stringResource(R.string.dead_targets_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 6.dp),
ruEnabled = ruState.enabled,
ruState = ruState,
directPackages = directPackages,
forceCount = config.forceTunnelPrefixes.size,
patternCount = config.packagePatterns.size,
deadCount = deadTargets.size,
onMasterToggle = manager::setGlobalEnabled,
onRuToggle = ruList::setEnabled,
onRuMode = ruList::setMode,
onRefresh = { refreshGeoip() },
onOpenApps = { onNavigate(RoutingPage.APPS) },
onAddAllRf = manager::enableAllRfApps,
onOpenAdvanced = {
onNavigate(
when (it)
{
AdvancedPage.FORCE -> RoutingPage.FORCE
AdvancedPage.PATTERNS -> RoutingPage.PATTERNS
AdvancedPage.DEAD -> RoutingPage.DEAD
}
)
deadTargets.forEachIndexed { index, target ->
ShxSectionDivider()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
) {
Box(
modifier = Modifier
.size(8.dp)
.background(MaterialTheme.colorScheme.error, CircleShape),
)
Column(Modifier.weight(1f)) {
Text(
text = target.dst,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
)
Text(
text = listOfNotNull(
target.owner,
stringResource(R.string.dead_targets_fails_fmt, target.fails),
stringResource(
R.string.dead_targets_cooldown_fmt,
formatDuration(target.leftMs / 1000),
),
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
/* ---- RU networks direct: geoip list + routing mode ---- */
ShxSection(title = stringResource(R.string.routing_section_ru))
{
RuToggleRow(
title = stringResource(R.string.rusplit_toggle_title),
subtitle = stringResource(R.string.rusplit_toggle_summary),
checked = ruState.enabled,
updating = ruState.status == RuIpListStatus.UPDATING,
onCheckedChange = { ruList.setEnabled(it) },
onUpdate = { refreshGeoip() },
)
}
/* ---- rule sets ---- */
ShxSection(title = stringResource(R.string.routing_rules_section))
{
if (config.enabled)
{
RfActionRow(onClick = { manager.applyRfBundle() })
if (config.ruleSets.isNotEmpty())
{
ShxSectionDivider()
}
}
if (config.ruleSets.isEmpty())
{
ShxEmptyState(
icon = Icons.Rounded.AltRoute,
message = stringResource(R.string.routing_empty),
actionLabel = stringResource(R.string.routing_add_rule),
onAction = { addNewSet() },
)
}
else
{
config.ruleSets.forEachIndexed { index, ruleSet ->
RuleSetRow(
ruleSet = ruleSet,
onToggle = { manager.setRuleSetEnabled(ruleSet.id, it) },
onOpen = { editingId = ruleSet.id; focusName = false },
)
if (index < config.ruleSets.lastIndex)
{
ShxSectionDivider()
}
}
ShxSectionDivider()
AddRuleRow(onClick = { addNewSet() })
}
}
/* ---- advanced: userspace mode, GeoIP list, force-tunnel prefixes ---- */
var advancedOpen by rememberSaveable { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { advancedOpen = !advancedOpen }
.padding(horizontal = ShxSpacing.lg + ShxSpacing.lg, vertical = ShxSpacing.lg),
)
{
Text(
text = stringResource(R.string.routing_advanced).uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f).semantics { heading() },
)
Icon(
if (advancedOpen) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AnimatedVisibility(visible = advancedOpen)
{
Column {
if (ruState.enabled)
{
ShxSection(title = stringResource(R.string.routing_section_ru))
{
if (ruState.enabled)
{
RuModeSelector(
mode = ruState.mode,
onModeChange = { ruList.setMode(it) },
)
if (ruState.status == RuIpListStatus.UPDATING)
{
LinearWavyProgressIndicator(
Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.xs),
)
}
GeoipChipsRow(state = ruState)
RuDetailsDisclosure(mode = ruState.mode)
}
}
}
/* ---- force-tunnel prefixes: always through the tunnel ---- */
ShxSection(title = stringResource(R.string.routing_force_section))
{
if (config.forceTunnelPrefixes.isEmpty())
{
Text(
text = stringResource(R.string.routing_force_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 6.dp),
)
}
else
{
config.forceTunnelPrefixes.forEachIndexed { index, entry ->
ForcePrefixRow(
entry = entry,
onToggle = { manager.setForcePrefixEnabled(entry.id, it) },
onEdit = { forceDialogId = entry.id },
onDelete = { manager.deleteForcePrefix(entry.id) },
)
if (index < config.forceTunnelPrefixes.lastIndex)
{
ShxSectionDivider()
}
}
}
ShxSectionDivider()
AddForcePrefixRow(onClick = { forceDialogId = "" })
}
}
}
}
/* ---- rule set editor, hosted outside the scrolling column ---- */
val editing = editingId?.let { id -> config.ruleSets.firstOrNull { it.id == id } }
if (editing != null)
{
RuleSetEditorSheet(
ruleSet = editing,
requestNameFocus = focusName,
onDismiss = { editingId = null; focusName = false },
onRename = { manager.updateRuleSetName(editing.id, it) },
onPickApps = {
pickTarget = editing.id
val intent = Intent(context, SelectedApplicationsActivity::class.java)
intent.putStringArrayListExtra(
VpnProfileDataSource.KEY_SELECTED_APPS_LIST,
ArrayList(editing.packages),
)
pickApps.launch(intent)
},
onTogglePreset = { manager.togglePreset(editing.id, it) },
onAddPrefix = { prefix ->
manager.updateConfig { cfg ->
cfg.copy(ruleSets = cfg.ruleSets.map {
if (it.id == editing.id) it.copy(prefixes = it.prefixes + prefix) else it
})
}
},
onRemovePrefix = { prefix ->
manager.updateConfig { cfg ->
cfg.copy(ruleSets = cfg.ruleSets.map {
if (it.id == editing.id) it.copy(prefixes = it.prefixes - prefix) else it
})
}
},
onAddKeyword = { keyword ->
manager.updateConfig { cfg ->
cfg.copy(ruleSets = cfg.ruleSets.map {
if (it.id == editing.id) it.copy(keywords = it.keywords + keyword.lowercase()) else it
})
}
},
onRemoveKeyword = { keyword ->
manager.updateConfig { cfg ->
cfg.copy(ruleSets = cfg.ruleSets.map {
if (it.id == editing.id) it.copy(keywords = it.keywords - keyword) else it
})
}
},
onDelete = {
deleteSetWithUndo(editing)
editingId = null
focusName = false
},
)
}
/* ---- force prefix add/edit dialog, hosted outside the scrolling column ---- */
/* ---- dialogs, hosted outside the scrolling pages ---- */
if (forceDialogId != null)
{
val initial = if (forceDialogId == "")
@@ -557,4 +266,137 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
onDismiss = { forceDialogId = null },
)
}
if (patternDialog)
{
PatternDialog(onConfirm = { manager.addPattern(it) }, onDismiss = { patternDialog = false })
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MainPage(
enabled: Boolean,
ruEnabled: Boolean,
ruState: org.strongswan.android.logic.routing.RuIpListState,
directPackages: List<String>,
forceCount: Int,
patternCount: Int,
deadCount: Int,
onMasterToggle: (Boolean) -> Unit,
onRuToggle: (Boolean) -> Unit,
onRuMode: (String) -> Unit,
onRefresh: () -> Unit,
onOpenApps: () -> Unit,
onAddAllRf: () -> Unit,
onOpenAdvanced: (AdvancedPage) -> Unit,
)
{
val context = LocalContext.current
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = ShxSpacing.xl),
verticalArrangement = Arrangement.spacedBy(ShxSpacing.md),
)
{
TopAppBar(
title = { Text(stringResource(R.string.routing_title), fontWeight = FontWeight.SemiBold) },
actions = {
IconButton(onClick = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) })
{
Icon(
Icons.Rounded.Route,
contentDescription = stringResource(R.string.route_check_title),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
/* ---- hero: master switch + one-line summary ---- */
RoutingHeroCard(
enabled = enabled,
summary = routingSummary(enabled, ruEnabled, directPackages.size),
onToggle = onMasterToggle,
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
)
/* ---- Russian sites ---- */
SectionTitle(stringResource(R.string.routing_section_ru))
RuSitesCard(
state = ruState,
onToggle = onRuToggle,
onModeChange = onRuMode,
onRefresh = onRefresh,
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
)
/* ---- apps ---- */
SectionTitle(stringResource(R.string.routing_section_apps))
DirectAppsCard(
count = directPackages.size,
previewPackages = directPackages,
onOpen = onOpenApps,
onAddAllRf = onAddAllRf,
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
)
/* kill switch + direct apps: the direct apps are added to the VPN's disallowed list
* (CharonVpnService.addDisallowedApplication), so the system lockdown does not cover
* them and they keep using the normal network when the tunnel drops. The RU-sites part
* is routed by IP *inside* the VPN's scope (the RU ranges are simply left out of its
* routes), so under lockdown it is blocked together with everything else. */
if (enabled && directPackages.isNotEmpty() && remember { context.isVpnLockdownEnabled() })
{
KillSwitchNote()
}
AdvancedSection(
forceCount = forceCount,
patternCount = patternCount,
deadCount = deadCount,
onOpen = onOpenAdvanced,
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
)
}
}
@Composable
private fun SectionTitle(text: String)
{
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(start = ShxSpacing.lg + ShxSpacing.lg, top = ShxSpacing.md)
.semantics { heading() },
)
}
@Composable
private fun KillSwitchNote()
{
Row(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
modifier = Modifier
.padding(horizontal = ShxSpacing.lg)
.fillMaxWidth(),
)
{
Icon(
Icons.Rounded.Shield,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.routing_killswitch_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -1,359 +0,0 @@
/*
* 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.
*/
package org.strongswan.android.ui.compose
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.Category
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingPresets
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.theme.ShxSpacing
/**
* Editor of one rule set as a modal bottom sheet. The preset picker is a
* second view inside the same sheet (back returns to the editor) so that two
* sheets never stack on top of each other.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RuleSetEditorSheet(
ruleSet: RuleSet,
requestNameFocus: Boolean,
onDismiss: () -> Unit,
onRename: (String) -> Unit,
onPickApps: () -> Unit,
onTogglePreset: (RoutingPresets.Preset) -> Unit,
onAddPrefix: (String) -> Unit,
onRemovePrefix: (String) -> Unit,
onAddKeyword: (String) -> Unit,
onRemoveKeyword: (String) -> Unit,
onDelete: () -> Unit,
)
{
var showPresets by remember { mutableStateOf(false) }
BackHandler(enabled = showPresets) { showPresets = false }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
)
{
AnimatedContent(
targetState = showPresets,
transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(120)) },
label = "rule-set-editor",
)
{ presetsVisible ->
if (presetsVisible)
{
PresetPickerContent(
ruleSet = ruleSet,
onToggle = onTogglePreset,
onBack = { showPresets = false },
)
}
else
{
EditorContent(
ruleSet = ruleSet,
requestNameFocus = requestNameFocus,
onDismiss = onDismiss,
onRename = onRename,
onPickApps = onPickApps,
onOpenPresets = { showPresets = true },
onAddPrefix = onAddPrefix,
onRemovePrefix = onRemovePrefix,
onAddKeyword = onAddKeyword,
onRemoveKeyword = onRemoveKeyword,
onDelete = onDelete,
)
}
}
}
}
@Composable
private fun EditorContent(
ruleSet: RuleSet,
requestNameFocus: Boolean,
onDismiss: () -> Unit,
onRename: (String) -> Unit,
onPickApps: () -> Unit,
onOpenPresets: () -> Unit,
onAddPrefix: (String) -> Unit,
onRemovePrefix: (String) -> Unit,
onAddKeyword: (String) -> Unit,
onRemoveKeyword: (String) -> Unit,
onDelete: () -> Unit,
)
{
val nameFocusRequester = remember { FocusRequester() }
LaunchedEffect(ruleSet.id, requestNameFocus)
{
if (requestNameFocus)
{
nameFocusRequester.requestFocus()
}
}
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = ShxSpacing.xl),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = ShxSpacing.xl, end = ShxSpacing.md, bottom = ShxSpacing.sm),
)
{
Text(
text = stringResource(R.string.routing_editor_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss)
{
Text(stringResource(R.string.action_done))
}
}
OutlinedTextField(
value = ruleSet.name,
onValueChange = onRename,
label = { Text(stringResource(R.string.routing_rule_name)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.xl)
.focusRequester(nameFocusRequester),
shape = MaterialTheme.shapes.medium,
)
EditorRow(
icon = Icons.Rounded.Apps,
title = stringResource(R.string.routing_pick_apps),
subtitle = stringResource(R.string.routing_apps_count, ruleSet.packages.size),
onClick = onPickApps,
)
EditorRow(
icon = Icons.Rounded.Category,
title = stringResource(R.string.routing_presets_selected, countSelectedPresets(ruleSet)),
subtitle = stringResource(R.string.routing_presets_hint),
onClick = onOpenPresets,
)
ChipGroup(
label = stringResource(R.string.routing_prefixes_label),
values = ruleSet.prefixes.sorted(),
text = { it },
onRemove = onRemovePrefix,
addLabel = stringResource(R.string.routing_add_prefix),
onAdd = onAddPrefix,
)
ChipGroup(
label = stringResource(R.string.routing_keywords_label),
values = ruleSet.keywords.sorted(),
text = { "*$it*" },
onRemove = onRemoveKeyword,
addLabel = stringResource(R.string.routing_add_keyword),
onAdd = onAddKeyword,
)
Button(
onClick = onDelete,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.md),
)
{
Icon(
Icons.Rounded.Delete,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(ShxSpacing.sm))
Text(stringResource(R.string.routing_delete))
}
}
}
/** Labeled chip group with trailing add field, shared by prefixes and keywords. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ChipGroup(
label: String,
values: List<String>,
text: (String) -> String,
onRemove: (String) -> Unit,
addLabel: String,
onAdd: (String) -> Unit,
)
{
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = ShxSpacing.xl, top = ShxSpacing.lg),
)
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.xs),
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
verticalArrangement = Arrangement.spacedBy(ShxSpacing.xs),
)
{
values.forEach { value ->
AssistChip(
onClick = { onRemove(value) },
label = { Text(text(value), maxLines = 1) },
trailingIcon = {
Icon(Icons.Rounded.Close, contentDescription = null, modifier = Modifier.size(16.dp))
},
)
}
}
AddChipField(label = addLabel, onAdd = onAdd)
}
/** Editor row: badge, title/subtitle, chevron. */
@Composable
private fun EditorRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = ShxSpacing.xl, vertical = 14.dp),
)
{
ShxIconBadge(icon)
Spacer(Modifier.width(ShxSpacing.lg))
Column(Modifier.weight(1f))
{
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** Compact input with an add action; commits on the IME "done" or the trailing button. */
@Composable
private fun AddChipField(
label: String,
onAdd: (String) -> Unit,
)
{
var value by remember { mutableStateOf("") }
val commit = {
if (value.isNotBlank())
{
onAdd(value.trim())
value = ""
}
}
OutlinedTextField(
value = value,
onValueChange = { value = it },
label = { Text(label) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { commit() }),
trailingIcon = {
IconButton(onClick = { commit() }, enabled = value.isNotBlank())
{
Icon(Icons.Rounded.Add, contentDescription = null)
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.xs),
shape = MaterialTheme.shapes.medium,
)
}
/** A preset counts as selected when all of its patterns are in the set. */
private fun countSelectedPresets(ruleSet: RuleSet): Int =
RoutingPresets.RF_BUNDLE.count { preset ->
ruleSet.prefixes.containsAll(preset.prefixes) && ruleSet.keywords.containsAll(preset.keywords)
}
@@ -44,7 +44,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.layout.Box
@@ -73,6 +75,7 @@ import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.TrustedCertificateManager
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.ui.LogActivity
import org.strongswan.android.ui.VpnProfileDetailActivity
import org.strongswan.android.ui.compose.theme.ShxSpacing
@@ -115,6 +118,37 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
viewModel.messages.collect { snackbarHostState.showSnackbar(context.getString(it)) }
}
/* open-tab requests from other activities (profile form -> Routing) */
val requestedTab by ShxNavigation.requestedTab.collectAsStateWithLifecycle()
LaunchedEffect(requestedTab)
{
requestedTab?.let {
viewModel.setTab(it)
ShxNavigation.consume()
}
}
/* one-time notice after the 2.16 migration of "only selected apps" profiles */
val routingManager = remember { RoutingManager.getInstance(context) }
val routingState by routingManager.state.collectAsStateWithLifecycle()
LaunchedEffect(routingState.migratedOnlyProfiles)
{
if (routingState.migratedOnlyProfiles > 0)
{
val result = snackbarHostState.showSnackbar(
message = context.getString(R.string.routing_migrated_only),
actionLabel = context.getString(R.string.dead_targets_open),
duration = SnackbarDuration.Indefinite,
withDismissAction = true,
)
routingManager.consumeMigrationNotice()
if (result == SnackbarResult.ActionPerformed)
{
viewModel.setTab(ShxTab.ROUTING)
}
}
}
/* the home list keeps its scroll state across tab switches */
val homeListState = rememberLazyListState()
@@ -0,0 +1,38 @@
/*
* 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.ui.compose
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* One-shot "open this tab" request from outside the main activity's Compose
* tree (e.g. the profile form's link to the Routing tab). ShxApp consumes it.
*/
object ShxNavigation
{
private val _requestedTab = MutableStateFlow<ShxTab?>(null)
val requestedTab: StateFlow<ShxTab?> = _requestedTab
fun request(tab: ShxTab)
{
_requestedTab.value = tab
}
fun consume()
{
_requestedTab.value = null
}
}
@@ -4,324 +4,401 @@
* 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.
* 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.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose.routing
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import android.app.Activity
import android.content.Intent
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.Block
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Lan
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Route
import androidx.compose.material.icons.rounded.Rule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.routing.ForceTunnelPrefix
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.logic.routing.RuIpListState
import org.strongswan.android.logic.routing.RuIpListStatus
import org.strongswan.android.utils.Constants
import org.strongswan.android.ui.RouteCheckActivity
import org.strongswan.android.ui.SelectedApplicationsActivity
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.DeadTargetUi
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.formatDuration
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Geoip toggle row with a refresh/update slot in front of the switch. */
/** Sub pages of the "Advanced" section. */
enum class AdvancedPage { FORCE, PATTERNS, DEAD }
/** One entry of the advanced list: title, hint, count badge and a chevron. */
@Composable
internal fun RuToggleRow(
title: String,
subtitle: String,
checked: Boolean,
updating: Boolean,
onCheckedChange: (Boolean) -> Unit,
onUpdate: () -> Unit,
)
private fun AdvancedRow(title: String, subtitle: String, count: Int, warn: Boolean, onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onCheckedChange(!checked) }
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
.clickable(onClick = onClick)
.heightIn(min = 56.dp)
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
{
ShxIconBadge(Icons.Rounded.Block)
Spacer(Modifier.width(ShxSpacing.lg))
Column(Modifier.weight(1f))
{
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (updating)
Spacer(Modifier.width(ShxSpacing.md))
Surface(
shape = MaterialTheme.shapes.small,
color = if (warn) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = if (warn) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant,
)
{
LoadingIndicator(Modifier.size(14.dp))
Spacer(Modifier.width(ShxSpacing.md))
Text(
text = count.toString(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
else
{
IconButton(onClick = onUpdate)
Icon(
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** Collapsible "Advanced": always-via-VPN addresses, package templates and unreachable hosts. */
@Composable
internal fun AdvancedSection(
forceCount: Int,
patternCount: Int,
deadCount: Int,
onOpen: (AdvancedPage) -> Unit,
modifier: Modifier = Modifier,
)
{
var open by rememberSaveable { mutableStateOf(false) }
val rotation by animateFloatAsState(if (open) 180f else 0f, label = "advancedChevron")
ShxCard(modifier = modifier.fillMaxWidth())
{
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.toggleable(value = open, role = Role.Button, onValueChange = { open = it })
.heightIn(min = 64.dp)
.padding(ShxSpacing.lg),
)
{
Column(Modifier.weight(1f))
{
Text(
text = stringResource(R.string.routing_advanced),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
modifier = Modifier.semantics { heading() },
)
Text(
text = stringResource(R.string.routing_advanced_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.rusplit_update),
Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.rotate(rotation),
)
}
AnimatedVisibility(visible = open)
{
Column(Modifier.padding(bottom = ShxSpacing.sm))
{
AdvancedRow(
title = stringResource(R.string.routing_force_section),
subtitle = stringResource(R.string.routing_adv_force_sub),
count = forceCount,
warn = false,
onClick = { onOpen(AdvancedPage.FORCE) },
)
AdvancedRow(
title = stringResource(R.string.routing_patterns_title),
subtitle = stringResource(R.string.routing_adv_patterns_sub),
count = patternCount,
warn = false,
onClick = { onOpen(AdvancedPage.PATTERNS) },
)
AdvancedRow(
title = stringResource(R.string.dead_targets_title),
subtitle = stringResource(R.string.routing_adv_dead_sub),
count = deadCount,
warn = deadCount > 0,
onClick = { onOpen(AdvancedPage.DEAD) },
)
}
}
}
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
/**
* Selector for how the RU split is routed: OS routes with the coarsened
* complement (as before) or per-packet classification in native code.
*/
/* ------------------------------ sub pages ------------------------------ */
@Composable
internal fun RuModeSelector(mode: String, onModeChange: (String) -> Unit)
private fun SubPage(title: String, onBack: () -> Unit, content: @Composable () -> Unit)
{
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
SecondaryScaffold(title = title, onBack = onBack) { padding ->
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = ShxSpacing.lg)
.padding(bottom = ShxSpacing.xl),
)
{
content()
}
}
}
@Composable
private fun Hint(text: String)
{
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.xs, vertical = ShxSpacing.md),
)
}
/** "Always via VPN": addresses that never go direct, even inside the RU ranges. */
@Composable
internal fun ForcePrefixPage(
prefixes: List<ForceTunnelPrefix>,
onToggle: (String, Boolean) -> Unit,
onEdit: (String) -> Unit,
onDelete: (String) -> Unit,
onAdd: () -> Unit,
onBack: () -> Unit,
)
{
SubPage(stringResource(R.string.routing_force_section), onBack)
{
SegmentedButton(
selected = mode == Constants.RUSPLIT_MODE_SYSTEM,
onClick = { onModeChange(Constants.RUSPLIT_MODE_SYSTEM) },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
)
Hint(stringResource(R.string.routing_force_summary))
ShxCard(Modifier.fillMaxWidth())
{
Text(stringResource(R.string.rusplit_mode_system))
}
SegmentedButton(
selected = mode == Constants.RUSPLIT_MODE_USERSPACE,
onClick = { onModeChange(Constants.RUSPLIT_MODE_USERSPACE) },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
)
{
Text(stringResource(R.string.rusplit_mode_userspace_short))
Column {
prefixes.forEach { entry ->
ForcePrefixRow(
entry = entry,
onToggle = { onToggle(entry.id, it) },
onEdit = { onEdit(entry.id) },
onDelete = { onDelete(entry.id) },
)
ShxSectionDivider()
}
AddRow(stringResource(R.string.routing_force_add), onAdd)
}
}
}
}
/** Mode description, list source and beta note behind a "Details" disclosure. */
/** "Package templates": patterns like ru.* or *yandex* that select apps by package name. */
@Composable
internal fun RuDetailsDisclosure(mode: String)
internal fun PatternsPage(
patterns: List<String>,
onDelete: (String) -> Unit,
onAdd: () -> Unit,
onBack: () -> Unit,
)
{
SubPage(stringResource(R.string.routing_patterns_title), onBack)
{
Hint(stringResource(R.string.routing_patterns_hint))
ShxCard(Modifier.fillMaxWidth())
{
Column {
if (patterns.isEmpty())
{
Text(
text = stringResource(R.string.routing_patterns_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(ShxSpacing.lg),
)
ShxSectionDivider()
}
patterns.forEach { pattern ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(start = ShxSpacing.lg, end = ShxSpacing.sm),
)
{
Text(
text = pattern,
style = MaterialTheme.typography.bodyLarge,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { onDelete(pattern) })
{
Icon(
Icons.Rounded.Delete,
contentDescription = stringResource(R.string.routing_patterns_delete),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
ShxSectionDivider(indent = 0.dp)
}
AddRow(stringResource(R.string.routing_patterns_add), onAdd)
}
}
}
}
/** "Unreachable hosts": direct destinations the engine keeps refusing. */
@Composable
internal fun DeadHostsPage(targets: List<DeadTargetUi>, onBack: () -> Unit)
{
SubPage(stringResource(R.string.dead_targets_title), onBack)
{
Hint(stringResource(R.string.dead_targets_summary))
if (targets.isNotEmpty())
{
ShxCard(Modifier.fillMaxWidth())
{
Column {
targets.forEachIndexed { index, target ->
if (index > 0)
{
ShxSectionDivider(indent = 0.dp)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
)
{
Box(
modifier = Modifier
.size(8.dp)
.background(MaterialTheme.colorScheme.error, CircleShape),
)
Column(Modifier.weight(1f))
{
Text(
text = target.dst,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
)
Text(
text = listOfNotNull(
target.owner,
stringResource(R.string.dead_targets_fails_fmt, target.fails),
stringResource(R.string.dead_targets_cooldown_fmt, formatDuration(target.leftMs / 1000)),
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
@Composable
private fun AddRow(label: String, onClick: () -> Unit)
{
var expanded by rememberSaveable { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
.clickable(onClick = onClick)
.heightIn(min = 56.dp)
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
{
Text(
text = stringResource(if (expanded) R.string.routing_details_less else R.string.routing_details_more),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(ShxSpacing.xs))
Icon(
if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
modifier = Modifier.size(22.dp),
)
}
AnimatedVisibility(
visible = expanded,
enter = expandVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow)) +
fadeIn(tween(120)),
exit = shrinkVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMedium)) +
fadeOut(tween(90)),
)
{
Column(Modifier.padding(start = ShxSpacing.lg, end = ShxSpacing.lg, bottom = ShxSpacing.md))
{
Text(
text = stringResource(
if (mode == Constants.RUSPLIT_MODE_USERSPACE)
{
R.string.rusplit_mode_userspace_desc
}
else
{
R.string.rusplit_mode_system_desc
}
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.rusplit_source),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
Text(
text = stringResource(R.string.rusplit_beta_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = ShxSpacing.xs),
)
}
}
}
/** Freshness/count chips of the geoip list. */
@Composable
internal fun GeoipChipsRow(state: RuIpListState)
{
val status = LocalShxStatusColors.current
val fresh = state.lastUpdatedMs > 0L &&
System.currentTimeMillis() - state.lastUpdatedMs < 26 * 60 * 60 * 1000L
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(start = ShxSpacing.lg, end = ShxSpacing.lg, bottom = ShxSpacing.xs),
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
verticalArrangement = Arrangement.spacedBy(ShxSpacing.xs),
)
{
InfoChip(text = stringResource(R.string.routing_geoip_ipv4, state.ipv4Ranges))
InfoChip(text = stringResource(R.string.routing_geoip_ipv6, state.ipv6Ranges))
val freshnessText = when
{
state.lastUpdatedMs > 0L ->
stringResource(
R.string.routing_geoip_updated,
DateUtils.getRelativeTimeSpanString(state.lastUpdatedMs).toString(),
)
else -> stringResource(R.string.routing_geoip_never)
}
InfoChip(
text = freshnessText,
highlight = if (state.lastUpdatedMs > 0L && fresh) status.connected else null,
)
}
}
/** Small rounded info pill used across the screen. */
@Composable
internal fun InfoChip(text: String, highlight: Color? = null)
{
Surface(
shape = RoundedCornerShape(8.dp),
color = if (highlight != null) highlight.copy(alpha = 0.14f) else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = highlight ?: MaterialTheme.colorScheme.onSurfaceVariant,
)
{
Spacer(Modifier.width(ShxSpacing.lg))
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
@@ -368,44 +445,53 @@ internal fun ForcePrefixRow(
}
}
/** Trailing "add force-tunnel prefix" row. */
@Composable
internal fun AddForcePrefixRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
{
Icon(
Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(ShxSpacing.lg))
Text(
stringResource(R.string.routing_force_add),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
/** Add/edit dialog for a force-tunnel prefix; shows an error until valid. */
@Composable
internal fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean, onDismiss: () -> Unit)
{
TextInputDialog(
title = stringResource(R.string.routing_force_dialog_title),
hint = stringResource(R.string.routing_force_hint),
errorText = stringResource(R.string.routing_force_invalid),
confirmLabel = stringResource(R.string.routing_force_save),
initial = initial,
onConfirm = onConfirm,
onDismiss = onDismiss,
)
}
/** Add dialog for a package template. */
@Composable
internal fun PatternDialog(onConfirm: (String) -> Boolean, onDismiss: () -> Unit)
{
TextInputDialog(
title = stringResource(R.string.routing_patterns_dialog_title),
hint = stringResource(R.string.routing_patterns_input_hint),
errorText = stringResource(R.string.routing_patterns_invalid),
confirmLabel = stringResource(R.string.routing_force_save),
initial = "",
onConfirm = onConfirm,
onDismiss = onDismiss,
)
}
@Composable
private fun TextInputDialog(
title: String,
hint: String,
errorText: String,
confirmLabel: String,
initial: String,
onConfirm: (String) -> Boolean,
onDismiss: () -> Unit,
)
{
var text by remember { mutableStateOf(initial) }
var error by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.routing_force_dialog_title)) },
title = { Text(title) },
text = {
OutlinedTextField(
value = text,
@@ -415,10 +501,10 @@ internal fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean,
},
singleLine = true,
isError = error,
label = { Text(stringResource(R.string.routing_force_hint)) },
label = { Text(hint) },
supportingText = if (error)
{
{ Text(stringResource(R.string.routing_force_invalid)) }
{ Text(errorText) }
}
else
{
@@ -438,7 +524,7 @@ internal fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean,
}
})
{
Text(stringResource(R.string.routing_force_save))
Text(confirmLabel)
}
},
dismissButton = {
@@ -0,0 +1,185 @@
/*
* 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.ui.compose.routing
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.theme.ShxSpacing
private const val STACK_ICONS = 3
/**
* "Apps direct" entry card: counter, a stack of real app icons and a chevron
* into the full list. While the list is empty it offers the one-tap preset
* for Russian apps instead.
*
* @param previewPackages a few direct packages for the icon stack (the rest is a "+N" bubble)
*/
@Composable
internal fun DirectAppsCard(
count: Int,
previewPackages: List<String>,
onOpen: () -> Unit,
onAddAllRf: () -> Unit,
modifier: Modifier = Modifier,
)
{
ShxCard(modifier = modifier.fillMaxWidth())
{
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpen)
.heightIn(min = 72.dp)
.padding(ShxSpacing.lg),
)
{
ShxIconBadge(Icons.Rounded.Apps)
Column(
Modifier
.weight(1f)
.padding(horizontal = ShxSpacing.lg),
)
{
Text(
text = stringResource(R.string.routing_apps_direct_title),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
text = if (count > 0)
{
pluralStringResource(R.plurals.routing_apps_direct_summary, count, count)
}
else
{
stringResource(R.string.routing_apps_direct_empty)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (count > 0)
{
IconStack(previewPackages.take(STACK_ICONS), extra = count - previewPackages.take(STACK_ICONS).size)
Spacer(Modifier.width(ShxSpacing.sm))
}
Icon(
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (count == 0)
{
FlowRow(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = Modifier.padding(start = 72.dp, end = ShxSpacing.lg, bottom = ShxSpacing.lg),
)
{
Button(onClick = onAddAllRf)
{
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.routing_apps_add_all_rf))
}
OutlinedButton(onClick = onOpen)
{
Text(stringResource(R.string.routing_apps_pick_manually))
}
}
}
}
}
}
/** Overlapping app icons with a "+N" bubble for the rest. */
@Composable
private fun IconStack(packages: List<String>, extra: Int)
{
val ring = MaterialTheme.colorScheme.surfaceContainerLow
Row(verticalAlignment = Alignment.CenterVertically)
{
packages.forEachIndexed { index, pkg ->
Box(
Modifier
.offset(x = (-8 * index).dp)
.size(28.dp)
.border(BorderStroke(2.dp, ring), CircleShape)
.padding(2.dp)
.clip(CircleShape),
)
{
AppIcon(pkg, 24.dp)
}
}
if (extra > 0)
{
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.offset(x = (-8 * packages.size).dp)
.heightIn(min = 28.dp),
)
{
Text(
text = "+$extra",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 5.dp),
)
}
}
}
}
@@ -0,0 +1,392 @@
/*
* 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.ui.compose.routing
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import org.strongswan.android.R
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** "Apps direct": every installed internet app with a switch, grouped by category. */
@Composable
fun DirectAppsScreen(onBack: () -> Unit, viewModel: DirectAppsViewModel = viewModel())
{
val state by viewModel.ui.collectAsStateWithLifecycle()
androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.refresh() }
DirectAppsContent(
state = state,
onBack = onBack,
onQuery = viewModel::setQuery,
onFilter = viewModel::setFilter,
onShowSystem = viewModel::setShowSystem,
onToggleApp = viewModel::setAppDirect,
onToggleCategory = viewModel::setCategoryEnabled,
onAllRf = viewModel::enableAllRf,
)
}
/** Stateless body of [DirectAppsScreen] (previewable and testable). */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun DirectAppsContent(
state: DirectAppsUiState,
onBack: () -> Unit,
onQuery: (String) -> Unit,
onFilter: (AppsFilter) -> Unit,
onShowSystem: (Boolean) -> Unit,
onToggleApp: (String, Boolean) -> Unit,
onToggleCategory: (String, Boolean) -> Unit,
onAllRf: () -> Unit,
)
{
SecondaryScaffold(title = stringResource(R.string.routing_apps_direct_title), onBack = onBack) { padding ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = padding.calculateTopPadding(),
bottom = padding.calculateBottomPadding() + ShxSpacing.xl,
),
)
{
item(key = "summary") {
Text(
text = pluralStringResource(R.plurals.routing_apps_direct_summary, state.directCount, state.directCount),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.lg + ShxSpacing.xs),
)
}
item(key = "search") {
SearchField(
query = state.query,
onQuery = onQuery,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
}
item(key = "filters") {
Row(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
)
{
FilterTab(
label = stringResource(R.string.apps_filter_direct, state.directCount),
selected = state.filter == AppsFilter.DIRECT,
onClick = { onFilter(AppsFilter.DIRECT) },
)
FilterTab(
label = stringResource(R.string.apps_filter_vpn, state.tunnelCount),
selected = state.filter == AppsFilter.VPN,
onClick = { onFilter(AppsFilter.VPN) },
)
FilterTab(
label = stringResource(R.string.apps_filter_all),
selected = state.filter == AppsFilter.ALL,
onClick = { onFilter(AppsFilter.ALL) },
)
}
}
item(key = "quick") {
Column(Modifier.padding(top = ShxSpacing.lg)) {
Text(
text = stringResource(R.string.apps_quick_add),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.lg + ShxSpacing.xs, vertical = ShxSpacing.xs),
)
Row(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = ShxSpacing.lg),
)
{
state.quickChips.forEach { chip ->
QuickChip(
label = stringResource(categoryChipRes(chip.category)),
done = chip.done,
onClick = { onToggleCategory(chip.category, !chip.done) },
)
}
QuickChip(
label = stringResource(R.string.routing_apps_add_all_rf),
done = state.allRfDone,
onClick = onAllRf,
)
}
}
}
if (state.loading)
{
item(key = "loading") {
Box(
Modifier
.fillMaxWidth()
.padding(ShxSpacing.xl),
contentAlignment = Alignment.Center,
)
{
LoadingIndicator(Modifier.size(40.dp))
}
}
}
else if (state.groups.isEmpty())
{
item(key = "empty") {
Text(
text = stringResource(R.string.apps_nothing_found),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 48.dp, horizontal = ShxSpacing.xl),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
else
{
state.groups.forEach { group ->
item(key = "h:${group.category}") {
Text(
text = stringResource(categoryTitleRes(group.category)).uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(start = ShxSpacing.lg + ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm)
.semantics { heading() },
)
}
itemsIndexed(group.rows, key = { _, row -> "a:${row.packageName}" }) { index, row ->
AppRow(
row = row,
first = index == 0,
last = index == group.rows.lastIndex,
onToggle = { onToggleApp(row.packageName, it) },
)
}
}
}
item(key = "system") {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.toggleable(value = state.showSystem, role = Role.Checkbox, onValueChange = onShowSystem)
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm),
)
{
Checkbox(checked = state.showSystem, onCheckedChange = null)
Text(
text = stringResource(R.string.apps_show_system),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = ShxSpacing.md),
)
}
}
}
}
}
@Composable
private fun SearchField(query: String, onQuery: (String) -> Unit, modifier: Modifier = Modifier)
{
TextField(
value = query,
onValueChange = onQuery,
singleLine = true,
placeholder = { Text(stringResource(R.string.apps_search)) },
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
trailingIcon = if (query.isNotEmpty())
{
{
IconButton(onClick = { onQuery("") }) {
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.apps_search_clear))
}
}
}
else
{
null
},
shape = CircleShape,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
disabledIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
errorIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
modifier = modifier.fillMaxWidth(),
)
}
@Composable
private fun FilterTab(label: String, selected: Boolean, onClick: () -> Unit)
{
FilterChip(
selected = selected,
onClick = onClick,
label = { Text(label, maxLines = 1) },
shape = MaterialTheme.shapes.small,
)
}
@Composable
private fun QuickChip(label: String, done: Boolean, onClick: () -> Unit)
{
val status = LocalShxStatusColors.current
FilterChip(
selected = done,
onClick = onClick,
label = { Text(label, maxLines = 1) },
leadingIcon = {
Icon(
if (done) Icons.Rounded.Check else Icons.Rounded.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
},
shape = CircleShape,
colors = androidx.compose.material3.FilterChipDefaults.filterChipColors(
selectedContainerColor = status.connectedContainer,
selectedLabelColor = status.onConnectedContainer,
selectedLeadingIconColor = status.onConnectedContainer,
),
)
}
@Composable
private fun AppRow(row: DirectAppRow, first: Boolean, last: Boolean, onToggle: (Boolean) -> Unit)
{
val status = LocalShxStatusColors.current
val corner = 20.dp
val shape = RoundedCornerShape(
topStart = if (first) corner else 0.dp,
topEnd = if (first) corner else 0.dp,
bottomStart = if (last) corner else 0.dp,
bottomEnd = if (last) corner else 0.dp,
)
val reasonText = when (val reason = row.reason)
{
AppReason.Added -> stringResource(R.string.apps_reason_added)
is AppReason.Template -> stringResource(R.string.apps_reason_template, templateLabel(reason.match))
AppReason.Tunnel -> stringResource(R.string.apps_reason_vpn)
is AppReason.Override -> stringResource(R.string.apps_reason_override, templateLabel(reason.match))
}
val reasonColor = when (row.reason)
{
is AppReason.Added, is AppReason.Template -> status.connected
is AppReason.Override -> MaterialTheme.colorScheme.tertiary
AppReason.Tunnel -> MaterialTheme.colorScheme.onSurfaceVariant
}
val switchDescription = stringResource(R.string.apps_switch_desc, row.label)
Surface(
shape = shape,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
.toggleable(value = row.direct, role = Role.Switch, onValueChange = onToggle)
.semantics(mergeDescendants = true) { contentDescription = switchDescription }
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
{
AppIcon(row.packageName, 40.dp)
Column(
Modifier
.weight(1f)
.padding(horizontal = ShxSpacing.lg),
)
{
Text(
text = row.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = reasonText,
style = MaterialTheme.typography.bodySmall,
color = reasonColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Switch(checked = row.direct, onCheckedChange = null)
}
}
}
@@ -0,0 +1,241 @@
/*
* 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.ui.compose.routing
import android.app.Application
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import androidx.compose.runtime.Immutable
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.logic.routing.RoutingConfig
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.logic.routing.RoutingPresets
import org.strongswan.android.logic.routing.TemplateMatch
import java.text.Collator
/** One installed app that can reach the network (holds INTERNET). */
@Immutable
data class InstalledApp(
val packageName: String,
val label: String,
/** A pure system app (not an updated one); hidden by default unless it is direct. */
val isSystem: Boolean,
)
enum class AppsFilter { DIRECT, VPN, ALL }
/** Why an app is in its current state; drives the grey/green line under the app name. */
@Immutable
sealed interface AppReason
{
/** Direct because the user switched it on. */
data object Added : AppReason
/** Direct because a category or a package template covers it. */
data class Template(val match: TemplateMatch) : AppReason
/** Goes through the VPN. */
data object Tunnel : AppReason
/** Goes through the VPN although a category or template would cover it. */
data class Override(val match: TemplateMatch) : AppReason
}
@Immutable
data class DirectAppRow(
val packageName: String,
val label: String,
val direct: Boolean,
val reason: AppReason,
)
/** Apps of one category; [category] null is the "Other" bucket. */
@Immutable
data class DirectAppsGroup(val category: String?, val rows: List<DirectAppRow>)
@Immutable
data class QuickChipState(val category: String, val done: Boolean)
@Immutable
data class DirectAppsUiState(
val loading: Boolean = true,
val groups: List<DirectAppsGroup> = emptyList(),
val directCount: Int = 0,
val tunnelCount: Int = 0,
val filter: AppsFilter = AppsFilter.DIRECT,
val query: String = "",
val showSystem: Boolean = false,
val quickChips: List<QuickChipState> = emptyList(),
/** Every category and the ru.* namespace are on. */
val allRfDone: Boolean = false,
)
/**
* State of the "Apps direct" screen: the installed internet apps (loaded off
* the main thread), joined with the routing config, grouped by category and
* filtered by search / tab. All heavy work runs on Dispatchers.Default.
*/
class DirectAppsViewModel(app: Application) : AndroidViewModel(app)
{
private val manager = RoutingManager.getInstance(app)
private val apps = MutableStateFlow<List<InstalledApp>?>(null)
private val query = MutableStateFlow("")
private val filter = MutableStateFlow(AppsFilter.DIRECT)
private val showSystem = MutableStateFlow(false)
val ui: StateFlow<DirectAppsUiState> = combine(
apps, manager.state, query, filter, showSystem,
) { installed, routing, q, f, system ->
DirectApps.buildUiState(installed, routing.config, q, f, system)
}
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DirectAppsUiState())
init
{
refresh()
}
/** (Re)loads the installed apps; the previous list stays visible meanwhile. */
fun refresh()
{
viewModelScope.launch {
apps.value = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(getApplication<Application>().packageManager, getApplication<Application>().packageName) }
}
}
fun setQuery(value: String)
{
query.value = value
}
fun setFilter(value: AppsFilter)
{
filter.value = value
}
fun setShowSystem(value: Boolean)
{
showSystem.value = value
}
fun setAppDirect(pkg: String, direct: Boolean) = manager.setAppDirect(pkg, direct)
fun setCategoryEnabled(category: String, enabled: Boolean) = manager.setCategoryEnabled(category, enabled)
fun enableAllRf() = manager.enableAllRfApps()
}
/** Pure parts of [DirectAppsViewModel]: loading and state building (unit tested). */
object DirectApps
{
/** All installed apps with the INTERNET permission, sorted by label; our own app is left out. */
fun loadInstalledApps(pm: PackageManager, ownPackage: String): List<InstalledApp>
{
val collator = Collator.getInstance()
return pm.getInstalledApplications(PackageManager.GET_META_DATA)
.asSequence()
.filter { it.packageName != ownPackage }
.filter { pm.checkPermission(android.Manifest.permission.INTERNET, it.packageName) == PackageManager.PERMISSION_GRANTED }
.map {
val flags = it.flags
val system = (flags and ApplicationInfo.FLAG_SYSTEM) != 0 && (flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) == 0
InstalledApp(it.packageName, it.loadLabel(pm)?.toString()?.takeIf { l -> l.isNotBlank() } ?: it.packageName, system)
}
.sortedWith { a, b -> collator.compare(a.label, b.label) }
.toList()
}
/** Pure state builder (also used by tests). */
fun buildUiState(
installed: List<InstalledApp>?,
config: RoutingConfig,
query: String,
filter: AppsFilter,
showSystem: Boolean,
): DirectAppsUiState
{
val quick = RoutingPresets.CATEGORIES.map { QuickChipState(it, it in config.enabledCategories) }
val allRf = RoutingPresets.ALL_CATEGORIES.all { it in config.enabledCategories }
if (installed == null)
{
return DirectAppsUiState(loading = true, filter = filter, query = query, showSystem = showSystem,
quickChips = quick, allRfDone = allRf)
}
val effective = RoutingManager.effectiveExcludes(config, installed.mapTo(HashSet()) { it.packageName })
val rows = installed.map { app ->
val direct = app.packageName in effective
val match = RoutingManager.templateMatch(app.packageName, config)
val reason = when
{
direct && app.packageName in config.directPackages -> AppReason.Added
direct && match != null -> AppReason.Template(match)
direct -> AppReason.Added
match != null && app.packageName in config.tunnelOverrides -> AppReason.Override(match)
else -> AppReason.Tunnel
}
app to DirectAppRow(app.packageName, app.label, direct, reason)
}
/* system apps are hidden behind the toggle, but a direct one always shows */
val visible = rows.filter { (app, row) -> showSystem || !app.isSystem || row.direct }
val q = query.trim()
val searched = if (q.isEmpty())
{
visible
}
else
{
visible.filter { (app, _) ->
app.label.contains(q, ignoreCase = true) || app.packageName.contains(q, ignoreCase = true)
}
}
val directCount = visible.count { it.second.direct }
val tunnelCount = visible.size - directCount
val shown = searched.filter { (_, row) ->
when (filter)
{
AppsFilter.DIRECT -> row.direct
AppsFilter.VPN -> !row.direct
AppsFilter.ALL -> true
}
}
val byCategory = shown.groupBy { (app, _) -> RoutingPresets.categoryOf(app.packageName) }
val groups = (RoutingPresets.CATEGORIES + listOf<String?>(null)).mapNotNull { category ->
byCategory[category]?.let { DirectAppsGroup(category, it.map { pair -> pair.second }) }
}
return DirectAppsUiState(
loading = false,
groups = groups,
directCount = directCount,
tunnelCount = tunnelCount,
filter = filter,
query = query,
showSystem = showSystem,
quickChips = quick,
allRfDone = allRf,
)
}
}
@@ -4,134 +4,74 @@
* 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.
* 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.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose.routing
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import android.app.Activity
import android.content.Intent
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.Block
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Lan
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Route
import androidx.compose.material.icons.rounded.Rule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.routing.ForceTunnelPrefix
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.logic.routing.RuIpListState
import org.strongswan.android.logic.routing.RuIpListStatus
import org.strongswan.android.utils.Constants
import org.strongswan.android.ui.RouteCheckActivity
import org.strongswan.android.ui.SelectedApplicationsActivity
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Hero card: master switch plus a live summary of what the rules currently affect. */
/** Plain-words summary of what currently bypasses the VPN. */
@Composable
internal fun routingSummary(enabled: Boolean, ruEnabled: Boolean, appCount: Int): String
{
if (!enabled)
{
return stringResource(R.string.routing_hero_off_summary)
}
val parts = ArrayList<String>()
if (ruEnabled)
{
parts.add(stringResource(R.string.routing_hero_part_sites))
}
if (appCount > 0)
{
parts.add(pluralStringResource(R.plurals.routing_apps_n, appCount, appCount))
}
return if (parts.isEmpty())
{
stringResource(R.string.routing_hero_nothing)
}
else
{
stringResource(R.string.routing_hero_summary, parts.joinToString(" · "))
}
}
/** Hero card: the master switch plus a one-line summary of what bypasses the VPN. */
@Composable
internal fun RoutingHeroCard(
enabled: Boolean,
setCount: Int,
appCount: Int,
summary: String,
onToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier,
)
@@ -146,7 +86,9 @@ internal fun RoutingHeroCard(
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = ShxSpacing.lg, top = 14.dp, bottom = 14.dp, end = ShxSpacing.sm),
modifier = Modifier
.toggleable(value = enabled, role = Role.Switch, onValueChange = onToggle)
.padding(start = ShxSpacing.lg, top = 14.dp, bottom = 14.dp, end = ShxSpacing.lg),
)
{
Icon(
@@ -158,23 +100,16 @@ internal fun RoutingHeroCard(
Column(Modifier.weight(1f))
{
Text(
text = stringResource(if (enabled) R.string.routing_hero_on else R.string.routing_hero_off),
text = stringResource(R.string.routing_hero_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = if (enabled)
{
stringResource(R.string.routing_hero_summary, setCount, appCount)
}
else
{
stringResource(R.string.routing_hero_off_summary)
},
style = MaterialTheme.typography.bodySmall,
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = if (enabled)
{
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f)
}
else
{
@@ -182,38 +117,8 @@ internal fun RoutingHeroCard(
},
)
}
Switch(checked = enabled, onCheckedChange = onToggle)
Spacer(Modifier.width(ShxSpacing.sm))
}
}
}
/** Quick action: create/enable rule sets for all RF bundle presets. */
@Composable
internal fun RfActionRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
{
ShxIconBadge(Icons.Rounded.Block)
Spacer(Modifier.width(ShxSpacing.lg))
Column(Modifier.weight(1f))
{
Text(
stringResource(R.string.routing_rf_quick_title),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
stringResource(R.string.routing_rf_quick_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(ShxSpacing.md))
Switch(checked = enabled, onCheckedChange = null)
}
}
}
@@ -0,0 +1,123 @@
/*
* 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.ui.compose.routing
import android.util.LruCache
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.logic.routing.RoutingPresets
import org.strongswan.android.logic.routing.TemplateMatch
/** Process-wide cache of decoded launcher icons, so scrolling a long list never re-decodes. */
private object AppIconCache
{
private val cache = object : LruCache<String, ImageBitmap>(256) {}
fun get(key: String): ImageBitmap? = cache.get(key)
fun put(key: String, value: ImageBitmap)
{
cache.put(key, value)
}
}
/** The launcher icon of an installed app, decoded off the main thread; a tinted square while loading. */
@Composable
fun AppIcon(packageName: String, size: Dp, modifier: Modifier = Modifier)
{
val context = LocalContext.current
val px = with(LocalDensity.current) { size.roundToPx() }
val key = "$packageName@$px"
val bitmap by produceState(initialValue = AppIconCache.get(key), key) {
if (value == null)
{
value = withContext(Dispatchers.IO) {
try
{
context.packageManager.getApplicationIcon(packageName).toBitmap(px, px).asImageBitmap()
.also { AppIconCache.put(key, it) }
}
catch (_: Exception)
{
null
}
}
}
}
val image = bitmap
if (image != null)
{
Image(bitmap = image, contentDescription = null, modifier = modifier.size(size))
}
else
{
Box(
modifier
.size(size)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
)
}
}
/** Short chip label of a built-in category. */
@StringRes
fun categoryChipRes(category: String): Int = when (category)
{
RoutingPresets.BANKS -> R.string.routing_cat_banks
RoutingPresets.GOV -> R.string.routing_cat_gov
RoutingPresets.SHOP -> R.string.routing_cat_shop
RoutingPresets.SOCIAL -> R.string.routing_cat_social
RoutingPresets.TRAVEL -> R.string.routing_cat_travel
RoutingPresets.SERVICES -> R.string.routing_cat_services
else -> R.string.routing_cat_namespace
}
/** Group heading of a category in the apps list (null = the "Other" bucket). */
@StringRes
fun categoryTitleRes(category: String?): Int = when (category)
{
RoutingPresets.BANKS -> R.string.routing_cat_banks_title
RoutingPresets.GOV -> R.string.routing_cat_gov_title
RoutingPresets.SHOP -> R.string.routing_cat_shop_title
RoutingPresets.SOCIAL -> R.string.routing_cat_social_title
RoutingPresets.TRAVEL -> R.string.routing_cat_travel_title
RoutingPresets.SERVICES -> R.string.routing_cat_services_title
else -> R.string.routing_cat_other_title
}
/** The name to show in "by template ..." for a match: the category label or the user's pattern. */
@Composable
fun templateLabel(match: TemplateMatch): String =
match.category?.let { stringResource(categoryChipRes(it)) } ?: match.pattern.orEmpty()
@@ -0,0 +1,288 @@
/*
* 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.ui.compose.routing
import android.text.format.DateUtils
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.logic.routing.RuIpListState
import org.strongswan.android.logic.routing.RuIpListStatus
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.utils.Constants
import java.text.NumberFormat
/**
* "Russian sites direct": the switch, the two ways to do it as selectable
* cards (right under the switch), a warning for the flexible mode and the
* freshness of the address database with a refresh button.
*/
@Composable
internal fun RuSitesCard(
state: RuIpListState,
onToggle: (Boolean) -> Unit,
onModeChange: (String) -> Unit,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
)
{
ShxCard(modifier = modifier.fillMaxWidth())
{
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(ShxSpacing.lg),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.toggleable(value = state.enabled, role = Role.Switch, onValueChange = onToggle),
)
{
ShxIconBadge(Icons.Rounded.Public)
Column(
Modifier
.weight(1f)
.padding(horizontal = ShxSpacing.lg),
)
{
Text(
text = stringResource(R.string.rusplit_toggle_title),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
text = stringResource(R.string.rusplit_toggle_summary),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = state.enabled, onCheckedChange = null)
}
AnimatedVisibility(
visible = state.enabled,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
)
{
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.md))
{
Text(
text = stringResource(R.string.routing_mode_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = Modifier.height(IntrinsicSize.Max),
)
{
val stable = state.mode != Constants.RUSPLIT_MODE_USERSPACE
ModeCard(
title = stringResource(R.string.routing_mode_stable),
description = stringResource(R.string.routing_mode_stable_desc),
selected = stable,
beta = false,
onClick = { onModeChange(Constants.RUSPLIT_MODE_SYSTEM) },
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
)
ModeCard(
title = stringResource(R.string.routing_mode_flexible),
description = stringResource(R.string.routing_mode_flexible_desc),
selected = !stable,
beta = true,
onClick = { onModeChange(Constants.RUSPLIT_MODE_USERSPACE) },
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
)
}
AnimatedVisibility(visible = state.mode == Constants.RUSPLIT_MODE_USERSPACE)
{
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
{
Row(Modifier.padding(ShxSpacing.md), horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Icon(Icons.Rounded.Warning, contentDescription = null, modifier = Modifier.size(18.dp))
Text(
text = stringResource(R.string.routing_mode_flexible_warning),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
DatabaseStatusRow(state = state, onRefresh = onRefresh)
}
}
}
}
}
@Composable
private fun ModeCard(
title: String,
description: String,
selected: Boolean,
beta: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
)
{
Surface(
shape = MaterialTheme.shapes.medium,
color = if (selected) MaterialTheme.colorScheme.primaryContainer else androidx.compose.ui.graphics.Color.Transparent,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
border = if (selected)
{
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
}
else
{
BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
},
modifier = modifier.selectable(selected = selected, role = Role.RadioButton, onClick = onClick),
)
{
Column(Modifier.padding(ShxSpacing.md), verticalArrangement = Arrangement.spacedBy(6.dp))
{
Row(verticalAlignment = Alignment.CenterVertically)
{
RadioButton(selected = selected, onClick = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(ShxSpacing.sm))
/* FlowRow: on narrow cards the BETA badge drops below the title
* as a whole instead of wrapping letter by letter */
androidx.compose.foundation.layout.FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
)
{
Text(text = title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
if (beta)
{
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
)
{
Text(
text = stringResource(R.string.routing_mode_beta),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
softWrap = false,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
}
}
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** "Address database: N networks - updated 3 h ago" with the refresh button. */
@OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun DatabaseStatusRow(state: RuIpListState, onRefresh: () -> Unit)
{
val text = if (state.lastUpdatedMs > 0L)
{
stringResource(
R.string.routing_db_status,
NumberFormat.getIntegerInstance().format(state.ipv4Ranges + state.ipv6Ranges),
DateUtils.getRelativeTimeSpanString(state.lastUpdatedMs).toString(),
)
}
else
{
stringResource(R.string.routing_db_never)
}
Row(verticalAlignment = Alignment.CenterVertically)
{
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (state.status == RuIpListStatus.UPDATING)
{
LoadingIndicator(Modifier.size(24.dp))
Spacer(Modifier.width(ShxSpacing.md))
}
else
{
IconButton(onClick = onRefresh)
{
Icon(
Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.routing_db_refresh),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
@@ -1,198 +0,0 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose.routing
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import android.app.Activity
import android.content.Intent
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.Block
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Lan
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Route
import androidx.compose.material.icons.rounded.Rule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.routing.ForceTunnelPrefix
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.logic.routing.RuIpListState
import org.strongswan.android.logic.routing.RuIpListStatus
import org.strongswan.android.utils.Constants
import org.strongswan.android.ui.RouteCheckActivity
import org.strongswan.android.ui.SelectedApplicationsActivity
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Collapsed rule set row: tap opens the editor sheet, the switch toggles in place. */
@Composable
internal fun RuleSetRow(
ruleSet: RuleSet,
onToggle: (Boolean) -> Unit,
onOpen: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpen)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
{
ShxIconBadge(
Icons.Rounded.Rule,
tint = if (ruleSet.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(ShxSpacing.lg))
Column(Modifier.weight(1f))
{
Text(
ruleSet.name.ifBlank { stringResource(R.string.routing_rule_name) },
style = MaterialTheme.typography.bodyLarge,
color = if (ruleSet.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stringResource(
R.string.routing_rule_subtitle,
ruleSet.packages.size,
ruleSet.prefixes.size,
ruleSet.keywords.size,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = ruleSet.enabled, onCheckedChange = onToggle)
}
}
/** Trailing "add rule set" row of the rule sets card. */
@Composable
internal fun AddRuleRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
{
Icon(
Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(ShxSpacing.lg))
Text(
stringResource(R.string.routing_add_rule),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
@@ -309,9 +309,7 @@
<string name="whitelist_domestic_direct">Yandex (direkt)</string>
<string name="nav_routing">Routing</string>
<string name="routing_title">Routing</string>
<string name="routing_section_ru">RU-Netze direkt</string>
<string name="routing_rf_quick_title">Apps von RF-Unternehmen ausnehmen</string>
<string name="routing_rf_quick_summary">Alle ru.*-Apps, Yandex, VK, Gosuslugi, große Banken, Handel, Telekommunikation, Verkehr und weitere russische Dienste</string>
<string name="routing_section_ru">RF-Websites und -Dienste</string>
<string name="routing_force_section">Immer über VPN</string>
<string name="routing_force_summary">Adressen und Subnetze, die immer durch den Tunnel geleitet werden, auch wenn sie in RU-Bereiche fallen (Userspace-Modus)</string>
<string name="routing_force_add">Adresse hinzufügen</string>
@@ -320,51 +318,81 @@
<string name="routing_force_invalid">Ungültige Adresse oder ungültiges Subnetz</string>
<string name="routing_force_save">Speichern</string>
<string name="routing_force_delete">Löschen</string>
<string name="routing_rules_section">Regelsätze</string>
<string name="routing_add_rule">Regelsatz hinzufügen</string>
<string name="routing_rule_name">Name des Regelsatzes</string>
<string name="routing_rule_subtitle">Apps: %1$d · Präfixe: %2$d · Schlüsselwörter: %3$d</string>
<string name="routing_pick_apps">Anwendungen auswählen</string>
<string name="routing_apps_count">Ausgewählt: %1$d</string>
<string name="routing_editor_title">Regelsatz</string>
<string name="routing_prefixes_label">Paket-Präfixe</string>
<string name="routing_keywords_label">Schlüsselwörter</string>
<string name="routing_details_more">Details</string>
<string name="routing_details_less">Details ausblenden</string>
<string name="routing_presets_label">Vorlagen</string>
<string name="routing_presets_selected">Vorlagen · %1$d ausgewählt</string>
<string name="routing_presets_hint">Fertige Gruppen von Diensten</string>
<string name="routing_presets_search">Vorlagen durchsuchen</string>
<string name="routing_presets_none">Nichts gefunden</string>
<string name="routing_preset_prefixes">%1$d Präfixe</string>
<string name="routing_preset_keywords">%1$d Schlüsselwörter</string>
<string name="routing_preset_no_patterns">keine Muster</string>
<string name="routing_empty">Noch keine Regelsätze vorhanden. Legen Sie einen an oder nutzen Sie die Schnellaktion oben.</string>
<string name="routing_delete">Regelsatz löschen</string>
<string name="routing_delete_snackbar">Regelsatz gelöscht</string>
<string name="routing_add_prefix">Paket-Präfix hinzufügen</string>
<string name="routing_add_keyword">Schlüsselwort hinzufügen</string>
<string name="routing_hero_on">Routing aktiv</string>
<string name="routing_hero_off">Routing inaktiv</string>
<string name="routing_hero_summary">Sets: %1$d · Apps direkt: %2$d</string>
<string name="routing_hero_off_summary">Gesamter Datenverkehr läuft über das VPN</string>
<string name="routing_geoip_ipv4">IPv4 · %1$d</string>
<string name="routing_geoip_ipv6">IPv6 · %1$d</string>
<string name="routing_geoip_updated">Aktualisiert %1$s</string>
<string name="routing_geoip_never">Nie aktualisiert</string>
<string name="rusplit_toggle_title">RU-Netze direkt verbinden</string>
<string name="rusplit_toggle_summary">Datenverkehr zu russischen IP-Adressen umgeht den VPN-Tunnel und nutzt die direkte Verbindung (geoip:ru)</string>
<string name="routing_hero_summary">Läuft am VPN vorbei: %1$s</string>
<string name="routing_hero_off_summary">Aus – der gesamte Datenverkehr läuft über das VPN</string>
<string name="routing_hero_title">Split-Tunnel</string>
<string name="routing_hero_nothing">Vorerst läuft alles über das VPN</string>
<string name="routing_hero_part_sites">RF-Websites</string>
<string name="routing_mode_label">Modus</string>
<string name="routing_mode_stable">Stabil</string>
<string name="routing_mode_stable_desc">Zuverlässig. Neue Adressen gelten nach dem Neuverbinden</string>
<string name="routing_mode_flexible">Flexibel</string>
<string name="routing_mode_beta">BETA</string>
<string name="routing_mode_flexible_desc">Aktualisiert sich im laufenden Betrieb, ohne Neuverbinden</string>
<string name="routing_mode_flexible_warning">Ping zu russischen Adressen funktioniert nicht – das ist normal. Websites und Apps funktionieren wie gewohnt.</string>
<string name="routing_db_status">Adressdatenbank: %1$s Netze · aktualisiert %2$s</string>
<string name="routing_db_never">Adressdatenbank: noch nicht geladen</string>
<string name="routing_db_refresh">Adressdatenbank aktualisieren</string>
<string name="routing_section_apps">Apps</string>
<string name="routing_apps_direct_title">Apps direkt</string>
<string name="routing_apps_direct_empty">Banken und russische Dienste funktionieren oft nicht über ein VPN – füge sie hinzu</string>
<string name="routing_apps_add_all_rf">Alle RF-Apps</string>
<string name="routing_apps_pick_manually">Manuell wählen</string>
<string name="routing_advanced_summary">Immer über VPN, Paketvorlagen, Diagnose</string>
<string name="routing_adv_force_sub">Adressen und Subnetze, die nie direkt laufen</string>
<string name="routing_adv_patterns_sub">Z. B. „ru.*“ – alle Apps, deren Paket so beginnt</string>
<string name="routing_adv_dead_sub">Hosts, die direkt nicht antworten – sie können über das VPN geleitet werden</string>
<string name="routing_patterns_title">Paketvorlagen</string>
<string name="routing_patterns_hint">Eine Vorlage wählt Apps nach dem Paketnamen. „ru.*“ – beginnt mit, „*yandex*“ – enthält, „*.beta“ – endet auf.</string>
<string name="routing_patterns_empty">Noch keine Vorlagen</string>
<string name="routing_patterns_add">Vorlage hinzufügen</string>
<string name="routing_patterns_dialog_title">Paketvorlage</string>
<string name="routing_patterns_input_hint">z. B. ru.* oder *yandex*</string>
<string name="routing_patterns_invalid">Erlaubt sind Buchstaben, Ziffern, Punkte, Bindestriche und *</string>
<string name="routing_patterns_delete">Vorlage löschen</string>
<string name="routing_migrated_only">Ein Profil mit „nur ausgewählte Apps“ wurde umgewandelt: alle anderen installierten Apps stehen jetzt in der Liste „Apps direkt“. Bitte prüfen.</string>
<string name="profile_routing_link_title">Was am VPN vorbeiläuft</string>
<string name="profile_routing_link_summary">Gilt für alle Profile. Wird im Tab „Routing“ eingestellt</string>
<string name="apps_search">Apps suchen</string>
<string name="apps_search_clear">Suche löschen</string>
<string name="apps_filter_direct">Direkt · %1$d</string>
<string name="apps_filter_vpn">Über VPN · %1$d</string>
<string name="apps_filter_all">Alle</string>
<string name="apps_quick_add">Schnell hinzufügen</string>
<string name="apps_nothing_found">Nichts gefunden</string>
<string name="apps_reason_added">Direkt · von dir hinzugefügt</string>
<string name="apps_reason_template">Direkt · per Vorlage „%1$s“</string>
<string name="apps_reason_vpn">Über VPN</string>
<string name="apps_reason_override">Ausnahme: über VPN (Vorlage „%1$s“ gilt nicht)</string>
<string name="apps_switch_desc">%1$s: direkt, ohne VPN</string>
<string name="routing_cat_banks">Banken</string>
<string name="routing_cat_gov">Gosuslugi</string>
<string name="routing_cat_shop">Marktplätze</string>
<string name="routing_cat_social">Kommunikation</string>
<string name="routing_cat_travel">Verkehr</string>
<string name="routing_cat_services">Dienste</string>
<string name="routing_cat_namespace">ru.*</string>
<string name="routing_cat_banks_title">Banken</string>
<string name="routing_cat_gov_title">Behörden und Dienste</string>
<string name="routing_cat_shop_title">Marktplätze</string>
<string name="routing_cat_social_title">Kommunikation und Unterhaltung</string>
<string name="routing_cat_travel_title">Verkehr und Reisen</string>
<string name="routing_cat_services_title">Dienste</string>
<string name="routing_cat_other_title">Andere</string>
<plurals name="routing_apps_n">
<item quantity="one">%d App</item>
<item quantity="other">%d Apps</item>
</plurals>
<plurals name="routing_apps_direct_summary">
<item quantity="one">%d App läuft ohne VPN</item>
<item quantity="other">%d Apps laufen ohne VPN</item>
</plurals>
<string name="rusplit_toggle_title">Russische Websites direkt</string>
<string name="rusplit_toggle_summary">Banken, Gosuslugi und russische Shops öffnen sich ohne VPN</string>
<string name="rusplit_update">Liste aktualisieren</string>
<string name="rusplit_updating">Wird aktualisiert…</string>
<string name="rusplit_updated">Liste aktualisiert: %1$d Bereiche</string>
<string name="rusplit_update_failed">Aktualisierung nicht möglich, gespeicherte Version wird verwendet</string>
<string name="rusplit_source">Quelle: github.com/MetaCubeX/meta-rules-dat</string>
<string name="rusplit_beta_note">Die Liste ist statisch und wird täglich von GitHub aktualisiert. Die Zuordnung erfolgt über IP-Adressen. Dienste hinter ausländischen CDNs sind möglicherweise nicht in der Liste enthalten. Änderungen werden nach einer erneuten Verbindung wirksam.</string>
<string name="rusplit_mode_system">Betriebssystem-Routen</string>
<string name="rusplit_mode_userspace">Userspace-Routing (Beta)</string>
<string name="rusplit_mode_userspace_short">Userspace (Beta)</string>
<string name="rusplit_mode_system_desc">Das Komplement der RU-Liste wird als Systemrouten installiert und auf das Routenlimit gerundet. Bewährtes Verfahren, die Liste wird dabei jedoch vergröbert, und Aktualisierungen erfordern eine erneute Verbindung.</string>
<string name="rusplit_mode_userspace_desc">Der gesamte Datenverkehr geht in das VPN. Die Entscheidung Direkt/Tunnel wird pro Paket anhand der genauen Liste getroffen. Listenaktualisierungen werden sofort übernommen. TCP und UDP gehen direkt. Ping zu RU-Adressen funktioniert in diesem Modus nicht direkt, anderes ICMP wird verworfen.</string>
<string name="route_check_title">Routenprüfung</string>
<string name="route_check_hint">Geben Sie eine Domain ein, um zu sehen, auf welche Adressen sie über den DNS des Tunnels und den DNS der direkten Verbindung aufgelöst wird und ob jede Adresse unter den angewendeten Ausnahmen durch den Tunnel läuft oder ihn umgeht.</string>
<string name="route_check_field">Domain, z. B. inet.ya.ru</string>
@@ -494,7 +522,7 @@
<string name="availability_expand">Details anzeigen</string>
<string name="availability_collapse">Details ausblenden</string>
<string name="routing_advanced">Erweitert</string>
<string name="routing_killswitch_note">%1$d ausgeschlossene App(s) nutzen immer das normale Netzwerk, auch wenn der Tunnel ausfällt. Der Kill-Switch blockiert sie nicht.</string>
<string name="routing_killswitch_note">„Verbindungen ohne VPN blockieren“ ist aktiv. Bricht das VPN ab, nutzen Apps aus der Liste „Apps direkt“ weiter das normale Netz; alles andere, auch RF-Websites, wird blockiert.</string>
<string name="settings_page_appearance">Darstellung</string>
<string name="settings_quick">Schnelleinstellungen</string>
<string name="pref_theme_title">Design</string>
@@ -208,9 +208,7 @@
<string name="whitelist_domestic_direct">Яндекс (напрямую)</string>
<string name="nav_routing">Маршрут</string>
<string name="routing_title">Маршрутизация</string>
<string name="routing_section_ru">RU-сети напрямую</string>
<string name="routing_rf_quick_title">Исключить приложения из РФ</string>
<string name="routing_rf_quick_summary">Все приложения ru.*, Яндекс, VK, Госуслуги, банки, магазины, связь, транспорт и другие сервисы РФ</string>
<string name="routing_section_ru">Сайты и сервисы РФ</string>
<string name="routing_force_section">Всегда через VPN</string>
<string name="routing_force_summary">Адреса и подсети, которые всегда идут через туннель, даже если попадают в RU-диапазоны (режим userspace)</string>
<string name="routing_force_add">Добавить адрес</string>
@@ -219,51 +217,85 @@
<string name="routing_force_invalid">Некорректный адрес или подсеть</string>
<string name="routing_force_save">Сохранить</string>
<string name="routing_force_delete">Удалить</string>
<string name="routing_rules_section">Наборы правил</string>
<string name="routing_add_rule">Добавить набор</string>
<string name="routing_rule_name">Название набора</string>
<string name="routing_rule_subtitle">Приложений: %1$d · Префиксов: %2$d · Слов: %3$d</string>
<string name="routing_pick_apps">Выбрать приложения</string>
<string name="routing_apps_count">Выбрано: %1$d</string>
<string name="routing_editor_title">Набор правил</string>
<string name="routing_prefixes_label">Префиксы пакетов</string>
<string name="routing_keywords_label">Ключевые слова</string>
<string name="routing_details_more">Подробнее</string>
<string name="routing_details_less">Скрыть</string>
<string name="routing_presets_label">Пресеты</string>
<string name="routing_presets_selected">Пресеты · выбрано %1$d</string>
<string name="routing_presets_hint">Готовые группы сервисов</string>
<string name="routing_presets_search">Поиск пресетов</string>
<string name="routing_presets_none">Ничего не найдено</string>
<string name="routing_preset_prefixes">%1$d префиксов</string>
<string name="routing_preset_keywords">%1$d слов</string>
<string name="routing_preset_no_patterns">нет паттернов</string>
<string name="routing_empty">Наборов пока нет. Добавьте свой или используйте быстрое действие выше.</string>
<string name="routing_delete">Удалить набор</string>
<string name="routing_delete_snackbar">Набор удалён</string>
<string name="routing_add_prefix">Свой префикс пакета</string>
<string name="routing_add_keyword">Своё ключевое слово</string>
<string name="routing_hero_on">Маршрутизация включена</string>
<string name="routing_hero_off">Маршрутизация выключена</string>
<string name="routing_hero_summary">Наборов: %1$d · Приложений: %2$d</string>
<string name="routing_hero_off_summary">Весь трафик идёт через VPN</string>
<string name="routing_geoip_ipv4">IPv4 · %1$d</string>
<string name="routing_geoip_ipv6">IPv6 · %1$d</string>
<string name="routing_geoip_updated">Обновлено %1$s</string>
<string name="routing_geoip_never">Не обновлялся</string>
<string name="rusplit_toggle_title">RU-сети напрямую (geoip)</string>
<string name="rusplit_toggle_summary">Трафик к российским IP-адресам идёт мимо VPN-туннеля, напрямую</string>
<string name="routing_hero_summary">Мимо VPN: %1$s</string>
<string name="routing_hero_off_summary">Выключен — весь трафик идёт через VPN</string>
<string name="routing_hero_title">Раздельный туннель</string>
<string name="routing_hero_nothing">Пока всё идёт через VPN</string>
<string name="routing_hero_part_sites">сайты РФ</string>
<string name="routing_mode_label">Способ</string>
<string name="routing_mode_stable">Стабильный</string>
<string name="routing_mode_stable_desc">Надёжно. Новые адреса — после переподключения</string>
<string name="routing_mode_flexible">Гибкий</string>
<string name="routing_mode_beta">БЕТА</string>
<string name="routing_mode_flexible_desc">Обновляется на лету, без переподключения</string>
<string name="routing_mode_flexible_warning">Ping до российских адресов не работает — это ожидаемо. Сайты и приложения работают как обычно.</string>
<string name="routing_db_status">База адресов: %1$s сетей · обновлена %2$s</string>
<string name="routing_db_never">База адресов: ещё не загружена</string>
<string name="routing_db_refresh">Обновить базу адресов</string>
<string name="routing_section_apps">Приложения</string>
<string name="routing_apps_direct_title">Приложения напрямую</string>
<string name="routing_apps_direct_empty">Банки и сервисы РФ часто не работают через VPN — добавьте их</string>
<string name="routing_apps_add_all_rf">Все приложения РФ</string>
<string name="routing_apps_pick_manually">Выбрать вручную</string>
<string name="routing_advanced_summary">Всегда через VPN, шаблоны пакетов, диагностика</string>
<string name="routing_adv_force_sub">Адреса и подсети, которые никогда не идут напрямую</string>
<string name="routing_adv_patterns_sub">Например, «ru.*» — все приложения с таким началом</string>
<string name="routing_adv_dead_sub">Адреса, которые не отвечают напрямую — их можно пустить через VPN</string>
<string name="routing_patterns_title">Шаблоны пакетов</string>
<string name="routing_patterns_hint">Шаблон выбирает приложения по имени пакета. «ru.*» — начинается с, «*yandex*» — содержит, «*.beta» — заканчивается на.</string>
<string name="routing_patterns_empty">Шаблонов пока нет</string>
<string name="routing_patterns_add">Добавить шаблон</string>
<string name="routing_patterns_dialog_title">Шаблон пакета</string>
<string name="routing_patterns_input_hint">например, ru.* или *yandex*</string>
<string name="routing_patterns_invalid">Допустимы буквы, цифры, точки, дефис и *</string>
<string name="routing_patterns_delete">Удалить шаблон</string>
<string name="routing_migrated_only">Профиль «только выбранные приложения» преобразован: остальные установленные приложения теперь в списке «Приложения напрямую». Проверьте список.</string>
<string name="profile_routing_link_title">Что идёт мимо VPN</string>
<string name="profile_routing_link_summary">Общее для всех профилей. Настраивается во вкладке «Маршрутизация»</string>
<string name="apps_search">Поиск приложений</string>
<string name="apps_search_clear">Очистить поиск</string>
<string name="apps_filter_direct">Напрямую · %1$d</string>
<string name="apps_filter_vpn">Через VPN · %1$d</string>
<string name="apps_filter_all">Все</string>
<string name="apps_quick_add">Быстро добавить</string>
<string name="apps_nothing_found">Ничего не найдено</string>
<string name="apps_reason_added">Напрямую · добавлено вами</string>
<string name="apps_reason_template">Напрямую · по шаблону «%1$s»</string>
<string name="apps_reason_vpn">Через VPN</string>
<string name="apps_reason_override">Исключение: через VPN (шаблон «%1$s» не действует)</string>
<string name="apps_switch_desc">%1$s: напрямую, без VPN</string>
<string name="routing_cat_banks">Банки</string>
<string name="routing_cat_gov">Госуслуги</string>
<string name="routing_cat_shop">Маркетплейсы</string>
<string name="routing_cat_social">Связь</string>
<string name="routing_cat_travel">Транспорт</string>
<string name="routing_cat_services">Сервисы</string>
<string name="routing_cat_namespace">ru.*</string>
<string name="routing_cat_banks_title">Банки</string>
<string name="routing_cat_gov_title">Госуслуги и сервисы</string>
<string name="routing_cat_shop_title">Маркетплейсы</string>
<string name="routing_cat_social_title">Связь и развлечения</string>
<string name="routing_cat_travel_title">Транспорт и поездки</string>
<string name="routing_cat_services_title">Сервисы</string>
<string name="routing_cat_other_title">Другие</string>
<plurals name="routing_apps_n">
<item quantity="one">%d приложение</item>
<item quantity="few">%d приложения</item>
<item quantity="many">%d приложений</item>
<item quantity="other">%d приложения</item>
</plurals>
<plurals name="routing_apps_direct_summary">
<item quantity="one">%d приложение работает без VPN</item>
<item quantity="few">%d приложения работают без VPN</item>
<item quantity="many">%d приложений работают без VPN</item>
<item quantity="other">%d приложения работают без VPN</item>
</plurals>
<string name="rusplit_toggle_title">Российские сайты напрямую</string>
<string name="rusplit_toggle_summary">Банки, госуслуги и магазины РФ открываются без VPN</string>
<string name="rusplit_update">Обновить список</string>
<string name="rusplit_updating">Обновление…</string>
<string name="rusplit_updated">Список обновлён: %1$d диапазонов</string>
<string name="rusplit_update_failed">Не удалось обновить, используется сохранённая версия</string>
<string name="rusplit_source">Источник: github.com/MetaCubeX/meta-rules-dat</string>
<string name="rusplit_beta_note">Список статичный, обновляется с GitHub раз в сутки. Работа по IP: сервисы за иностранным CDN могут не попасть в список. Изменения применяются при переподключении.</string>
<string name="rusplit_mode_system">Маршруты ОС</string>
<string name="rusplit_mode_userspace">Userspace-роутинг (бета)</string>
<string name="rusplit_mode_userspace_short">Userspace (бета)</string>
<string name="rusplit_mode_system_desc">Комплемент RU-списка устанавливается системными маршрутами с округлением под бюджет маршрутов. Проверенная схема, но список грубеет, а для обновления нужно переподключение.</string>
<string name="rusplit_mode_userspace_desc">Весь трафик входит в VPN, решение «напрямую/в туннель» принимается по пакету по точному списку, обновления применяются на лету. Напрямую идут TCP и UDP. Ping по RU-адресам напрямую не работает, прочий ICMP отбрасывается.</string>
<string name="route_check_title">Проверка маршрута</string>
<string name="route_check_hint">Введите домен, чтобы увидеть, какие адреса он получает через DNS туннеля и через DNS прямой сети, и какой путь проходит каждый адрес при текущих исключениях.</string>
<string name="route_check_field">домен, например inet.ya.ru</string>
@@ -474,7 +506,7 @@
<string name="network_label">Сеть</string>
<string name="network_wifi">Wi-Fi</string>
<string name="network_cellular">Сотовая сеть</string>
<string name="dead_targets_title">Недоступные напрямую</string>
<string name="dead_targets_title">Недоступные хосты</string>
<string name="dead_targets_summary">Приложения продолжают стучаться к этим адресам, но те не отвечают — движок отклоняет соединения на время паузы. Давно умершие хосты автоматически переводятся в туннель VPN до смены сети. Проверьте серверы или уберите их из настроек использующего их приложения.</string>
<string name="dead_targets_fails_fmt">отказов: %1$d</string>
<string name="dead_targets_cooldown_fmt">пауза %1$s</string>
@@ -492,7 +524,7 @@
<string name="availability_expand">Показать детали</string>
<string name="availability_collapse">Скрыть детали</string>
<string name="routing_advanced">Дополнительно</string>
<string name="routing_killswitch_note">Исключённые приложения (%1$d) всегда идут мимо VPN, в том числе при обрыве туннеля: kill switch их не блокирует.</string>
<string name="routing_killswitch_note">Включена блокировка без VPN. При обрыве VPN приложения из списка «Напрямую» продолжат работать через обычную сеть — остальное, включая сайты РФ, будет заблокировано.</string>
<string name="settings_page_appearance">Оформление</string>
<string name="settings_quick">Быстрые настройки</string>
<string name="pref_theme_title">Тема</string>
@@ -186,8 +186,6 @@
<string name="whitelist_domestic_direct">Яндекс (напряму)</string>
<string name="nav_routing">Маршрут</string>
<string name="routing_title">Маршрутизація</string>
<string name="routing_rf_quick_title">Виключити застосунки з РФ</string>
<string name="routing_rf_quick_summary">Усі застосунки ru.*, Яндекс, VK, Дії, банки, магазини, зв\'язок, транспорт та інші сервіси РФ</string>
<string name="routing_force_section">Завжди через VPN</string>
<string name="routing_force_summary">Адреси та підмережі, які завжди йдуть через тунель, навіть якщо потрапляють у RU-діапазони (режим userspace)</string>
<string name="routing_force_add">Додати адресу</string>
@@ -196,14 +194,6 @@
<string name="routing_force_invalid">Некоректна адреса чи підмережа</string>
<string name="routing_force_save">Зберегти</string>
<string name="routing_force_delete">Видалити</string>
<string name="routing_rules_section">Набори правил</string>
<string name="routing_add_rule">Додати набір</string>
<string name="routing_rule_name">Назва набору</string>
<string name="routing_rule_subtitle">Застосунків: %1$d · Префіксів: %2$d · Слів: %3$d</string>
<string name="routing_pick_apps">Вибрати застосунки</string>
<string name="routing_presets_label">Пресети</string>
<string name="routing_empty">Наборів ще немає. Додайте свій або скористайтеся швидкою дією вище.</string>
<string name="routing_delete">Видалити набір</string>
<string name="profile_cert_import">Import certificate from VPN profile</string>
<string name="profile_cert_alias">Certificate for \"%1$s\"</string>
<string name="profile_profile_id">Profile ID</string>
@@ -185,8 +185,6 @@
<string name="whitelist_domestic_direct">Yandex(直连)</string>
<string name="nav_routing">路由</string>
<string name="routing_title">路由</string>
<string name="routing_rf_quick_title">排除俄罗斯应用</string>
<string name="routing_rf_quick_summary">全部 ru.* 应用、Yandex、VK、Gosuslugi、主要银行、零售、电信、交通及其他俄罗斯服务</string>
<string name="routing_force_section">始终通过 VPN</string>
<string name="routing_force_summary">始终经隧道路由的地址和子网,即使位于 RU 网段内(用户态模式)</string>
<string name="routing_force_add">添加地址</string>
@@ -195,14 +193,6 @@
<string name="routing_force_invalid">地址或子网无效</string>
<string name="routing_force_save">保存</string>
<string name="routing_force_delete">删除</string>
<string name="routing_rules_section">规则集</string>
<string name="routing_add_rule">添加规则集</string>
<string name="routing_rule_name">规则集名称</string>
<string name="routing_rule_subtitle">应用:%1$d · 前缀:%2$d · 关键词:%3$d</string>
<string name="routing_pick_apps">选择应用</string>
<string name="routing_presets_label">预设</string>
<string name="routing_empty">暂无规则集。添加一个或使用上方快捷操作。</string>
<string name="routing_delete">删除规则集</string>
<string name="profile_cert_import">ф╗ОVPNщЕНч╜охп╝хЕешпБф╣ж</string>
<string name="profile_cert_alias">\"%1$s\" цЙАхп╣х║ФчЪДшпБф╣ж</string>
<string name="profile_profile_id">щЕНч╜оцЦЗф╗╢ID</string>
@@ -212,9 +212,7 @@
<string name="whitelist_domestic_direct">Yandex (direct)</string>
<string name="nav_routing">Routing</string>
<string name="routing_title">Routing</string>
<string name="routing_section_ru">RU networks direct</string>
<string name="routing_rf_quick_title">Exclude apps from RF companies</string>
<string name="routing_rf_quick_summary">All ru.* apps, Yandex, VK, Gosuslugi, major banks, retail, telecom, transport and other Russian services</string>
<string name="routing_section_ru">RF sites and services</string>
<string name="routing_force_section">Always via VPN</string>
<string name="routing_force_summary">Addresses and subnets that are always routed through the tunnel, even when they fall into RU ranges (userspace mode)</string>
<string name="routing_force_add">Add address</string>
@@ -223,51 +221,81 @@
<string name="routing_force_invalid">Invalid address or subnet</string>
<string name="routing_force_save">Save</string>
<string name="routing_force_delete">Delete</string>
<string name="routing_rules_section">Rule sets</string>
<string name="routing_add_rule">Add rule set</string>
<string name="routing_rule_name">Rule set name</string>
<string name="routing_rule_subtitle">Apps: %1$d · Prefixes: %2$d · Keywords: %3$d</string>
<string name="routing_pick_apps">Choose applications</string>
<string name="routing_apps_count">Selected: %1$d</string>
<string name="routing_editor_title">Rule set</string>
<string name="routing_prefixes_label">Package prefixes</string>
<string name="routing_keywords_label">Keywords</string>
<string name="routing_details_more">Details</string>
<string name="routing_details_less">Hide details</string>
<string name="routing_presets_label">Presets</string>
<string name="routing_presets_selected">Presets · %1$d selected</string>
<string name="routing_presets_hint">Ready-made groups of services</string>
<string name="routing_presets_search">Search presets</string>
<string name="routing_presets_none">Nothing found</string>
<string name="routing_preset_prefixes">%1$d prefixes</string>
<string name="routing_preset_keywords">%1$d keywords</string>
<string name="routing_preset_no_patterns">no patterns</string>
<string name="routing_empty">No rule sets yet. Add one or use the quick action above.</string>
<string name="routing_delete">Delete rule set</string>
<string name="routing_delete_snackbar">Rule set deleted</string>
<string name="routing_add_prefix">Add package prefix</string>
<string name="routing_add_keyword">Add keyword</string>
<string name="routing_hero_on">Routing on</string>
<string name="routing_hero_off">Routing off</string>
<string name="routing_hero_summary">Sets: %1$d · Apps direct: %2$d</string>
<string name="routing_hero_off_summary">All traffic goes through the VPN</string>
<string name="routing_geoip_ipv4">IPv4 · %1$d</string>
<string name="routing_geoip_ipv6">IPv6 · %1$d</string>
<string name="routing_geoip_updated">Updated %1$s</string>
<string name="routing_geoip_never">Never updated</string>
<string name="rusplit_toggle_title">Route RU networks directly</string>
<string name="rusplit_toggle_summary">Traffic to Russian IP addresses bypasses the VPN tunnel and uses the connection directly (geoip:ru)</string>
<string name="routing_hero_summary">Bypasses the VPN: %1$s</string>
<string name="routing_hero_off_summary">Off — all traffic goes through the VPN</string>
<string name="routing_hero_title">Split tunnel</string>
<string name="routing_hero_nothing">Everything goes through the VPN for now</string>
<string name="routing_hero_part_sites">RF sites</string>
<string name="routing_mode_label">Mode</string>
<string name="routing_mode_stable">Stable</string>
<string name="routing_mode_stable_desc">Reliable. New addresses apply after reconnecting</string>
<string name="routing_mode_flexible">Flexible</string>
<string name="routing_mode_beta">BETA</string>
<string name="routing_mode_flexible_desc">Updates on the fly, no reconnect</string>
<string name="routing_mode_flexible_warning">Ping to Russian addresses does not work - this is expected. Sites and apps work as usual.</string>
<string name="routing_db_status">Address database: %1$s networks · updated %2$s</string>
<string name="routing_db_never">Address database: not downloaded yet</string>
<string name="routing_db_refresh">Refresh address database</string>
<string name="routing_section_apps">Apps</string>
<string name="routing_apps_direct_title">Apps direct</string>
<string name="routing_apps_direct_empty">Banks and Russian services often do not work through a VPN - add them</string>
<string name="routing_apps_add_all_rf">All RF apps</string>
<string name="routing_apps_pick_manually">Pick manually</string>
<string name="routing_advanced_summary">Always via VPN, package templates, diagnostics</string>
<string name="routing_adv_force_sub">Addresses and subnets that never go direct</string>
<string name="routing_adv_patterns_sub">For example \"ru.*\" - all apps whose package starts with it</string>
<string name="routing_adv_dead_sub">Hosts that do not answer directly - they can be sent through the VPN</string>
<string name="routing_patterns_title">Package templates</string>
<string name="routing_patterns_hint">A template selects apps by package name. \"ru.*\" - starts with, \"*yandex*\" - contains, \"*.beta\" - ends with.</string>
<string name="routing_patterns_empty">No templates yet</string>
<string name="routing_patterns_add">Add template</string>
<string name="routing_patterns_dialog_title">Package template</string>
<string name="routing_patterns_input_hint">e.g. ru.* or *yandex*</string>
<string name="routing_patterns_invalid">Use letters, digits, dots, hyphens and *</string>
<string name="routing_patterns_delete">Delete template</string>
<string name="routing_migrated_only">A profile with \"only selected apps\" was converted: all other installed apps are now in the \"Apps direct\" list. Please review it.</string>
<string name="profile_routing_link_title">What bypasses the VPN</string>
<string name="profile_routing_link_summary">Shared by all profiles. Set up in the Routing tab</string>
<string name="apps_search">Search apps</string>
<string name="apps_search_clear">Clear search</string>
<string name="apps_filter_direct">Direct · %1$d</string>
<string name="apps_filter_vpn">Via VPN · %1$d</string>
<string name="apps_filter_all">All</string>
<string name="apps_quick_add">Quick add</string>
<string name="apps_nothing_found">Nothing found</string>
<string name="apps_reason_added">Direct · added by you</string>
<string name="apps_reason_template">Direct · by template “%1$s”</string>
<string name="apps_reason_vpn">Via VPN</string>
<string name="apps_reason_override">Exception: via VPN (template “%1$s” does not apply)</string>
<string name="apps_switch_desc">%1$s: direct, without VPN</string>
<string name="routing_cat_banks">Banks</string>
<string name="routing_cat_gov">Gosuslugi</string>
<string name="routing_cat_shop">Marketplaces</string>
<string name="routing_cat_social">Communication</string>
<string name="routing_cat_travel">Transport</string>
<string name="routing_cat_services">Services</string>
<string name="routing_cat_namespace">ru.*</string>
<string name="routing_cat_banks_title">Banks</string>
<string name="routing_cat_gov_title">Government and services</string>
<string name="routing_cat_shop_title">Marketplaces</string>
<string name="routing_cat_social_title">Communication and entertainment</string>
<string name="routing_cat_travel_title">Transport and travel</string>
<string name="routing_cat_services_title">Services</string>
<string name="routing_cat_other_title">Other</string>
<plurals name="routing_apps_n">
<item quantity="one">%d app</item>
<item quantity="other">%d apps</item>
</plurals>
<plurals name="routing_apps_direct_summary">
<item quantity="one">%d app works without the VPN</item>
<item quantity="other">%d apps work without the VPN</item>
</plurals>
<string name="rusplit_toggle_title">RF sites direct</string>
<string name="rusplit_toggle_summary">Banks, Gosuslugi and Russian shops open without the VPN</string>
<string name="rusplit_update">Update list</string>
<string name="rusplit_updating">Updating…</string>
<string name="rusplit_updated">List updated: %1$d ranges</string>
<string name="rusplit_update_failed">Could not update, using the saved version</string>
<string name="rusplit_source">Source: github.com/MetaCubeX/meta-rules-dat</string>
<string name="rusplit_beta_note">The list is static and refreshed from GitHub daily. It works by IP: services behind foreign CDNs may not be in the list. Changes apply on reconnect.</string>
<string name="rusplit_mode_system">OS routes</string>
<string name="rusplit_mode_userspace">Userspace routing (beta)</string>
<string name="rusplit_mode_userspace_short">Userspace (beta)</string>
<string name="rusplit_mode_system_desc">The complement of the RU list is installed as system routes, rounded up to fit the route budget. Proven scheme, but the list is coarsened and a reconnect is needed to apply updates.</string>
<string name="rusplit_mode_userspace_desc">All traffic enters the VPN and the direct/tunnel decision is made per packet from the exact list. List updates apply on the fly. TCP and UDP go direct. Ping to RU addresses does not work directly in this mode; other ICMP is dropped.</string>
<string name="route_check_title">Route check</string>
<string name="route_check_hint">Enter a domain to see which addresses it resolves to through the tunnel\'s DNS and the direct network\'s DNS, and whether each address goes through the tunnel or bypasses it under the applied exclusions.</string>
<string name="route_check_field">domain, e.g. inet.ya.ru</string>
@@ -497,7 +525,7 @@
<string name="availability_expand">Show details</string>
<string name="availability_collapse">Hide details</string>
<string name="routing_advanced">Advanced</string>
<string name="routing_killswitch_note">%1$d excluded app(s) always use the normal network, even when the tunnel drops: the kill switch does not block them.</string>
<string name="routing_killswitch_note">Block connections without VPN is on. If the VPN drops, apps from the \"Apps direct\" list keep using the regular network; everything else, including RF sites, is blocked.</string>
<string name="settings_page_appearance">Appearance</string>
<string name="settings_quick">Quick settings</string>
<string name="pref_theme_title">Theme</string>
@@ -0,0 +1,63 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.routing
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
class PackagePatternsTest
{
@Test
fun bareInputBecomesPrefixPattern()
{
assertThat(PackagePatterns.normalize(" RU.Ozon ")).isEqualTo("ru.ozon*")
}
@Test
fun starsAreCollapsedAndKept()
{
assertThat(PackagePatterns.normalize("**yandex**")).isEqualTo("*yandex*")
}
@Test
fun tooBroadOrInvalidInputIsRejected()
{
assertThat(PackagePatterns.normalize("")).isNull()
assertThat(PackagePatterns.normalize("*")).isNull()
assertThat(PackagePatterns.normalize("**")).isNull()
assertThat(PackagePatterns.normalize("a b")).isNull()
assertThat(PackagePatterns.normalize("ru/x")).isNull()
}
@Test
fun prefixKeywordSuffixAndExactMatching()
{
assertThat(PackagePatterns.matches("ru.*", "ru.ozon.app")).isTrue()
assertThat(PackagePatterns.matches("ru.*", "com.ru.app")).isFalse()
assertThat(PackagePatterns.matches("*yandex*", "com.Yandex.browser")).isTrue()
assertThat(PackagePatterns.matches("*.beta", "org.app.beta")).isTrue()
assertThat(PackagePatterns.matches("*.beta", "org.app.beta2")).isFalse()
assertThat(PackagePatterns.matches("org.app", "org.app")).isTrue()
assertThat(PackagePatterns.matches("org.app", "org.app2")).isFalse()
}
@Test
fun matchAllCollectsOnlyMatchingPackages()
{
val installed = setOf("ru.a", "ru.b", "com.c")
assertThat(PackagePatterns.matchAll(setOf("ru.*"), installed)).containsExactlyInAnyOrder("ru.a", "ru.b")
assertThat(PackagePatterns.matchAll(emptySet(), installed)).isEmpty()
}
}
@@ -16,7 +16,6 @@ package org.strongswan.android.logic.routing
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
class RoutingManagerTest
{
@@ -27,143 +26,107 @@ class RoutingManagerTest
"com.sberbank.mobile",
"ru.ozon.app",
"org.mozilla.firefox",
"org.telegram.messenger",
)
/* ---------------- matchPrefixes ---------------- */
@Test
fun prefixMatchesInstalledPackages()
{
val matched = RoutingManager.matchPrefixes(setOf("ru.yandex."), installed)
assertThat(matched).containsExactlyInAnyOrder("ru.yandex.taxi", "ru.yandex.mail")
}
@Test
fun prefixDoesNotMatchDifferentReverseDomain()
{
val matched = RoutingManager.matchPrefixes(setOf("ru.yandex."), installed)
assertThat(matched).doesNotContain("com.yandex.browser")
}
@Test
fun emptyPrefixesMatchNothing()
{
assertThat(RoutingManager.matchPrefixes(emptySet(), installed)).isEmpty()
}
/* ---------------- matchKeywords ---------------- */
@Test
fun keywordMatchesAnywhereInPackageName()
{
val matched = RoutingManager.matchKeywords(setOf("yandex"), installed)
assertThat(matched).contains("com.yandex.browser")
assertThat(matched).contains("ru.yandex.taxi")
}
@Test
fun keywordMatchingIsCaseInsensitive()
{
assertThat(RoutingManager.matchKeywords(setOf("YANDEX"), installed))
.contains("com.yandex.browser")
}
@Test
fun emptyKeywordsMatchNothing()
{
assertThat(RoutingManager.matchKeywords(emptySet(), installed)).isEmpty()
}
/* ---------------- effectiveExcludes ---------------- */
@Test
fun disabledRuleSetsAreIgnored()
fun emptyConfigExcludesNothing()
{
val ruleSets = listOf(
RuleSet(id = "1", name = "Yandex", enabled = false, prefixes = setOf("ru.yandex.")),
RuleSet(id = "2", name = "Ozon", enabled = true, prefixes = setOf("ru.ozon")),
)
assertThat(RoutingManager.effectiveExcludes(ruleSets, installed)).containsExactly("ru.ozon.app")
assertThat(RoutingManager.effectiveExcludes(RoutingConfig(), installed)).isEmpty()
}
@Test
fun explicitPackagesAndPrefixesCombine()
fun explicitDirectAppsAreIncluded()
{
val ruleSets = listOf(
RuleSet(id = "1", name = "Mix", enabled = true, packages = setOf("org.mozilla.firefox"), prefixes = setOf("com.sberbank")),
val config = RoutingConfig(directPackages = setOf("org.mozilla.firefox"))
assertThat(RoutingManager.effectiveExcludes(config, installed)).containsExactly("org.mozilla.firefox")
}
@Test
fun uninstalledDirectAppsAreDropped()
{
val config = RoutingConfig(directPackages = setOf("org.mozilla.firefox", "gone.app"))
assertThat(RoutingManager.effectiveExcludes(config, installed)).containsExactly("org.mozilla.firefox")
}
@Test
fun directAppsSurviveWhenInstalledListIsUnavailable()
{
val config = RoutingConfig(directPackages = setOf("org.mozilla.firefox"))
assertThat(RoutingManager.effectiveExcludes(config, emptySet())).containsExactly("org.mozilla.firefox")
}
@Test
fun enabledCategoryMatchesInstalledApps()
{
val config = RoutingConfig(enabledCategories = setOf(RoutingPresets.BANKS, RoutingPresets.SHOP))
assertThat(RoutingManager.effectiveExcludes(config, installed))
.containsExactlyInAnyOrder("com.sberbank.mobile", "ru.ozon.app")
}
@Test
fun namespaceCategoryMatchesWholeRuNamespace()
{
val config = RoutingConfig(enabledCategories = setOf(RoutingPresets.NAMESPACE))
assertThat(RoutingManager.effectiveExcludes(config, installed))
.containsExactlyInAnyOrder("ru.yandex.taxi", "ru.yandex.mail", "ru.ozon.app")
}
@Test
fun patternsMatchPrefixAndKeyword()
{
val config = RoutingConfig(packagePatterns = setOf("ru.yandex.*", "*sber*"))
assertThat(RoutingManager.effectiveExcludes(config, installed))
.containsExactlyInAnyOrder("ru.yandex.taxi", "ru.yandex.mail", "com.sberbank.mobile")
}
@Test
fun tunnelOverrideWinsOverCategoriesPatternsAndExplicitList()
{
val config = RoutingConfig(
directPackages = setOf("org.mozilla.firefox"),
enabledCategories = setOf(RoutingPresets.NAMESPACE),
packagePatterns = setOf("*yandex*"),
tunnelOverrides = setOf("ru.yandex.taxi", "com.yandex.browser", "org.mozilla.firefox"),
)
val excludes = RoutingManager.effectiveExcludes(ruleSets, installed)
assertThat(excludes).containsExactlyInAnyOrder("org.mozilla.firefox", "com.sberbank.mobile")
val excludes = RoutingManager.effectiveExcludes(config, installed)
assertThat(excludes).containsExactlyInAnyOrder("ru.yandex.mail", "ru.ozon.app")
}
@Test
fun duplicateMatchesCollapse()
{
val ruleSets = listOf(
RuleSet(id = "1", name = "A", enabled = true, packages = setOf("ru.ozon.app")),
RuleSet(id = "2", name = "B", enabled = true, prefixes = setOf("ru.ozon")),
val config = RoutingConfig(
directPackages = setOf("ru.ozon.app"),
enabledCategories = setOf(RoutingPresets.SHOP),
packagePatterns = setOf("ru.ozon*"),
)
assertThat(RoutingManager.effectiveExcludes(ruleSets, installed)).containsExactly("ru.ozon.app")
assertThat(RoutingManager.effectiveExcludes(config, installed)).containsExactly("ru.ozon.app")
}
/* ---------------- templateMatch ---------------- */
@Test
fun templateMatchReportsCategoryBeforeNamespace()
{
val config = RoutingConfig(enabledCategories = setOf(RoutingPresets.SHOP, RoutingPresets.NAMESPACE))
val match = RoutingManager.templateMatch("ru.ozon.app", config)
assertThat(match?.category).isEqualTo(RoutingPresets.SHOP)
}
@Test
fun keywordsAndPrefixesCombine()
fun templateMatchIsNullWhenNothingCoversThePackage()
{
val ruleSets = listOf(
RuleSet(id = "1", name = "Ru", enabled = true, prefixes = setOf("ru.")),
RuleSet(id = "2", name = "Yandex", enabled = true, keywords = setOf("yandex")),
)
val excludes = RoutingManager.effectiveExcludes(ruleSets, installed)
/* prefix ru.* plus keyword matching com.yandex.browser */
assertThat(excludes).containsExactlyInAnyOrder(
"ru.yandex.taxi", "ru.yandex.mail", "ru.ozon.app", "com.yandex.browser",
)
assertThat(excludes).doesNotContain("org.mozilla.firefox")
}
/* ---------------- applyGlobalExcludes ---------------- */
@Test
fun disableProfileBecomesExcludeWithGlobalApps()
{
val selected = mutableSetOf<String>()
val handling = RoutingManager.applyGlobalExcludes(
SelectedAppsHandling.SELECTED_APPS_DISABLE, selected, setOf("ru.yandex.taxi"),
)
assertThat(handling).isEqualTo(SelectedAppsHandling.SELECTED_APPS_EXCLUDE)
assertThat(selected).containsExactly("ru.yandex.taxi")
val config = RoutingConfig(enabledCategories = setOf(RoutingPresets.BANKS), packagePatterns = setOf("ru.yandex.*"))
assertThat(RoutingManager.templateMatch("org.mozilla.firefox", config)).isNull()
assertThat(RoutingManager.templateMatch("ru.yandex.taxi", config)?.pattern).isEqualTo("ru.yandex.*")
}
@Test
fun excludeProfileGainsGlobalApps()
fun disabledCategoryDoesNotCover()
{
val selected = mutableSetOf("com.example.existing")
val handling = RoutingManager.applyGlobalExcludes(
SelectedAppsHandling.SELECTED_APPS_EXCLUDE, selected, setOf("ru.yandex.taxi"),
)
assertThat(handling).isEqualTo(SelectedAppsHandling.SELECTED_APPS_EXCLUDE)
assertThat(selected).containsExactlyInAnyOrder("com.example.existing", "ru.yandex.taxi")
}
@Test
fun onlyProfileDropsGlobalAppsFromAllowedSet()
{
val selected = mutableSetOf("ru.yandex.taxi", "org.mozilla.firefox")
val handling = RoutingManager.applyGlobalExcludes(
SelectedAppsHandling.SELECTED_APPS_ONLY, selected, setOf("ru.yandex.taxi"),
)
assertThat(handling).isEqualTo(SelectedAppsHandling.SELECTED_APPS_ONLY)
assertThat(selected).containsExactly("org.mozilla.firefox")
}
@Test
fun emptyGlobalSetKeepsEverything()
{
val selected = mutableSetOf("com.example.app")
val handling = RoutingManager.applyGlobalExcludes(
SelectedAppsHandling.SELECTED_APPS_EXCLUDE, selected, emptySet(),
)
assertThat(handling).isEqualTo(SelectedAppsHandling.SELECTED_APPS_EXCLUDE)
assertThat(selected).containsExactly("com.example.app")
assertThat(RoutingManager.templateMatch("com.sberbank.mobile", RoutingConfig())).isNull()
}
}
@@ -0,0 +1,141 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.routing
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
class RoutingMigrationTest
{
private val own = "one.shx.strongswan.ext"
private val installed = setOf("a.one", "b.two", "c.three", own)
/* ---------------- rule sets -> direct apps + patterns ---------------- */
@Test
fun ruleSetPackagesBecomeDirectApps()
{
val result = RoutingMigration.migrateRuleSets(listOf(
RuleSet("1", "Mine", packages = setOf("a.one", "b.two")),
))
assertThat(result.directPackages).containsExactlyInAnyOrder("a.one", "b.two")
assertThat(result.patterns).isEmpty()
assertThat(result.categories).isEmpty()
}
@Test
fun prefixesAndKeywordsBecomePatterns()
{
val result = RoutingMigration.migrateRuleSets(listOf(
RuleSet("1", "Custom", prefixes = setOf("ru.custom."), keywords = setOf("MyBank")),
))
assertThat(result.patterns).containsExactlyInAnyOrder("ru.custom.*", "*mybank*")
}
@Test
fun disabledRuleSetsAreDropped()
{
val result = RoutingMigration.migrateRuleSets(listOf(
RuleSet("1", "Off", enabled = false, packages = setOf("a.one"), prefixes = setOf("ru.")),
))
assertThat(result.directPackages).isEmpty()
assertThat(result.patterns).isEmpty()
}
@Test
fun fullyCoveredCategoryBecomesCategoryAndLosesItsPatterns()
{
val ruleSets = RoutingPresets.presetsOf(RoutingPresets.GOV).map {
RuleSet(it.name, it.name, prefixes = it.prefixes.toSet(), keywords = it.keywords.toSet())
} + RuleSet("x", "Custom", prefixes = setOf("org.custom."))
val result = RoutingMigration.migrateRuleSets(ruleSets)
assertThat(result.categories).containsExactly(RoutingPresets.GOV)
assertThat(result.patterns).containsExactly("org.custom.*")
}
@Test
fun partiallyCoveredCategoryStaysAsPatterns()
{
val sber = RoutingPresets.presetsOf(RoutingPresets.BANKS).first()
val result = RoutingMigration.migrateRuleSets(listOf(
RuleSet("1", sber.name, keywords = sber.keywords.toSet()),
))
assertThat(result.categories).isEmpty()
assertThat(result.patterns).isNotEmpty()
}
@Test
fun migratedRuleSetsKeepTheirEffectiveCoverage()
{
val ruleSets = listOf(
RuleSet("1", "Yandex", prefixes = setOf("ru.yandex."), keywords = setOf("yandex")),
RuleSet("2", "Mine", packages = setOf("org.mozilla.firefox")),
)
val pkgs = setOf("ru.yandex.taxi", "com.yandex.browser", "org.mozilla.firefox", "org.other")
val migrated = RoutingMigration.migrateRuleSets(ruleSets)
val config = RoutingConfig(
directPackages = migrated.directPackages,
enabledCategories = migrated.categories,
packagePatterns = migrated.patterns,
)
assertThat(RoutingManager.effectiveExcludes(config, pkgs))
.containsExactlyInAnyOrder("ru.yandex.taxi", "com.yandex.browser", "org.mozilla.firefox")
}
/* ---------------- profile app lists -> direct apps ---------------- */
@Test
fun excludeProfileListBecomesDirectApps()
{
val direct = RoutingMigration.profileAppsToDirect(
SelectedAppsHandling.SELECTED_APPS_EXCLUDE, setOf("a.one", own), emptySet(), own)
assertThat(direct).containsExactly("a.one")
}
@Test
fun onlyProfileBecomesEveryOtherInstalledApp()
{
val direct = RoutingMigration.profileAppsToDirect(
SelectedAppsHandling.SELECTED_APPS_ONLY, setOf("a.one"), installed, own)
assertThat(direct).containsExactlyInAnyOrder("b.two", "c.three")
}
@Test
fun emptyOnlyProfileYieldsNothingBecauseTheOldCodeIgnoredIt()
{
val direct = RoutingMigration.profileAppsToDirect(
SelectedAppsHandling.SELECTED_APPS_ONLY, emptySet(), installed, own)
assertThat(direct).isEmpty()
}
@Test
fun disabledProfileYieldsNothing()
{
val direct = RoutingMigration.profileAppsToDirect(
SelectedAppsHandling.SELECTED_APPS_DISABLE, setOf("a.one"), installed, own)
assertThat(direct).isEmpty()
}
@Test
fun mergingTheSameMigrationTwiceChangesNothing()
{
val first = RoutingMigration.profileAppsToDirect(
SelectedAppsHandling.SELECTED_APPS_ONLY, setOf("a.one"), installed, own)
val config = RoutingConfig(directPackages = first)
val again = config.copy(directPackages = config.directPackages + first)
assertThat(again).isEqualTo(config)
}
}
@@ -0,0 +1,106 @@
/*
* 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.ui.compose.routing
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.strongswan.android.logic.routing.RoutingConfig
import org.strongswan.android.logic.routing.RoutingPresets
class DirectAppsStateTest
{
private val installed = listOf(
InstalledApp("com.sberbank.mobile", "Sber", false),
InstalledApp("ru.ozon.app", "Ozon", false),
InstalledApp("org.telegram.messenger", "Telegram", false),
InstalledApp("com.android.systemapp", "System thing", true),
)
private fun build(
config: RoutingConfig,
query: String = "",
filter: AppsFilter = AppsFilter.ALL,
showSystem: Boolean = false,
) = DirectApps.buildUiState(installed, config, query, filter, showSystem)
private fun DirectAppsUiState.rows() = groups.flatMap { it.rows }
@Test
fun loadingStateWhileAppsAreNotLoaded()
{
val state = DirectApps.buildUiState(null, RoutingConfig(), "", AppsFilter.DIRECT, false)
assertThat(state.loading).isTrue()
assertThat(state.groups).isEmpty()
}
@Test
fun systemAppsAreHiddenUnlessShownOrDirect()
{
assertThat(build(RoutingConfig()).rows().map { it.packageName }).doesNotContain("com.android.systemapp")
assertThat(build(RoutingConfig(), showSystem = true).rows().map { it.packageName }).contains("com.android.systemapp")
val direct = RoutingConfig(directPackages = setOf("com.android.systemapp"))
assertThat(build(direct).rows().map { it.packageName }).contains("com.android.systemapp")
}
@Test
fun groupsFollowCategoryOrderWithOtherLast()
{
val state = build(RoutingConfig())
assertThat(state.groups.map { it.category }).containsExactly(RoutingPresets.BANKS, RoutingPresets.SHOP, null)
}
@Test
fun filterCountsAndRows()
{
val config = RoutingConfig(enabledCategories = setOf(RoutingPresets.BANKS))
val all = build(config)
assertThat(all.directCount).isEqualTo(1)
assertThat(all.tunnelCount).isEqualTo(2)
assertThat(build(config, filter = AppsFilter.DIRECT).rows().map { it.packageName }).containsExactly("com.sberbank.mobile")
assertThat(build(config, filter = AppsFilter.VPN).rows().map { it.packageName })
.containsExactlyInAnyOrder("ru.ozon.app", "org.telegram.messenger")
}
@Test
fun searchMatchesLabelAndPackageName()
{
assertThat(build(RoutingConfig(), query = "ozon").rows().map { it.packageName }).containsExactly("ru.ozon.app")
assertThat(build(RoutingConfig(), query = "TELEGRAM.mess").rows().map { it.packageName }).containsExactly("org.telegram.messenger")
assertThat(build(RoutingConfig(), query = "nothing-like-this").groups).isEmpty()
}
@Test
fun reasonsDescribeWhyAnAppIsInItsState()
{
val config = RoutingConfig(
directPackages = setOf("org.telegram.messenger"),
enabledCategories = setOf(RoutingPresets.SHOP, RoutingPresets.BANKS),
tunnelOverrides = setOf("com.sberbank.mobile"),
)
val byPkg = build(config).rows().associateBy { it.packageName }
assertThat(byPkg.getValue("org.telegram.messenger").reason).isEqualTo(AppReason.Added)
assertThat(byPkg.getValue("ru.ozon.app").reason).isInstanceOf(AppReason.Template::class.java)
assertThat(byPkg.getValue("com.sberbank.mobile").reason).isInstanceOf(AppReason.Override::class.java)
assertThat(byPkg.getValue("com.sberbank.mobile").direct).isFalse()
}
@Test
fun allRfIsDoneOnlyWhenEveryCategoryAndNamespaceAreOn()
{
assertThat(build(RoutingConfig()).allRfDone).isFalse()
assertThat(build(RoutingConfig(enabledCategories = RoutingPresets.ALL_CATEGORIES)).allRfDone).isTrue()
assertThat(build(RoutingConfig(enabledCategories = RoutingPresets.CATEGORIES.toSet())).allRfDone).isFalse()
}
}