Compare commits
6
Commits
584004a25c
...
5e38fc2f76
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e38fc2f76 | ||
|
|
c23d4dad8b | ||
|
|
e2ce8420a7 | ||
|
|
488c0aa210 | ||
|
|
48f1ed7fb8 | ||
|
|
389712bba3 |
No files matched your search
@@ -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}\""
|
||||
|
||||
+91
@@ -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" >
|
||||
|
||||
+15
-53
@@ -51,7 +51,6 @@ import android.util.Log;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.data.VpnProfile;
|
||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
|
||||
import org.strongswan.android.data.VpnProfileDataSource;
|
||||
import org.strongswan.android.data.VpnProfileSource;
|
||||
import org.strongswan.android.data.VpnType.VpnTypeFeature;
|
||||
@@ -92,6 +91,7 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.SortedSet;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
@@ -2098,7 +2098,6 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
* the OS routes here */
|
||||
private final boolean mUserspaceRouting;
|
||||
private final int mSplitTunneling;
|
||||
private final SelectedAppsHandling mAppHandling;
|
||||
private final SortedSet<String> mSelectedApps;
|
||||
private final List<InetAddress> mDnsServers = new ArrayList<>();
|
||||
/* addresses of the system's private DNS server (empty if none is
|
||||
@@ -2145,26 +2144,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
mExcludedSubnets = IPRangeSet.fromString(profile.getExcludedSubnets());
|
||||
Integer splitTunneling = profile.getSplitTunneling();
|
||||
mSplitTunneling = splitTunneling != null ? splitTunneling : 0;
|
||||
SelectedAppsHandling appHandling = profile.getSelectedAppsHandling();
|
||||
mSelectedApps = profile.getSelectedAppsSet();
|
||||
/* exclude our own app, otherwise the fetcher is blocked */
|
||||
switch (appHandling)
|
||||
{
|
||||
case SELECTED_APPS_DISABLE:
|
||||
appHandling = SelectedAppsHandling.SELECTED_APPS_EXCLUDE;
|
||||
mSelectedApps.clear();
|
||||
/* fall-through */
|
||||
case SELECTED_APPS_EXCLUDE:
|
||||
mSelectedApps.add(getPackageName());
|
||||
break;
|
||||
case SELECTED_APPS_ONLY:
|
||||
mSelectedApps.remove(getPackageName());
|
||||
break;
|
||||
}
|
||||
/* global routing rules: packages that must bypass the tunnel */
|
||||
Set<String> globalExcludes = RoutingManager.getInstance(CharonVpnService.this).computeExcludedPackages();
|
||||
appHandling = RoutingManager.applyGlobalExcludes(appHandling, mSelectedApps, globalExcludes);
|
||||
mAppHandling = appHandling;
|
||||
/* the only per-app routing is the global one (Routing tab): its
|
||||
* packages bypass the tunnel. The old per-profile handling is gone
|
||||
* (migrated by RoutingManager); our own app is always excluded,
|
||||
* otherwise the fetchers are blocked */
|
||||
mSelectedApps = new TreeSet<>(RoutingManager.getInstance(CharonVpnService.this).computeExcludedPackages());
|
||||
mSelectedApps.add(getPackageName());
|
||||
|
||||
/* RU geoip exclusions: a large presorted list. It is never passed
|
||||
* to VpnService's excludeRoute() - on some devices (One UI was
|
||||
@@ -2512,39 +2497,16 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
{
|
||||
applyRoutesWithGeo(builder, rangesV6, mUserspaceRouting ? Collections.<IPRange>emptyList() : geoV6);
|
||||
}
|
||||
/* apply selected applications */
|
||||
if (mSelectedApps.size() > 0)
|
||||
/* apply the directly routed applications (never empty: our own package) */
|
||||
for (String app : mSelectedApps)
|
||||
{
|
||||
switch (mAppHandling)
|
||||
try
|
||||
{
|
||||
case SELECTED_APPS_EXCLUDE:
|
||||
for (String app : mSelectedApps)
|
||||
{
|
||||
try
|
||||
{
|
||||
builder.addDisallowedApplication(app);
|
||||
}
|
||||
catch (PackageManager.NameNotFoundException e)
|
||||
{
|
||||
// possible if not configured via GUI or app was uninstalled
|
||||
}
|
||||
}
|
||||
break;
|
||||
case SELECTED_APPS_ONLY:
|
||||
for (String app : mSelectedApps)
|
||||
{
|
||||
try
|
||||
{
|
||||
builder.addAllowedApplication(app);
|
||||
}
|
||||
catch (PackageManager.NameNotFoundException e)
|
||||
{
|
||||
// possible if not configured via GUI or app was uninstalled
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
builder.addDisallowedApplication(app);
|
||||
}
|
||||
catch (PackageManager.NameNotFoundException e)
|
||||
{
|
||||
// app was uninstalled since the list was built
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && mProxyServer != null)
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
/**
|
||||
* User-defined package name patterns ("package templates" in the UI):
|
||||
* - "ru.*" - package names starting with "ru."
|
||||
* - "*yandex*" - package names containing "yandex" anywhere
|
||||
* - "*.beta" - package names ending with ".beta"
|
||||
* - "ru.ozon.app" - without a star: prefix match, same as "ru.ozon.app*"
|
||||
* Matching is case-insensitive.
|
||||
*/
|
||||
object PackagePatterns
|
||||
{
|
||||
private val ALLOWED = Regex("[a-z0-9._*-]+")
|
||||
private val STARS = Regex("\\*+")
|
||||
|
||||
/** Canonical form of user input, or null if it is empty, too broad or has invalid characters. */
|
||||
fun normalize(input: String): String?
|
||||
{
|
||||
val text = input.trim().lowercase()
|
||||
if (text.isEmpty() || !ALLOWED.matches(text))
|
||||
{
|
||||
return null
|
||||
}
|
||||
val collapsed = text.replace(STARS, "*")
|
||||
if (collapsed.replace("*", "").isEmpty())
|
||||
{
|
||||
return null
|
||||
}
|
||||
return if (collapsed.contains('*')) collapsed else "$collapsed*"
|
||||
}
|
||||
|
||||
fun matches(pattern: String, pkg: String): Boolean
|
||||
{
|
||||
val core = pattern.trim('*').lowercase()
|
||||
if (core.isEmpty())
|
||||
{
|
||||
return false
|
||||
}
|
||||
val name = pkg.lowercase()
|
||||
val starts = pattern.startsWith("*")
|
||||
val ends = pattern.endsWith("*")
|
||||
return when
|
||||
{
|
||||
starts && ends -> name.contains(core)
|
||||
ends -> name.startsWith(core)
|
||||
starts -> name.endsWith(core)
|
||||
else -> name == core
|
||||
}
|
||||
}
|
||||
|
||||
/** Packages (of [installed]) matched by any of the patterns. */
|
||||
fun matchAll(patterns: Set<String>, installed: Set<String>): Set<String> =
|
||||
if (patterns.isEmpty())
|
||||
{
|
||||
emptySet()
|
||||
}
|
||||
else
|
||||
{
|
||||
installed.filterTo(HashSet()) { pkg -> patterns.any { matches(it, pkg) } }
|
||||
}
|
||||
|
||||
fun fromPrefix(prefix: String): String = "$prefix*"
|
||||
|
||||
fun fromKeyword(keyword: String): String = "*${keyword.lowercase()}*"
|
||||
}
|
||||
+402
-266
@@ -15,9 +15,11 @@
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Handler
|
||||
@@ -34,10 +36,15 @@ import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
|
||||
import org.strongswan.android.data.VpnProfileSqlDataSource
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.routing.RoutingPresets.matches
|
||||
import java.util.UUID
|
||||
|
||||
/** A named set of applications that bypass the VPN tunnel. */
|
||||
/**
|
||||
* A named set of applications that bypassed the VPN tunnel (pre-2.16 model).
|
||||
* Only used to migrate old settings, see [RoutingMigration.migrateRuleSets].
|
||||
*/
|
||||
data class RuleSet(
|
||||
val id: String,
|
||||
val name: String,
|
||||
@@ -58,25 +65,51 @@ data class ForceTunnelPrefix(
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* The one global answer to "what goes around the VPN" for apps:
|
||||
* [effectiveExcludes] = ([directPackages] + apps matched by [enabledCategories]
|
||||
* and [packagePatterns]) - [tunnelOverrides].
|
||||
*/
|
||||
data class RoutingConfig(
|
||||
val enabled: Boolean = true,
|
||||
val ruleSets: List<RuleSet> = emptyList(),
|
||||
/** Apps the user switched to "direct" one by one. */
|
||||
val directPackages: Set<String> = emptySet(),
|
||||
/** Apps switched back to "via VPN" although a category or pattern covers them. */
|
||||
val tunnelOverrides: Set<String> = emptySet(),
|
||||
/** Enabled built-in categories ([RoutingPresets] ids). */
|
||||
val enabledCategories: Set<String> = emptySet(),
|
||||
/** User package patterns, see [PackagePatterns]. */
|
||||
val packagePatterns: Set<String> = emptySet(),
|
||||
val forceTunnelPrefixes: List<ForceTunnelPrefix> = emptyList(),
|
||||
val version: Int = RoutingManager.CONFIG_VERSION,
|
||||
)
|
||||
|
||||
/** Why a package is (not) direct, for the per-app reason line. */
|
||||
data class TemplateMatch(
|
||||
/** Built-in category id (may be [RoutingPresets.NAMESPACE]) or null. */
|
||||
val category: String? = null,
|
||||
/** User pattern or null. */
|
||||
val pattern: String? = null,
|
||||
)
|
||||
|
||||
data class RoutingState(
|
||||
val config: RoutingConfig = RoutingConfig(),
|
||||
/** Number of profiles whose "only selected apps" list was converted at migration (one-time notice). */
|
||||
val migratedOnlyProfiles: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Global per-app routing: named rule sets of applications that must NOT use
|
||||
* the VPN tunnel (they go direct), independent of the per-profile split
|
||||
* tunneling settings. [CharonVpnService.BuilderCache] merges the effective
|
||||
* package list into the TUN builder on every (re)connect.
|
||||
* Global per-app routing: one list of applications that must NOT use the VPN
|
||||
* tunnel (they go direct). [CharonVpnService.BuilderCache] merges the
|
||||
* effective package list into the TUN builder on every (re)connect.
|
||||
*
|
||||
* When rules change while the VPN is connected the connection is restarted
|
||||
* automatically (debounced), because per-app routing only applies when the
|
||||
* TUN interface is created.
|
||||
* When the list changes while the VPN is connected the connection is
|
||||
* restarted automatically (debounced), because per-app routing only applies
|
||||
* when the TUN interface is created.
|
||||
*
|
||||
* The constructor migrates pre-2.16 settings (rule sets, per-profile app
|
||||
* lists) once, so it has to run before the first VPN start - it does, from
|
||||
* StrongSwanApplication.onCreate.
|
||||
*/
|
||||
class RoutingManager private constructor(private val context: Context)
|
||||
{
|
||||
@@ -85,8 +118,9 @@ class RoutingManager private constructor(private val context: Context)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private var config = RoutingConfig()
|
||||
private var service: VpnStateService? = null
|
||||
private var migratedOnlyProfiles = 0
|
||||
|
||||
/* package cache: expanded prefixes against installed apps, keyed by config version */
|
||||
/* package cache: expanded patterns against installed apps, keyed by config version */
|
||||
@Volatile
|
||||
private var configVersion = 0L
|
||||
|
||||
@@ -118,15 +152,33 @@ class RoutingManager private constructor(private val context: Context)
|
||||
}
|
||||
}
|
||||
|
||||
/* new/removed apps change what categories and patterns match */
|
||||
private val packagesReceiver = object : BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(context: Context?, intent: Intent?)
|
||||
{
|
||||
invalidateCache()
|
||||
scope.launch { computeExcludedPackages() }
|
||||
}
|
||||
}
|
||||
|
||||
init
|
||||
{
|
||||
config = loadConfig()
|
||||
migrateProfiles()
|
||||
migratedOnlyProfiles = prefs.getInt(KEY_NOTICE_ONLY, 0)
|
||||
publish()
|
||||
}
|
||||
|
||||
fun start()
|
||||
{
|
||||
context.bindService(Intent(context, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE)
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
context.registerReceiver(packagesReceiver, filter)
|
||||
}
|
||||
|
||||
fun updateConfig(transform: (RoutingConfig) -> RoutingConfig)
|
||||
@@ -149,90 +201,104 @@ class RoutingManager private constructor(private val context: Context)
|
||||
updateConfig { it.copy(enabled = enabled) }
|
||||
}
|
||||
|
||||
/** Adds an empty rule set and returns its id so the UI can expand and focus it. */
|
||||
fun addRuleSet(): String
|
||||
{
|
||||
val id = UUID.randomUUID().toString()
|
||||
updateConfig { config ->
|
||||
config.copy(ruleSets = config.ruleSets + RuleSet(id = id, name = ""))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
fun deleteRuleSet(id: String)
|
||||
{
|
||||
updateConfig { config -> config.copy(ruleSets = config.ruleSets.filter { it.id != id }) }
|
||||
}
|
||||
|
||||
/** Re-inserts a just-deleted rule set (the snackbar "Undo" action). */
|
||||
fun restoreRuleSet(ruleSet: RuleSet)
|
||||
{
|
||||
updateConfig { config -> config.copy(ruleSets = config.ruleSets + ruleSet) }
|
||||
}
|
||||
|
||||
/** A rename never affects routing, so it must not schedule a reconnect. */
|
||||
fun updateRuleSetName(id: String, name: String)
|
||||
{
|
||||
config = config.copy(ruleSets = config.ruleSets.map { if (it.id == id) it.copy(name = name) else it })
|
||||
saveConfig(config)
|
||||
publish()
|
||||
}
|
||||
|
||||
fun setRuleSetEnabled(id: String, enabled: Boolean)
|
||||
{
|
||||
updateConfig { config -> config.copy(ruleSets = config.ruleSets.map { if (it.id == id) it.copy(enabled = enabled) else it }) }
|
||||
}
|
||||
|
||||
fun updateRuleSetPackages(id: String, packages: Set<String>)
|
||||
{
|
||||
updateConfig { config -> config.copy(ruleSets = config.ruleSets.map { if (it.id == id) it.copy(packages = packages) else it }) }
|
||||
}
|
||||
|
||||
/** Toggles a preset on a rule set: adds its prefixes/keywords, or removes them if all present. */
|
||||
fun togglePreset(id: String, preset: RoutingPresets.Preset)
|
||||
/**
|
||||
* The per-app switch: "direct" adds the app (or drops its tunnel
|
||||
* override when a category/pattern already covers it); "via VPN" removes
|
||||
* it, keeping a personal override when a category/pattern covers it.
|
||||
*/
|
||||
fun setAppDirect(pkg: String, direct: Boolean)
|
||||
{
|
||||
updateConfig { config ->
|
||||
config.copy(ruleSets = config.ruleSets.map { ruleSet ->
|
||||
if (ruleSet.id != id)
|
||||
{
|
||||
ruleSet
|
||||
}
|
||||
else if (ruleSet.prefixes.containsAll(preset.prefixes) && ruleSet.keywords.containsAll(preset.keywords))
|
||||
{
|
||||
ruleSet.copy(
|
||||
prefixes = ruleSet.prefixes - preset.prefixes.toSet(),
|
||||
keywords = ruleSet.keywords - preset.keywords,
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
ruleSet.copy(
|
||||
prefixes = ruleSet.prefixes + preset.prefixes,
|
||||
keywords = ruleSet.keywords + preset.keywords.map { it.lowercase() },
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Quick action: enable every preset of the RF bundle as its own rule set. */
|
||||
fun applyRfBundle()
|
||||
{
|
||||
updateConfig { config ->
|
||||
val sets = config.ruleSets.toMutableList()
|
||||
for (preset in RoutingPresets.RF_BUNDLE)
|
||||
val covered = templateMatch(pkg, config) != null
|
||||
if (direct)
|
||||
{
|
||||
val index = sets.indexOfFirst { it.name == preset.name }
|
||||
if (index < 0)
|
||||
{
|
||||
sets.add(preset.asRuleSet())
|
||||
}
|
||||
else if (!sets[index].enabled)
|
||||
{
|
||||
sets[index] = sets[index].copy(enabled = true)
|
||||
}
|
||||
config.copy(
|
||||
enabled = true,
|
||||
tunnelOverrides = config.tunnelOverrides - pkg,
|
||||
directPackages = if (covered) config.directPackages else config.directPackages + pkg,
|
||||
)
|
||||
}
|
||||
config.copy(enabled = true, ruleSets = sets)
|
||||
else
|
||||
{
|
||||
config.copy(
|
||||
directPackages = config.directPackages - pkg,
|
||||
tunnelOverrides = if (covered) config.tunnelOverrides + pkg else config.tunnelOverrides,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Switches a built-in category on or off. Switching on also clears personal "via VPN" exceptions inside it. */
|
||||
fun setCategoryEnabled(category: String, enabled: Boolean)
|
||||
{
|
||||
updateConfig { config ->
|
||||
if (enabled)
|
||||
{
|
||||
config.copy(
|
||||
enabled = true,
|
||||
enabledCategories = config.enabledCategories + category,
|
||||
tunnelOverrides = config.tunnelOverrides.filterNot { RoutingPresets.matchesCategory(category, it) }.toSet(),
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
config.copy(enabledCategories = config.enabledCategories - category)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Quick action: every RF category plus the ru.* namespace ("all RF apps"). */
|
||||
fun enableAllRfApps()
|
||||
{
|
||||
updateConfig { config ->
|
||||
config.copy(
|
||||
enabled = true,
|
||||
enabledCategories = config.enabledCategories + RoutingPresets.ALL_CATEGORIES,
|
||||
tunnelOverrides = config.tunnelOverrides.filterNot { pkg ->
|
||||
RoutingPresets.ALL_CATEGORIES.any { RoutingPresets.matchesCategory(it, pkg) }
|
||||
}.toSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds a package pattern; false if the input is invalid. */
|
||||
fun addPattern(input: String): Boolean
|
||||
{
|
||||
val pattern = PackagePatterns.normalize(input) ?: return false
|
||||
updateConfig { it.copy(packagePatterns = it.packagePatterns + pattern) }
|
||||
return true
|
||||
}
|
||||
|
||||
fun removePattern(pattern: String)
|
||||
{
|
||||
updateConfig { it.copy(packagePatterns = it.packagePatterns - pattern) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the split-tunneling apps of a profile file (JSON import): the
|
||||
* per-profile setting no longer exists, so the apps join the global
|
||||
* direct list ("excluded-apps" as is, "apps" as every other app).
|
||||
*/
|
||||
fun importProfileApps(handling: SelectedAppsHandling, selected: Set<String>)
|
||||
{
|
||||
val added = RoutingMigration.profileAppsToDirect(
|
||||
handling, selected,
|
||||
if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY) loadInstalledPackages() else emptySet(),
|
||||
context.packageName)
|
||||
if (added.isNotEmpty())
|
||||
{
|
||||
updateConfig { it.copy(enabled = true, directPackages = it.directPackages + added, tunnelOverrides = it.tunnelOverrides - added) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The one-time "your 'only selected apps' profile was converted" notice has been shown. */
|
||||
fun consumeMigrationNotice()
|
||||
{
|
||||
if (migratedOnlyProfiles != 0)
|
||||
{
|
||||
migratedOnlyProfiles = 0
|
||||
prefs.edit().putInt(KEY_NOTICE_ONLY, 0).apply()
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +384,8 @@ class RoutingManager private constructor(private val context: Context)
|
||||
*/
|
||||
fun computeExcludedPackages(): Set<String>
|
||||
{
|
||||
if (!config.enabled || config.ruleSets.none { it.enabled })
|
||||
val current = config
|
||||
if (!current.enabled)
|
||||
{
|
||||
return emptySet()
|
||||
}
|
||||
@@ -337,7 +404,7 @@ class RoutingManager private constructor(private val context: Context)
|
||||
return packages
|
||||
}
|
||||
}
|
||||
val packages = effectiveExcludes(config.ruleSets, loadInstalledPackages())
|
||||
val packages = effectiveExcludes(current, loadInstalledPackages())
|
||||
packageCache = version to packages
|
||||
return packages
|
||||
}
|
||||
@@ -349,10 +416,213 @@ class RoutingManager private constructor(private val context: Context)
|
||||
packageCache = null
|
||||
}
|
||||
|
||||
private fun loadInstalledPackages(): Set<String>
|
||||
private fun loadInstalledPackages(): Set<String> = loadInternetPackages(context.packageManager)
|
||||
|
||||
private fun publish()
|
||||
{
|
||||
val pm = context.packageManager
|
||||
_state.value = RoutingState(config = config, migratedOnlyProfiles = migratedOnlyProfiles)
|
||||
}
|
||||
|
||||
private fun scheduleReconnect()
|
||||
{
|
||||
handler.removeCallbacks(reconnectRunnable)
|
||||
handler.postDelayed(reconnectRunnable, RECONNECT_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/* ------------------------- persistence + migration ------------------------- */
|
||||
|
||||
private fun loadConfig(): RoutingConfig
|
||||
{
|
||||
val json = prefs.getString(KEY_CONFIG, null) ?: return RoutingConfig()
|
||||
return try
|
||||
{
|
||||
val obj = JSONObject(json)
|
||||
if (obj.optInt("version", 1) >= CONFIG_VERSION)
|
||||
{
|
||||
parseConfig(obj)
|
||||
}
|
||||
else
|
||||
{
|
||||
migrateLegacyConfig(json, obj)
|
||||
}
|
||||
}
|
||||
catch (_: JSONException)
|
||||
{
|
||||
RoutingConfig()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseConfig(obj: JSONObject) = RoutingConfig(
|
||||
enabled = obj.optBoolean("enabled", true),
|
||||
directPackages = obj.optJSONArray("directPackages").toStringSet(),
|
||||
tunnelOverrides = obj.optJSONArray("tunnelOverrides").toStringSet(),
|
||||
enabledCategories = obj.optJSONArray("enabledCategories").toStringSet(),
|
||||
packagePatterns = obj.optJSONArray("packagePatterns").toStringSet(),
|
||||
forceTunnelPrefixes = parseForcePrefixes(obj),
|
||||
version = CONFIG_VERSION,
|
||||
)
|
||||
|
||||
private fun parseForcePrefixes(obj: JSONObject): List<ForceTunnelPrefix> =
|
||||
obj.optJSONArray("forceTunnelPrefixes")?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
arr.optJSONObject(i)?.let { o ->
|
||||
ForceTunnelPrefix(
|
||||
id = o.optString("id"),
|
||||
prefix = o.optString("prefix"),
|
||||
enabled = o.optBoolean("enabled", true),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
/** Rule sets (version 1) -> direct apps + package patterns; the old JSON is kept in a backup key. */
|
||||
private fun migrateLegacyConfig(json: String, obj: JSONObject): RoutingConfig
|
||||
{
|
||||
if (!prefs.contains(KEY_CONFIG_BACKUP))
|
||||
{
|
||||
prefs.edit().putString(KEY_CONFIG_BACKUP, json).commit()
|
||||
}
|
||||
val ruleSets = obj.optJSONArray("ruleSets")?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
arr.optJSONObject(i)?.let { o ->
|
||||
RuleSet(
|
||||
id = o.optString("id"),
|
||||
name = o.optString("name"),
|
||||
enabled = o.optBoolean("enabled", true),
|
||||
packages = o.optJSONArray("packages").toStringSet(),
|
||||
prefixes = o.optJSONArray("prefixes").toStringSet(),
|
||||
keywords = o.optJSONArray("keywords").toStringSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
val migrated = RoutingMigration.migrateRuleSets(ruleSets)
|
||||
val result = RoutingConfig(
|
||||
enabled = obj.optBoolean("enabled", true),
|
||||
directPackages = migrated.directPackages,
|
||||
enabledCategories = migrated.categories,
|
||||
packagePatterns = migrated.patterns,
|
||||
forceTunnelPrefixes = parseForcePrefixes(obj),
|
||||
)
|
||||
Log.i(TAG, "Migrated ${ruleSets.size} rule sets to ${result.directPackages.size} apps, " +
|
||||
"${result.packagePatterns.size} patterns, ${result.enabledCategories.size} categories")
|
||||
saveConfig(result, commit = true)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-profile app handling is gone: EXCLUDE lists become direct apps, an
|
||||
* ONLY list becomes "every other installed internet app" (snapshot), and
|
||||
* the profile is reset to DISABLE. Runs once (flag in prefs); a failure
|
||||
* leaves the flag unset so the next start retries.
|
||||
*/
|
||||
private fun migrateProfiles()
|
||||
{
|
||||
if (prefs.getBoolean(KEY_PROFILES_MIGRATED, false))
|
||||
{
|
||||
return
|
||||
}
|
||||
try
|
||||
{
|
||||
val source = VpnProfileSqlDataSource()
|
||||
source.open()
|
||||
val direct = HashSet<String>()
|
||||
var onlyConverted = 0
|
||||
try
|
||||
{
|
||||
val installed by lazy { loadInstalledPackages() }
|
||||
for (profile in source.allVpnProfiles)
|
||||
{
|
||||
val handling = profile.selectedAppsHandling
|
||||
if (handling == SelectedAppsHandling.SELECTED_APPS_DISABLE)
|
||||
{
|
||||
continue
|
||||
}
|
||||
val added = RoutingMigration.profileAppsToDirect(
|
||||
handling, profile.selectedAppsSet, if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY) installed else emptySet(),
|
||||
context.packageName)
|
||||
direct.addAll(added)
|
||||
if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY && added.isNotEmpty())
|
||||
{
|
||||
onlyConverted++
|
||||
}
|
||||
profile.setSelectedAppsHandling(SelectedAppsHandling.SELECTED_APPS_DISABLE)
|
||||
source.updateVpnProfile(profile)
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
if (direct.isNotEmpty())
|
||||
{ /* the old per-profile excludes applied regardless of the global switch */
|
||||
config = config.copy(enabled = true, directPackages = config.directPackages + direct,
|
||||
tunnelOverrides = config.tunnelOverrides - direct)
|
||||
saveConfig(config, commit = true)
|
||||
}
|
||||
prefs.edit()
|
||||
.putBoolean(KEY_PROFILES_MIGRATED, true)
|
||||
.putInt(KEY_NOTICE_ONLY, onlyConverted)
|
||||
.commit()
|
||||
Log.i(TAG, "Migrated profile apps: ${direct.size} direct apps, $onlyConverted 'only' profiles converted")
|
||||
}
|
||||
catch (e: Throwable)
|
||||
{
|
||||
Log.w(TAG, "Profile app migration failed, will retry on next start", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveConfig(config: RoutingConfig, commit: Boolean = false)
|
||||
{
|
||||
val obj = JSONObject()
|
||||
obj.put("version", CONFIG_VERSION)
|
||||
obj.put("enabled", config.enabled)
|
||||
obj.put("directPackages", JSONArray(config.directPackages.sorted()))
|
||||
obj.put("tunnelOverrides", JSONArray(config.tunnelOverrides.sorted()))
|
||||
obj.put("enabledCategories", JSONArray(config.enabledCategories.sorted()))
|
||||
obj.put("packagePatterns", JSONArray(config.packagePatterns.sorted()))
|
||||
val forcePrefixes = JSONArray()
|
||||
for (entry in config.forceTunnelPrefixes)
|
||||
{
|
||||
val o = JSONObject()
|
||||
o.put("id", entry.id)
|
||||
o.put("prefix", entry.prefix)
|
||||
o.put("enabled", entry.enabled)
|
||||
forcePrefixes.put(o)
|
||||
}
|
||||
obj.put("forceTunnelPrefixes", forcePrefixes)
|
||||
val editor = prefs.edit().putString(KEY_CONFIG, obj.toString())
|
||||
if (commit) editor.commit() else editor.apply()
|
||||
}
|
||||
|
||||
private fun JSONArray?.toStringSet(): Set<String> =
|
||||
this?.let { arr -> (0 until arr.length()).mapNotNull { i -> arr.optString(i).takeIf { it.isNotBlank() } }.toSet() } ?: emptySet()
|
||||
|
||||
companion object
|
||||
{
|
||||
private const val TAG = "RoutingManager"
|
||||
private const val PREFS_NAME = "one.shx.strongswan.ext.ROUTING"
|
||||
private const val KEY_CONFIG = "config"
|
||||
private const val KEY_CONFIG_BACKUP = "config_v1_backup"
|
||||
private const val KEY_PROFILES_MIGRATED = "profiles_migrated_v2"
|
||||
private const val KEY_NOTICE_ONLY = "notice_only_profiles"
|
||||
private const val RECONNECT_DEBOUNCE_MS = 2000L
|
||||
|
||||
/** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */
|
||||
const val CONFIG_VERSION = 2
|
||||
|
||||
/** Validates and canonicalizes a CIDR/address; null if invalid. */
|
||||
fun normalizePrefix(cidr: String): String? = try
|
||||
{
|
||||
org.strongswan.android.utils.IPRange(cidr.trim()).toString()
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
null
|
||||
}
|
||||
|
||||
/** All installed apps that hold the INTERNET permission. */
|
||||
fun loadInternetPackages(pm: PackageManager): Set<String> = try
|
||||
{
|
||||
pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
||||
.asSequence()
|
||||
@@ -365,193 +635,59 @@ class RoutingManager private constructor(private val context: Context)
|
||||
Log.w(TAG, "Failed to list installed packages", e)
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun publish()
|
||||
{
|
||||
_state.value = RoutingState(config = config)
|
||||
}
|
||||
|
||||
private fun scheduleReconnect()
|
||||
{
|
||||
handler.removeCallbacks(reconnectRunnable)
|
||||
handler.postDelayed(reconnectRunnable, RECONNECT_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
private fun loadConfig(): RoutingConfig
|
||||
{
|
||||
val json = prefs.getString(KEY_CONFIG, null) ?: return RoutingConfig()
|
||||
return try
|
||||
/** Built-in categories that are enabled in [config] and match [pkg], visible ones first. */
|
||||
private fun matchingCategory(pkg: String, config: RoutingConfig): String?
|
||||
{
|
||||
val obj = JSONObject(json)
|
||||
RoutingConfig(
|
||||
enabled = obj.optBoolean("enabled", true),
|
||||
ruleSets = obj.optJSONArray("ruleSets")?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
arr.optJSONObject(i)?.let { o ->
|
||||
RuleSet(
|
||||
id = o.optString("id"),
|
||||
name = o.optString("name"),
|
||||
enabled = o.optBoolean("enabled", true),
|
||||
packages = o.optJSONArray("packages").toStringSet(),
|
||||
prefixes = o.optJSONArray("prefixes").toStringSet(),
|
||||
keywords = o.optJSONArray("keywords").toStringSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList(),
|
||||
forceTunnelPrefixes = obj.optJSONArray("forceTunnelPrefixes")?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
arr.optJSONObject(i)?.let { o ->
|
||||
ForceTunnelPrefix(
|
||||
id = o.optString("id"),
|
||||
prefix = o.optString("prefix"),
|
||||
enabled = o.optBoolean("enabled", true),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList(),
|
||||
)
|
||||
}
|
||||
catch (_: JSONException)
|
||||
{
|
||||
RoutingConfig()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveConfig(config: RoutingConfig)
|
||||
{
|
||||
val obj = JSONObject()
|
||||
obj.put("enabled", config.enabled)
|
||||
val sets = JSONArray()
|
||||
for (ruleSet in config.ruleSets)
|
||||
{
|
||||
val o = JSONObject()
|
||||
o.put("id", ruleSet.id)
|
||||
o.put("name", ruleSet.name)
|
||||
o.put("enabled", ruleSet.enabled)
|
||||
o.put("packages", JSONArray(ruleSet.packages.sorted()))
|
||||
o.put("prefixes", JSONArray(ruleSet.prefixes.sorted()))
|
||||
o.put("keywords", JSONArray(ruleSet.keywords.map { it.lowercase() }.sorted()))
|
||||
sets.put(o)
|
||||
}
|
||||
obj.put("ruleSets", sets)
|
||||
val forcePrefixes = JSONArray()
|
||||
for (entry in config.forceTunnelPrefixes)
|
||||
{
|
||||
val o = JSONObject()
|
||||
o.put("id", entry.id)
|
||||
o.put("prefix", entry.prefix)
|
||||
o.put("enabled", entry.enabled)
|
||||
forcePrefixes.put(o)
|
||||
}
|
||||
obj.put("forceTunnelPrefixes", forcePrefixes)
|
||||
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
|
||||
}
|
||||
|
||||
private fun JSONArray?.toStringSet(): Set<String> =
|
||||
this?.let { arr -> (0 until arr.length()).mapNotNull { i -> arr.optString(i).takeIf { it.isNotBlank() } }.toSet() } ?: emptySet()
|
||||
|
||||
companion object
|
||||
{
|
||||
private const val TAG = "RoutingManager"
|
||||
private const val PREFS_NAME = "one.shx.strongswan.ext.ROUTING"
|
||||
private const val KEY_CONFIG = "config"
|
||||
private const val RECONNECT_DEBOUNCE_MS = 2000L
|
||||
|
||||
/** Validates and canonicalizes a CIDR/address; null if invalid. */
|
||||
fun normalizePrefix(cidr: String): String? = try
|
||||
{
|
||||
org.strongswan.android.utils.IPRange(cidr.trim()).toString()
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
null
|
||||
val visible = RoutingPresets.CATEGORIES.firstOrNull {
|
||||
it in config.enabledCategories && RoutingPresets.matchesCategory(it, pkg)
|
||||
}
|
||||
if (visible != null)
|
||||
{
|
||||
return visible
|
||||
}
|
||||
return RoutingPresets.NAMESPACE.takeIf {
|
||||
it in config.enabledCategories && RoutingPresets.matchesCategory(it, pkg)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All packages matched by the enabled rule sets: explicit packages,
|
||||
* prefixes and keywords matched against the installed packages.
|
||||
* Why a category or pattern covers [pkg] (ignoring personal overrides
|
||||
* and the explicit list), or null if none does.
|
||||
*/
|
||||
fun effectiveExcludes(ruleSets: List<RuleSet>, installed: Set<String>): Set<String>
|
||||
fun templateMatch(pkg: String, config: RoutingConfig): TemplateMatch?
|
||||
{
|
||||
val pattern = config.packagePatterns.firstOrNull { PackagePatterns.matches(it, pkg) }
|
||||
val category = matchingCategory(pkg, config)
|
||||
return if (category == null && pattern == null) null else TemplateMatch(category, pattern)
|
||||
}
|
||||
|
||||
/**
|
||||
* All packages that bypass the tunnel: explicit direct apps plus the
|
||||
* installed apps matched by enabled categories and package patterns,
|
||||
* minus personal "via VPN" overrides. Explicit apps that are not
|
||||
* installed are dropped (unless the installed list is unavailable).
|
||||
*/
|
||||
fun effectiveExcludes(config: RoutingConfig, installed: Set<String>): Set<String>
|
||||
{
|
||||
val result = HashSet<String>()
|
||||
for (ruleSet in ruleSets)
|
||||
result.addAll(if (installed.isEmpty()) config.directPackages else config.directPackages.filter { it in installed })
|
||||
result.addAll(PackagePatterns.matchAll(config.packagePatterns, installed))
|
||||
for (category in config.enabledCategories)
|
||||
{
|
||||
if (!ruleSet.enabled)
|
||||
val presets = RoutingPresets.presetsOf(category)
|
||||
for (pkg in installed)
|
||||
{
|
||||
continue
|
||||
if (presets.any { it.matches(pkg) })
|
||||
{
|
||||
result.add(pkg)
|
||||
}
|
||||
}
|
||||
result.addAll(ruleSet.packages)
|
||||
result.addAll(matchPrefixes(ruleSet.prefixes, installed))
|
||||
result.addAll(matchKeywords(ruleSet.keywords, installed))
|
||||
}
|
||||
result.removeAll(config.tunnelOverrides)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Installed packages whose name starts with any of the prefixes. */
|
||||
fun matchPrefixes(prefixes: Set<String>, installed: Set<String>): Set<String> =
|
||||
if (prefixes.isEmpty())
|
||||
{
|
||||
emptySet()
|
||||
}
|
||||
else
|
||||
{
|
||||
installed.filter { pkg -> prefixes.any { pkg.startsWith(it) } }.toSet()
|
||||
}
|
||||
|
||||
/** Installed packages whose name contains any keyword (case-insensitive, anywhere). */
|
||||
fun matchKeywords(keywords: Set<String>, installed: Set<String>): Set<String> =
|
||||
if (keywords.isEmpty())
|
||||
{
|
||||
emptySet()
|
||||
}
|
||||
else
|
||||
{
|
||||
val lower = keywords.map { it.lowercase() }
|
||||
installed.filter { pkg -> lower.any { pkg.lowercase().contains(it) } }.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the global excludes into a profile's per-app split tunneling
|
||||
* settings (applied in-memory when the TUN is built, the stored profile
|
||||
* is untouched):
|
||||
* - EXCLUDE (incl. DISABLE, converted by the caller): global packages
|
||||
* are added to the excluded set;
|
||||
* - ONLY: global packages are removed from the allowed set - in ONLY
|
||||
* mode everything else goes direct anyway, so this alone is enough.
|
||||
*
|
||||
* @return the effective handling (DISABLE is normalized to EXCLUDE)
|
||||
*/
|
||||
@JvmStatic
|
||||
fun applyGlobalExcludes(handling: SelectedAppsHandling, selected: MutableSet<String>, global: Set<String>): SelectedAppsHandling
|
||||
{
|
||||
if (global.isEmpty())
|
||||
{
|
||||
return if (handling == SelectedAppsHandling.SELECTED_APPS_DISABLE)
|
||||
{
|
||||
SelectedAppsHandling.SELECTED_APPS_EXCLUDE
|
||||
}
|
||||
else
|
||||
{
|
||||
handling
|
||||
}
|
||||
}
|
||||
return when (handling)
|
||||
{
|
||||
SelectedAppsHandling.SELECTED_APPS_ONLY ->
|
||||
{
|
||||
selected.removeAll(global)
|
||||
SelectedAppsHandling.SELECTED_APPS_ONLY
|
||||
}
|
||||
else ->
|
||||
{
|
||||
selected.addAll(global)
|
||||
SelectedAppsHandling.SELECTED_APPS_EXCLUDE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var instance: RoutingManager? = null
|
||||
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
|
||||
|
||||
/**
|
||||
* Pure conversion rules from the pre-2.16 settings (named rule sets and
|
||||
* per-profile split-tunneling apps) to the single global direct-apps list.
|
||||
* No Android dependencies, so they are unit tested; [RoutingManager] applies
|
||||
* them once at startup.
|
||||
*/
|
||||
object RoutingMigration
|
||||
{
|
||||
data class RuleSetResult(
|
||||
val directPackages: Set<String>,
|
||||
val patterns: Set<String>,
|
||||
val categories: Set<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Enabled rule sets become explicit packages plus package patterns. A
|
||||
* built-in category whose every preset is fully present among the rule
|
||||
* sets (what the old "all RF apps" action produced) turns into the
|
||||
* category itself - same coverage, but shown as one switch. Disabled rule
|
||||
* sets are dropped (the old JSON is kept in a backup key).
|
||||
*/
|
||||
fun migrateRuleSets(ruleSets: List<RuleSet>): RuleSetResult
|
||||
{
|
||||
val active = ruleSets.filter { it.enabled }
|
||||
val direct = HashSet<String>()
|
||||
val patterns = LinkedHashSet<String>()
|
||||
for (ruleSet in active)
|
||||
{
|
||||
direct.addAll(ruleSet.packages)
|
||||
ruleSet.prefixes.forEach { patterns.add(PackagePatterns.fromPrefix(it)) }
|
||||
ruleSet.keywords.forEach { patterns.add(PackagePatterns.fromKeyword(it)) }
|
||||
}
|
||||
val categories = LinkedHashSet<String>()
|
||||
for (category in RoutingPresets.ALL_CATEGORIES)
|
||||
{
|
||||
val presets = RoutingPresets.presetsOf(category)
|
||||
if (presets.isNotEmpty() && presets.all { preset -> active.any { it.covers(preset) } })
|
||||
{
|
||||
categories.add(category)
|
||||
for (preset in presets)
|
||||
{
|
||||
preset.prefixes.forEach { patterns.remove(PackagePatterns.fromPrefix(it)) }
|
||||
preset.keywords.forEach { patterns.remove(PackagePatterns.fromKeyword(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return RuleSetResult(direct, patterns, categories)
|
||||
}
|
||||
|
||||
private fun RuleSet.covers(preset: RoutingPresets.Preset): Boolean
|
||||
{
|
||||
if (preset.prefixes.isEmpty() && preset.keywords.isEmpty())
|
||||
{
|
||||
return false
|
||||
}
|
||||
val lowerKeywords = keywords.map { it.lowercase() }.toSet()
|
||||
return prefixes.containsAll(preset.prefixes) && lowerKeywords.containsAll(preset.keywords.map { it.lowercase() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Packages a profile's old per-app setting sends direct (outside the VPN):
|
||||
* - EXCLUDE: the listed apps;
|
||||
* - ONLY: "all installed internet apps except the listed ones" snapshot
|
||||
* (an empty ONLY list never took effect in the old code, so it yields
|
||||
* nothing);
|
||||
* - DISABLE: nothing.
|
||||
* The app's own package is never listed (it is always excluded anyway).
|
||||
*/
|
||||
fun profileAppsToDirect(
|
||||
handling: SelectedAppsHandling,
|
||||
selected: Set<String>,
|
||||
installed: Set<String>,
|
||||
ownPackage: String,
|
||||
): Set<String> = when (handling)
|
||||
{
|
||||
SelectedAppsHandling.SELECTED_APPS_EXCLUDE -> selected - ownPackage
|
||||
SelectedAppsHandling.SELECTED_APPS_ONLY ->
|
||||
if (selected.isEmpty()) emptySet() else installed - selected - ownPackage
|
||||
else -> emptySet()
|
||||
}
|
||||
}
|
||||
+100
-64
@@ -14,10 +14,9 @@
|
||||
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Built-in rule set templates.
|
||||
* Built-in app templates, grouped into categories the user can switch on in
|
||||
* one tap ("Banks", "Marketplaces", ...).
|
||||
* - Prefixes match package names, so "ru." catches every package in the ru.*
|
||||
* reverse-domain namespace.
|
||||
* - Keywords match anywhere in the package name (case-insensitive), so
|
||||
@@ -28,75 +27,112 @@ import java.util.UUID
|
||||
*/
|
||||
object RoutingPresets
|
||||
{
|
||||
data class Preset(val name: String, val prefixes: List<String> = emptyList(), val keywords: List<String> = emptyList())
|
||||
/* category ids; the namespace-wide preset lives in the hidden NAMESPACE
|
||||
* category that only "all RF apps" switches on */
|
||||
const val BANKS = "banks"
|
||||
const val GOV = "gov"
|
||||
const val SHOP = "shop"
|
||||
const val SOCIAL = "social"
|
||||
const val TRAVEL = "travel"
|
||||
const val SERVICES = "services"
|
||||
const val NAMESPACE = "ru_namespace"
|
||||
|
||||
/** User-visible categories in display order (the hidden [NAMESPACE] one is excluded). */
|
||||
val CATEGORIES: List<String> = listOf(BANKS, GOV, SHOP, SOCIAL, TRAVEL, SERVICES)
|
||||
|
||||
/** Everything "all RF apps" switches on. */
|
||||
val ALL_CATEGORIES: Set<String> = (CATEGORIES + NAMESPACE).toSet()
|
||||
|
||||
data class Preset(
|
||||
val name: String,
|
||||
val category: String,
|
||||
val prefixes: List<String> = emptyList(),
|
||||
val keywords: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/** Whether a package belongs to the preset (prefix, or keyword anywhere, case-insensitive). */
|
||||
fun Preset.matches(pkg: String): Boolean
|
||||
{
|
||||
if (prefixes.any { pkg.startsWith(it) })
|
||||
{
|
||||
return true
|
||||
}
|
||||
val lower = pkg.lowercase()
|
||||
return keywords.any { lower.contains(it.lowercase()) }
|
||||
}
|
||||
|
||||
/** Presets of one category, in declaration order. */
|
||||
fun presetsOf(category: String): List<Preset> = RF_BUNDLE.filter { it.category == category }
|
||||
|
||||
/** Whether the package matches any preset of [category] (the namespace one included). */
|
||||
fun matchesCategory(category: String, pkg: String): Boolean =
|
||||
presetsOf(category).any { it.matches(pkg) }
|
||||
|
||||
/** First user-visible category the package belongs to, or null. */
|
||||
fun categoryOf(pkg: String): String? =
|
||||
CATEGORIES.firstOrNull { matchesCategory(it, pkg) }
|
||||
|
||||
val RF_BUNDLE: List<Preset> = listOf(
|
||||
/* namespace-wide */
|
||||
Preset("Все RU.*", prefixes = listOf("ru.")),
|
||||
Preset("Все RU.*", NAMESPACE, prefixes = listOf("ru.")),
|
||||
/* ecosystems */
|
||||
Preset("Яндекс", prefixes = listOf("ru.yandex."), keywords = listOf("yandex")),
|
||||
Preset("VK", prefixes = listOf("com.vkontakte", "ru.vk", "com.vk"), keywords = listOf("vkontakte")),
|
||||
Preset("Одноклассники", prefixes = listOf("ru.ok.")),
|
||||
Preset("Дзен", keywords = listOf("dzen")),
|
||||
Preset("Rutube", keywords = listOf("rutube")),
|
||||
Preset("Звук", keywords = listOf("zvuk", "zvooq")),
|
||||
Preset("Иви", prefixes = listOf("ru.ivi")),
|
||||
Preset("Яндекс", SOCIAL, prefixes = listOf("ru.yandex."), keywords = listOf("yandex")),
|
||||
Preset("VK", SOCIAL, prefixes = listOf("com.vkontakte", "ru.vk", "com.vk"), keywords = listOf("vkontakte")),
|
||||
Preset("Одноклассники", SOCIAL, prefixes = listOf("ru.ok.")),
|
||||
Preset("Дзен", SOCIAL, keywords = listOf("dzen")),
|
||||
Preset("Rutube", SOCIAL, keywords = listOf("rutube")),
|
||||
Preset("Звук", SOCIAL, keywords = listOf("zvuk", "zvooq")),
|
||||
Preset("Иви", SOCIAL, prefixes = listOf("ru.ivi")),
|
||||
/* banks & finance */
|
||||
Preset("Сбер", keywords = listOf("sber")),
|
||||
Preset("Т-Банк", keywords = listOf("tinkoff", "tbank")),
|
||||
Preset("ВТБ", keywords = listOf("vtb")),
|
||||
Preset("Альфа-Банк", keywords = listOf("alfabank", "alpha-bank")),
|
||||
Preset("Газпромбанк", keywords = listOf("gazprombank")),
|
||||
Preset("Совкомбанк", keywords = listOf("sovcombank")),
|
||||
Preset("Почта Банк", keywords = listOf("pochtabank")),
|
||||
Preset("РСХБ", keywords = listOf("rshb", "rosselkhozbank")),
|
||||
Preset("ОТП-Банк", prefixes = listOf("ru.otpbank"), keywords = listOf("otpbank")),
|
||||
Preset("МКБ", prefixes = listOf("ru.mkb")),
|
||||
Preset("Уралсиб", keywords = listOf("uralsib")),
|
||||
Preset("ЮMoney", keywords = listOf("yoomoney")),
|
||||
Preset("Золотая Корона", keywords = listOf("koronapay")),
|
||||
Preset("Мой налог", keywords = listOf("nalog")),
|
||||
Preset("Сбер", BANKS, keywords = listOf("sber")),
|
||||
Preset("Т-Банк", BANKS, keywords = listOf("tinkoff", "tbank")),
|
||||
Preset("ВТБ", BANKS, keywords = listOf("vtb")),
|
||||
Preset("Альфа-Банк", BANKS, keywords = listOf("alfabank", "alpha-bank")),
|
||||
Preset("Газпромбанк", BANKS, keywords = listOf("gazprombank")),
|
||||
Preset("Совкомбанк", BANKS, keywords = listOf("sovcombank")),
|
||||
Preset("Почта Банк", BANKS, keywords = listOf("pochtabank")),
|
||||
Preset("РСХБ", BANKS, keywords = listOf("rshb", "rosselkhozbank")),
|
||||
Preset("ОТП-Банк", BANKS, prefixes = listOf("ru.otpbank"), keywords = listOf("otpbank")),
|
||||
Preset("МКБ", BANKS, prefixes = listOf("ru.mkb")),
|
||||
Preset("Уралсиб", BANKS, keywords = listOf("uralsib")),
|
||||
Preset("ЮMoney", BANKS, keywords = listOf("yoomoney")),
|
||||
Preset("Золотая Корона", BANKS, keywords = listOf("koronapay")),
|
||||
/* government */
|
||||
Preset("Госуслуги", GOV, prefixes = listOf("ru.rostel"), keywords = listOf("gosuslugi")),
|
||||
Preset("Мой налог", GOV, keywords = listOf("nalog")),
|
||||
/* retail & marketplaces */
|
||||
Preset("Ozon", keywords = listOf("ozon")),
|
||||
Preset("Wildberries", keywords = listOf("wildberries")),
|
||||
Preset("Мегамаркет", keywords = listOf("megamarket")),
|
||||
Preset("Пятёрочка", keywords = listOf("pyaterochka")),
|
||||
Preset("Перекрёсток", keywords = listOf("perekrestok")),
|
||||
Preset("Магнит", keywords = listOf("magnit")),
|
||||
Preset("Лента", keywords = listOf("lenta")),
|
||||
Preset("М.Видео", keywords = listOf("mvideo")),
|
||||
Preset("Спортмастер", keywords = listOf("sportmaster")),
|
||||
Preset("Лемана ПРО", keywords = listOf("leroymerlin", "lemana")),
|
||||
Preset("Самокат", keywords = listOf("samokat")),
|
||||
Preset("ВкусВилл", keywords = listOf("vkusvill")),
|
||||
Preset("Ozon", SHOP, keywords = listOf("ozon")),
|
||||
Preset("Wildberries", SHOP, keywords = listOf("wildberries")),
|
||||
Preset("Мегамаркет", SHOP, keywords = listOf("megamarket")),
|
||||
Preset("Пятёрочка", SHOP, keywords = listOf("pyaterochka")),
|
||||
Preset("Перекрёсток", SHOP, keywords = listOf("perekrestok")),
|
||||
Preset("Магнит", SHOP, keywords = listOf("magnit")),
|
||||
Preset("Лента", SHOP, keywords = listOf("lenta")),
|
||||
Preset("М.Видео", SHOP, keywords = listOf("mvideo")),
|
||||
Preset("Спортмастер", SHOP, keywords = listOf("sportmaster")),
|
||||
Preset("Лемана ПРО", SHOP, keywords = listOf("leroymerlin", "lemana")),
|
||||
Preset("Самокат", SHOP, keywords = listOf("samokat")),
|
||||
Preset("ВкусВилл", SHOP, keywords = listOf("vkusvill")),
|
||||
/* telecom */
|
||||
Preset("МТС", prefixes = listOf("ru.mts", "com.mts")),
|
||||
Preset("Мегафон", keywords = listOf("megafon")),
|
||||
Preset("Билайн", keywords = listOf("beeline")),
|
||||
Preset("T2/Tele2", prefixes = listOf("ru.t2"), keywords = listOf("tele2")),
|
||||
Preset("Yota", prefixes = listOf("ru.yota")),
|
||||
Preset("МТС", SOCIAL, prefixes = listOf("ru.mts", "com.mts")),
|
||||
Preset("Мегафон", SOCIAL, keywords = listOf("megafon")),
|
||||
Preset("Билайн", SOCIAL, keywords = listOf("beeline")),
|
||||
Preset("T2/Tele2", SOCIAL, prefixes = listOf("ru.t2"), keywords = listOf("tele2")),
|
||||
Preset("Yota", SOCIAL, prefixes = listOf("ru.yota")),
|
||||
/* transport & travel */
|
||||
Preset("РЖД", keywords = listOf("rzd")),
|
||||
Preset("Аэрофлот", keywords = listOf("aeroflot")),
|
||||
Preset("Туту.ру", keywords = listOf("tutu.ru")),
|
||||
Preset("Aviasales", keywords = listOf("aviasales")),
|
||||
Preset("Ситимобил", keywords = listOf("sitimobil")),
|
||||
Preset("Делимобиль", keywords = listOf("delimobil")),
|
||||
Preset("РЖД", TRAVEL, keywords = listOf("rzd")),
|
||||
Preset("Аэрофлот", TRAVEL, keywords = listOf("aeroflot")),
|
||||
Preset("Туту.ру", TRAVEL, keywords = listOf("tutu.ru")),
|
||||
Preset("Aviasales", TRAVEL, keywords = listOf("aviasales")),
|
||||
Preset("Ситимобил", TRAVEL, keywords = listOf("sitimobil")),
|
||||
Preset("Делимобиль", TRAVEL, keywords = listOf("delimobil")),
|
||||
/* services */
|
||||
Preset("2ГИС", keywords = listOf("2gis", "dublgis")),
|
||||
Preset("ЦИАН", prefixes = listOf("ru.cian")),
|
||||
Preset("Дром", keywords = listOf("drom")),
|
||||
Preset("Юла", keywords = listOf("youla")),
|
||||
Preset("HeadHunter", prefixes = listOf("ru.hh"), keywords = listOf("headhunter")),
|
||||
Preset("Почта России", keywords = listOf("pochta", "russianpost")),
|
||||
Preset("СДЭК", keywords = listOf("cdek")),
|
||||
Preset("2ГИС", SERVICES, keywords = listOf("2gis", "dublgis")),
|
||||
Preset("ЦИАН", SERVICES, prefixes = listOf("ru.cian")),
|
||||
Preset("Дром", SERVICES, keywords = listOf("drom")),
|
||||
Preset("Юла", SERVICES, keywords = listOf("youla")),
|
||||
Preset("HeadHunter", SERVICES, prefixes = listOf("ru.hh"), keywords = listOf("headhunter")),
|
||||
Preset("Почта России", SERVICES, keywords = listOf("pochta", "russianpost")),
|
||||
Preset("СДЭК", SERVICES, keywords = listOf("cdek")),
|
||||
)
|
||||
}
|
||||
|
||||
fun RoutingPresets.Preset.asRuleSet(): RuleSet = RuleSet(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = name,
|
||||
enabled = true,
|
||||
prefixes = prefixes.toSet(),
|
||||
keywords = keywords.map { it.lowercase() }.toSet(),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
-213
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -41,6 +41,7 @@ import org.strongswan.android.data.VpnProfileSource;
|
||||
import org.strongswan.android.data.VpnType;
|
||||
import org.strongswan.android.data.VpnType.VpnTypeFeature;
|
||||
import org.strongswan.android.logic.TrustedCertificateManager;
|
||||
import org.strongswan.android.logic.routing.RoutingManager;
|
||||
import org.strongswan.android.security.TrustedCertificateEntry;
|
||||
import org.strongswan.android.ui.compose.ImportUiModel;
|
||||
import org.strongswan.android.ui.compose.ImportScreenKt;
|
||||
@@ -629,6 +630,11 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
if (verifyInput())
|
||||
{
|
||||
updateProfileData();
|
||||
if (mProfile.getSelectedAppsHandling() != SelectedAppsHandling.SELECTED_APPS_DISABLE)
|
||||
{ /* per-profile apps are gone: they join the global direct-apps list */
|
||||
RoutingManager.getInstance(this).importProfileApps(mProfile.getSelectedAppsHandling(), mProfile.getSelectedAppsSet());
|
||||
mProfile.setSelectedAppsHandling(SelectedAppsHandling.SELECTED_APPS_DISABLE);
|
||||
}
|
||||
if (mExisting != null)
|
||||
{
|
||||
mProfile.setUUID(mExisting.getUUID());
|
||||
|
||||
-73
@@ -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());
|
||||
}
|
||||
}
|
||||
-179
@@ -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 = " · ")
|
||||
}
|
||||
}
|
||||
+11
-52
@@ -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,
|
||||
|
||||
+257
-415
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-359
@@ -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()
|
||||
|
||||
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
+335
-249
@@ -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 = {
|
||||
|
||||
+185
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+392
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+241
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+47
-142
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -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()
|
||||
+288
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-198
@@ -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>
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class PackagePatternsTest
|
||||
{
|
||||
@Test
|
||||
fun bareInputBecomesPrefixPattern()
|
||||
{
|
||||
assertThat(PackagePatterns.normalize(" RU.Ozon ")).isEqualTo("ru.ozon*")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun starsAreCollapsedAndKept()
|
||||
{
|
||||
assertThat(PackagePatterns.normalize("**yandex**")).isEqualTo("*yandex*")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tooBroadOrInvalidInputIsRejected()
|
||||
{
|
||||
assertThat(PackagePatterns.normalize("")).isNull()
|
||||
assertThat(PackagePatterns.normalize("*")).isNull()
|
||||
assertThat(PackagePatterns.normalize("**")).isNull()
|
||||
assertThat(PackagePatterns.normalize("a b")).isNull()
|
||||
assertThat(PackagePatterns.normalize("ru/x")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefixKeywordSuffixAndExactMatching()
|
||||
{
|
||||
assertThat(PackagePatterns.matches("ru.*", "ru.ozon.app")).isTrue()
|
||||
assertThat(PackagePatterns.matches("ru.*", "com.ru.app")).isFalse()
|
||||
assertThat(PackagePatterns.matches("*yandex*", "com.Yandex.browser")).isTrue()
|
||||
assertThat(PackagePatterns.matches("*.beta", "org.app.beta")).isTrue()
|
||||
assertThat(PackagePatterns.matches("*.beta", "org.app.beta2")).isFalse()
|
||||
assertThat(PackagePatterns.matches("org.app", "org.app")).isTrue()
|
||||
assertThat(PackagePatterns.matches("org.app", "org.app2")).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchAllCollectsOnlyMatchingPackages()
|
||||
{
|
||||
val installed = setOf("ru.a", "ru.b", "com.c")
|
||||
assertThat(PackagePatterns.matchAll(setOf("ru.*"), installed)).containsExactlyInAnyOrder("ru.a", "ru.b")
|
||||
assertThat(PackagePatterns.matchAll(emptySet(), installed)).isEmpty()
|
||||
}
|
||||
}
|
||||
+77
-114
@@ -16,7 +16,6 @@ package org.strongswan.android.logic.routing
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.Test
|
||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
|
||||
|
||||
class RoutingManagerTest
|
||||
{
|
||||
@@ -27,143 +26,107 @@ class RoutingManagerTest
|
||||
"com.sberbank.mobile",
|
||||
"ru.ozon.app",
|
||||
"org.mozilla.firefox",
|
||||
"org.telegram.messenger",
|
||||
)
|
||||
|
||||
/* ---------------- matchPrefixes ---------------- */
|
||||
|
||||
@Test
|
||||
fun prefixMatchesInstalledPackages()
|
||||
{
|
||||
val matched = RoutingManager.matchPrefixes(setOf("ru.yandex."), installed)
|
||||
assertThat(matched).containsExactlyInAnyOrder("ru.yandex.taxi", "ru.yandex.mail")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefixDoesNotMatchDifferentReverseDomain()
|
||||
{
|
||||
val matched = RoutingManager.matchPrefixes(setOf("ru.yandex."), installed)
|
||||
assertThat(matched).doesNotContain("com.yandex.browser")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyPrefixesMatchNothing()
|
||||
{
|
||||
assertThat(RoutingManager.matchPrefixes(emptySet(), installed)).isEmpty()
|
||||
}
|
||||
|
||||
/* ---------------- matchKeywords ---------------- */
|
||||
|
||||
@Test
|
||||
fun keywordMatchesAnywhereInPackageName()
|
||||
{
|
||||
val matched = RoutingManager.matchKeywords(setOf("yandex"), installed)
|
||||
assertThat(matched).contains("com.yandex.browser")
|
||||
assertThat(matched).contains("ru.yandex.taxi")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keywordMatchingIsCaseInsensitive()
|
||||
{
|
||||
assertThat(RoutingManager.matchKeywords(setOf("YANDEX"), installed))
|
||||
.contains("com.yandex.browser")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyKeywordsMatchNothing()
|
||||
{
|
||||
assertThat(RoutingManager.matchKeywords(emptySet(), installed)).isEmpty()
|
||||
}
|
||||
|
||||
/* ---------------- effectiveExcludes ---------------- */
|
||||
|
||||
@Test
|
||||
fun disabledRuleSetsAreIgnored()
|
||||
fun emptyConfigExcludesNothing()
|
||||
{
|
||||
val ruleSets = listOf(
|
||||
RuleSet(id = "1", name = "Yandex", enabled = false, prefixes = setOf("ru.yandex.")),
|
||||
RuleSet(id = "2", name = "Ozon", enabled = true, prefixes = setOf("ru.ozon")),
|
||||
)
|
||||
assertThat(RoutingManager.effectiveExcludes(ruleSets, installed)).containsExactly("ru.ozon.app")
|
||||
assertThat(RoutingManager.effectiveExcludes(RoutingConfig(), installed)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitPackagesAndPrefixesCombine()
|
||||
fun explicitDirectAppsAreIncluded()
|
||||
{
|
||||
val ruleSets = listOf(
|
||||
RuleSet(id = "1", name = "Mix", enabled = true, packages = setOf("org.mozilla.firefox"), prefixes = setOf("com.sberbank")),
|
||||
val config = RoutingConfig(directPackages = setOf("org.mozilla.firefox"))
|
||||
assertThat(RoutingManager.effectiveExcludes(config, installed)).containsExactly("org.mozilla.firefox")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uninstalledDirectAppsAreDropped()
|
||||
{
|
||||
val config = RoutingConfig(directPackages = setOf("org.mozilla.firefox", "gone.app"))
|
||||
assertThat(RoutingManager.effectiveExcludes(config, installed)).containsExactly("org.mozilla.firefox")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun directAppsSurviveWhenInstalledListIsUnavailable()
|
||||
{
|
||||
val config = RoutingConfig(directPackages = setOf("org.mozilla.firefox"))
|
||||
assertThat(RoutingManager.effectiveExcludes(config, emptySet())).containsExactly("org.mozilla.firefox")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enabledCategoryMatchesInstalledApps()
|
||||
{
|
||||
val config = RoutingConfig(enabledCategories = setOf(RoutingPresets.BANKS, RoutingPresets.SHOP))
|
||||
assertThat(RoutingManager.effectiveExcludes(config, installed))
|
||||
.containsExactlyInAnyOrder("com.sberbank.mobile", "ru.ozon.app")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun namespaceCategoryMatchesWholeRuNamespace()
|
||||
{
|
||||
val config = RoutingConfig(enabledCategories = setOf(RoutingPresets.NAMESPACE))
|
||||
assertThat(RoutingManager.effectiveExcludes(config, installed))
|
||||
.containsExactlyInAnyOrder("ru.yandex.taxi", "ru.yandex.mail", "ru.ozon.app")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patternsMatchPrefixAndKeyword()
|
||||
{
|
||||
val config = RoutingConfig(packagePatterns = setOf("ru.yandex.*", "*sber*"))
|
||||
assertThat(RoutingManager.effectiveExcludes(config, installed))
|
||||
.containsExactlyInAnyOrder("ru.yandex.taxi", "ru.yandex.mail", "com.sberbank.mobile")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tunnelOverrideWinsOverCategoriesPatternsAndExplicitList()
|
||||
{
|
||||
val config = RoutingConfig(
|
||||
directPackages = setOf("org.mozilla.firefox"),
|
||||
enabledCategories = setOf(RoutingPresets.NAMESPACE),
|
||||
packagePatterns = setOf("*yandex*"),
|
||||
tunnelOverrides = setOf("ru.yandex.taxi", "com.yandex.browser", "org.mozilla.firefox"),
|
||||
)
|
||||
val excludes = RoutingManager.effectiveExcludes(ruleSets, installed)
|
||||
assertThat(excludes).containsExactlyInAnyOrder("org.mozilla.firefox", "com.sberbank.mobile")
|
||||
val excludes = RoutingManager.effectiveExcludes(config, installed)
|
||||
assertThat(excludes).containsExactlyInAnyOrder("ru.yandex.mail", "ru.ozon.app")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateMatchesCollapse()
|
||||
{
|
||||
val ruleSets = listOf(
|
||||
RuleSet(id = "1", name = "A", enabled = true, packages = setOf("ru.ozon.app")),
|
||||
RuleSet(id = "2", name = "B", enabled = true, prefixes = setOf("ru.ozon")),
|
||||
val config = RoutingConfig(
|
||||
directPackages = setOf("ru.ozon.app"),
|
||||
enabledCategories = setOf(RoutingPresets.SHOP),
|
||||
packagePatterns = setOf("ru.ozon*"),
|
||||
)
|
||||
assertThat(RoutingManager.effectiveExcludes(ruleSets, installed)).containsExactly("ru.ozon.app")
|
||||
assertThat(RoutingManager.effectiveExcludes(config, installed)).containsExactly("ru.ozon.app")
|
||||
}
|
||||
|
||||
/* ---------------- templateMatch ---------------- */
|
||||
|
||||
@Test
|
||||
fun templateMatchReportsCategoryBeforeNamespace()
|
||||
{
|
||||
val config = RoutingConfig(enabledCategories = setOf(RoutingPresets.SHOP, RoutingPresets.NAMESPACE))
|
||||
val match = RoutingManager.templateMatch("ru.ozon.app", config)
|
||||
assertThat(match?.category).isEqualTo(RoutingPresets.SHOP)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keywordsAndPrefixesCombine()
|
||||
fun templateMatchIsNullWhenNothingCoversThePackage()
|
||||
{
|
||||
val ruleSets = listOf(
|
||||
RuleSet(id = "1", name = "Ru", enabled = true, prefixes = setOf("ru.")),
|
||||
RuleSet(id = "2", name = "Yandex", enabled = true, keywords = setOf("yandex")),
|
||||
)
|
||||
val excludes = RoutingManager.effectiveExcludes(ruleSets, installed)
|
||||
/* prefix ru.* plus keyword matching com.yandex.browser */
|
||||
assertThat(excludes).containsExactlyInAnyOrder(
|
||||
"ru.yandex.taxi", "ru.yandex.mail", "ru.ozon.app", "com.yandex.browser",
|
||||
)
|
||||
assertThat(excludes).doesNotContain("org.mozilla.firefox")
|
||||
}
|
||||
|
||||
/* ---------------- applyGlobalExcludes ---------------- */
|
||||
|
||||
@Test
|
||||
fun disableProfileBecomesExcludeWithGlobalApps()
|
||||
{
|
||||
val selected = mutableSetOf<String>()
|
||||
val handling = RoutingManager.applyGlobalExcludes(
|
||||
SelectedAppsHandling.SELECTED_APPS_DISABLE, selected, setOf("ru.yandex.taxi"),
|
||||
)
|
||||
assertThat(handling).isEqualTo(SelectedAppsHandling.SELECTED_APPS_EXCLUDE)
|
||||
assertThat(selected).containsExactly("ru.yandex.taxi")
|
||||
val config = RoutingConfig(enabledCategories = setOf(RoutingPresets.BANKS), packagePatterns = setOf("ru.yandex.*"))
|
||||
assertThat(RoutingManager.templateMatch("org.mozilla.firefox", config)).isNull()
|
||||
assertThat(RoutingManager.templateMatch("ru.yandex.taxi", config)?.pattern).isEqualTo("ru.yandex.*")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun excludeProfileGainsGlobalApps()
|
||||
fun disabledCategoryDoesNotCover()
|
||||
{
|
||||
val selected = mutableSetOf("com.example.existing")
|
||||
val handling = RoutingManager.applyGlobalExcludes(
|
||||
SelectedAppsHandling.SELECTED_APPS_EXCLUDE, selected, setOf("ru.yandex.taxi"),
|
||||
)
|
||||
assertThat(handling).isEqualTo(SelectedAppsHandling.SELECTED_APPS_EXCLUDE)
|
||||
assertThat(selected).containsExactlyInAnyOrder("com.example.existing", "ru.yandex.taxi")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyProfileDropsGlobalAppsFromAllowedSet()
|
||||
{
|
||||
val selected = mutableSetOf("ru.yandex.taxi", "org.mozilla.firefox")
|
||||
val handling = RoutingManager.applyGlobalExcludes(
|
||||
SelectedAppsHandling.SELECTED_APPS_ONLY, selected, setOf("ru.yandex.taxi"),
|
||||
)
|
||||
assertThat(handling).isEqualTo(SelectedAppsHandling.SELECTED_APPS_ONLY)
|
||||
assertThat(selected).containsExactly("org.mozilla.firefox")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyGlobalSetKeepsEverything()
|
||||
{
|
||||
val selected = mutableSetOf("com.example.app")
|
||||
val handling = RoutingManager.applyGlobalExcludes(
|
||||
SelectedAppsHandling.SELECTED_APPS_EXCLUDE, selected, emptySet(),
|
||||
)
|
||||
assertThat(handling).isEqualTo(SelectedAppsHandling.SELECTED_APPS_EXCLUDE)
|
||||
assertThat(selected).containsExactly("com.example.app")
|
||||
assertThat(RoutingManager.templateMatch("com.sberbank.mobile", RoutingConfig())).isNull()
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.Test
|
||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
|
||||
|
||||
class RoutingMigrationTest
|
||||
{
|
||||
private val own = "one.shx.strongswan.ext"
|
||||
private val installed = setOf("a.one", "b.two", "c.three", own)
|
||||
|
||||
/* ---------------- rule sets -> direct apps + patterns ---------------- */
|
||||
|
||||
@Test
|
||||
fun ruleSetPackagesBecomeDirectApps()
|
||||
{
|
||||
val result = RoutingMigration.migrateRuleSets(listOf(
|
||||
RuleSet("1", "Mine", packages = setOf("a.one", "b.two")),
|
||||
))
|
||||
assertThat(result.directPackages).containsExactlyInAnyOrder("a.one", "b.two")
|
||||
assertThat(result.patterns).isEmpty()
|
||||
assertThat(result.categories).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefixesAndKeywordsBecomePatterns()
|
||||
{
|
||||
val result = RoutingMigration.migrateRuleSets(listOf(
|
||||
RuleSet("1", "Custom", prefixes = setOf("ru.custom."), keywords = setOf("MyBank")),
|
||||
))
|
||||
assertThat(result.patterns).containsExactlyInAnyOrder("ru.custom.*", "*mybank*")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disabledRuleSetsAreDropped()
|
||||
{
|
||||
val result = RoutingMigration.migrateRuleSets(listOf(
|
||||
RuleSet("1", "Off", enabled = false, packages = setOf("a.one"), prefixes = setOf("ru.")),
|
||||
))
|
||||
assertThat(result.directPackages).isEmpty()
|
||||
assertThat(result.patterns).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullyCoveredCategoryBecomesCategoryAndLosesItsPatterns()
|
||||
{
|
||||
val ruleSets = RoutingPresets.presetsOf(RoutingPresets.GOV).map {
|
||||
RuleSet(it.name, it.name, prefixes = it.prefixes.toSet(), keywords = it.keywords.toSet())
|
||||
} + RuleSet("x", "Custom", prefixes = setOf("org.custom."))
|
||||
val result = RoutingMigration.migrateRuleSets(ruleSets)
|
||||
assertThat(result.categories).containsExactly(RoutingPresets.GOV)
|
||||
assertThat(result.patterns).containsExactly("org.custom.*")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partiallyCoveredCategoryStaysAsPatterns()
|
||||
{
|
||||
val sber = RoutingPresets.presetsOf(RoutingPresets.BANKS).first()
|
||||
val result = RoutingMigration.migrateRuleSets(listOf(
|
||||
RuleSet("1", sber.name, keywords = sber.keywords.toSet()),
|
||||
))
|
||||
assertThat(result.categories).isEmpty()
|
||||
assertThat(result.patterns).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migratedRuleSetsKeepTheirEffectiveCoverage()
|
||||
{
|
||||
val ruleSets = listOf(
|
||||
RuleSet("1", "Yandex", prefixes = setOf("ru.yandex."), keywords = setOf("yandex")),
|
||||
RuleSet("2", "Mine", packages = setOf("org.mozilla.firefox")),
|
||||
)
|
||||
val pkgs = setOf("ru.yandex.taxi", "com.yandex.browser", "org.mozilla.firefox", "org.other")
|
||||
val migrated = RoutingMigration.migrateRuleSets(ruleSets)
|
||||
val config = RoutingConfig(
|
||||
directPackages = migrated.directPackages,
|
||||
enabledCategories = migrated.categories,
|
||||
packagePatterns = migrated.patterns,
|
||||
)
|
||||
assertThat(RoutingManager.effectiveExcludes(config, pkgs))
|
||||
.containsExactlyInAnyOrder("ru.yandex.taxi", "com.yandex.browser", "org.mozilla.firefox")
|
||||
}
|
||||
|
||||
/* ---------------- profile app lists -> direct apps ---------------- */
|
||||
|
||||
@Test
|
||||
fun excludeProfileListBecomesDirectApps()
|
||||
{
|
||||
val direct = RoutingMigration.profileAppsToDirect(
|
||||
SelectedAppsHandling.SELECTED_APPS_EXCLUDE, setOf("a.one", own), emptySet(), own)
|
||||
assertThat(direct).containsExactly("a.one")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyProfileBecomesEveryOtherInstalledApp()
|
||||
{
|
||||
val direct = RoutingMigration.profileAppsToDirect(
|
||||
SelectedAppsHandling.SELECTED_APPS_ONLY, setOf("a.one"), installed, own)
|
||||
assertThat(direct).containsExactlyInAnyOrder("b.two", "c.three")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyOnlyProfileYieldsNothingBecauseTheOldCodeIgnoredIt()
|
||||
{
|
||||
val direct = RoutingMigration.profileAppsToDirect(
|
||||
SelectedAppsHandling.SELECTED_APPS_ONLY, emptySet(), installed, own)
|
||||
assertThat(direct).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disabledProfileYieldsNothing()
|
||||
{
|
||||
val direct = RoutingMigration.profileAppsToDirect(
|
||||
SelectedAppsHandling.SELECTED_APPS_DISABLE, setOf("a.one"), installed, own)
|
||||
assertThat(direct).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergingTheSameMigrationTwiceChangesNothing()
|
||||
{
|
||||
val first = RoutingMigration.profileAppsToDirect(
|
||||
SelectedAppsHandling.SELECTED_APPS_ONLY, setOf("a.one"), installed, own)
|
||||
val config = RoutingConfig(directPackages = first)
|
||||
val again = config.copy(directPackages = config.directPackages + first)
|
||||
assertThat(again).isEqualTo(config)
|
||||
}
|
||||
}
|
||||
+106
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user