android: Routing tab redesign - RU sites card, direct apps screen, advanced pages

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-10 01:47:59 +07:00
1 parent 48f1ed7fb8
commit 488c0aa210
14 files changed
+2144 -1004

No files matched your search

@@ -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)
}
}
@@ -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)
@@ -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,
)
}
}
@@ -44,7 +44,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.layout.Box
@@ -73,6 +75,7 @@ import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.TrustedCertificateManager
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.ui.LogActivity
import org.strongswan.android.ui.VpnProfileDetailActivity
import org.strongswan.android.ui.compose.theme.ShxSpacing
@@ -115,6 +118,37 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
viewModel.messages.collect { snackbarHostState.showSnackbar(context.getString(it)) }
}
/* open-tab requests from other activities (profile form -> Routing) */
val requestedTab by ShxNavigation.requestedTab.collectAsStateWithLifecycle()
LaunchedEffect(requestedTab)
{
requestedTab?.let {
viewModel.setTab(it)
ShxNavigation.consume()
}
}
/* one-time notice after the 2.16 migration of "only selected apps" profiles */
val routingManager = remember { RoutingManager.getInstance(context) }
val routingState by routingManager.state.collectAsStateWithLifecycle()
LaunchedEffect(routingState.migratedOnlyProfiles)
{
if (routingState.migratedOnlyProfiles > 0)
{
val result = snackbarHostState.showSnackbar(
message = context.getString(R.string.routing_migrated_only),
actionLabel = context.getString(R.string.dead_targets_open),
duration = SnackbarDuration.Indefinite,
withDismissAction = true,
)
routingManager.consumeMigrationNotice()
if (result == SnackbarResult.ActionPerformed)
{
viewModel.setTab(ShxTab.ROUTING)
}
}
}
/* the home list keeps its scroll state across tab switches */
val homeListState = rememberLazyListState()
@@ -0,0 +1,38 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.compose
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* One-shot "open this tab" request from outside the main activity's Compose
* tree (e.g. the profile form's link to the Routing tab). ShxApp consumes it.
*/
object ShxNavigation
{
private val _requestedTab = MutableStateFlow<ShxTab?>(null)
val requestedTab: StateFlow<ShxTab?> = _requestedTab
fun request(tab: ShxTab)
{
_requestedTab.value = tab
}
fun consume()
{
_requestedTab.value = null
}
}
@@ -4,324 +4,401 @@
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose.routing
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import android.app.Activity
import android.content.Intent
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.Block
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Lan
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Route
import androidx.compose.material.icons.rounded.Rule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.routing.ForceTunnelPrefix
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.logic.routing.RuIpListState
import org.strongswan.android.logic.routing.RuIpListStatus
import org.strongswan.android.utils.Constants
import org.strongswan.android.ui.RouteCheckActivity
import org.strongswan.android.ui.SelectedApplicationsActivity
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.DeadTargetUi
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.formatDuration
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Geoip toggle row with a refresh/update slot in front of the switch. */
/** Sub pages of the "Advanced" section. */
enum class AdvancedPage { FORCE, PATTERNS, DEAD }
/** One entry of the advanced list: title, hint, count badge and a chevron. */
@Composable
internal fun RuToggleRow(
title: String,
subtitle: String,
checked: Boolean,
updating: Boolean,
onCheckedChange: (Boolean) -> Unit,
onUpdate: () -> Unit,
)
private fun AdvancedRow(title: String, subtitle: String, count: Int, warn: Boolean, onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onCheckedChange(!checked) }
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
.clickable(onClick = onClick)
.heightIn(min = 56.dp)
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
{
ShxIconBadge(Icons.Rounded.Block)
Spacer(Modifier.width(ShxSpacing.lg))
Column(Modifier.weight(1f))
{
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (updating)
Spacer(Modifier.width(ShxSpacing.md))
Surface(
shape = MaterialTheme.shapes.small,
color = if (warn) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = if (warn) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant,
)
{
LoadingIndicator(Modifier.size(14.dp))
Spacer(Modifier.width(ShxSpacing.md))
Text(
text = count.toString(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
else
{
IconButton(onClick = onUpdate)
Icon(
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** Collapsible "Advanced": always-via-VPN addresses, package templates and unreachable hosts. */
@Composable
internal fun AdvancedSection(
forceCount: Int,
patternCount: Int,
deadCount: Int,
onOpen: (AdvancedPage) -> Unit,
modifier: Modifier = Modifier,
)
{
var open by rememberSaveable { mutableStateOf(false) }
val rotation by animateFloatAsState(if (open) 180f else 0f, label = "advancedChevron")
ShxCard(modifier = modifier.fillMaxWidth())
{
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.toggleable(value = open, role = Role.Button, onValueChange = { open = it })
.heightIn(min = 64.dp)
.padding(ShxSpacing.lg),
)
{
Column(Modifier.weight(1f))
{
Text(
text = stringResource(R.string.routing_advanced),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
modifier = Modifier.semantics { heading() },
)
Text(
text = stringResource(R.string.routing_advanced_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.rusplit_update),
Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.rotate(rotation),
)
}
AnimatedVisibility(visible = open)
{
Column(Modifier.padding(bottom = ShxSpacing.sm))
{
AdvancedRow(
title = stringResource(R.string.routing_force_section),
subtitle = stringResource(R.string.routing_adv_force_sub),
count = forceCount,
warn = false,
onClick = { onOpen(AdvancedPage.FORCE) },
)
AdvancedRow(
title = stringResource(R.string.routing_patterns_title),
subtitle = stringResource(R.string.routing_adv_patterns_sub),
count = patternCount,
warn = false,
onClick = { onOpen(AdvancedPage.PATTERNS) },
)
AdvancedRow(
title = stringResource(R.string.dead_targets_title),
subtitle = stringResource(R.string.routing_adv_dead_sub),
count = deadCount,
warn = deadCount > 0,
onClick = { onOpen(AdvancedPage.DEAD) },
)
}
}
}
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
/**
* Selector for how the RU split is routed: OS routes with the coarsened
* complement (as before) or per-packet classification in native code.
*/
/* ------------------------------ sub pages ------------------------------ */
@Composable
internal fun RuModeSelector(mode: String, onModeChange: (String) -> Unit)
private fun SubPage(title: String, onBack: () -> Unit, content: @Composable () -> Unit)
{
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
SecondaryScaffold(title = title, onBack = onBack) { padding ->
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = ShxSpacing.lg)
.padding(bottom = ShxSpacing.xl),
)
{
content()
}
}
}
@Composable
private fun Hint(text: String)
{
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.xs, vertical = ShxSpacing.md),
)
}
/** "Always via VPN": addresses that never go direct, even inside the RU ranges. */
@Composable
internal fun ForcePrefixPage(
prefixes: List<ForceTunnelPrefix>,
onToggle: (String, Boolean) -> Unit,
onEdit: (String) -> Unit,
onDelete: (String) -> Unit,
onAdd: () -> Unit,
onBack: () -> Unit,
)
{
SubPage(stringResource(R.string.routing_force_section), onBack)
{
SegmentedButton(
selected = mode == Constants.RUSPLIT_MODE_SYSTEM,
onClick = { onModeChange(Constants.RUSPLIT_MODE_SYSTEM) },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
)
Hint(stringResource(R.string.routing_force_summary))
ShxCard(Modifier.fillMaxWidth())
{
Text(stringResource(R.string.rusplit_mode_system))
}
SegmentedButton(
selected = mode == Constants.RUSPLIT_MODE_USERSPACE,
onClick = { onModeChange(Constants.RUSPLIT_MODE_USERSPACE) },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
)
{
Text(stringResource(R.string.rusplit_mode_userspace_short))
Column {
prefixes.forEach { entry ->
ForcePrefixRow(
entry = entry,
onToggle = { onToggle(entry.id, it) },
onEdit = { onEdit(entry.id) },
onDelete = { onDelete(entry.id) },
)
ShxSectionDivider()
}
AddRow(stringResource(R.string.routing_force_add), onAdd)
}
}
}
}
/** Mode description, list source and beta note behind a "Details" disclosure. */
/** "Package templates": patterns like ru.* or *yandex* that select apps by package name. */
@Composable
internal fun RuDetailsDisclosure(mode: String)
internal fun PatternsPage(
patterns: List<String>,
onDelete: (String) -> Unit,
onAdd: () -> Unit,
onBack: () -> Unit,
)
{
SubPage(stringResource(R.string.routing_patterns_title), onBack)
{
Hint(stringResource(R.string.routing_patterns_hint))
ShxCard(Modifier.fillMaxWidth())
{
Column {
if (patterns.isEmpty())
{
Text(
text = stringResource(R.string.routing_patterns_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(ShxSpacing.lg),
)
ShxSectionDivider()
}
patterns.forEach { pattern ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(start = ShxSpacing.lg, end = ShxSpacing.sm),
)
{
Text(
text = pattern,
style = MaterialTheme.typography.bodyLarge,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { onDelete(pattern) })
{
Icon(
Icons.Rounded.Delete,
contentDescription = stringResource(R.string.routing_patterns_delete),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
ShxSectionDivider(indent = 0.dp)
}
AddRow(stringResource(R.string.routing_patterns_add), onAdd)
}
}
}
}
/** "Unreachable hosts": direct destinations the engine keeps refusing. */
@Composable
internal fun DeadHostsPage(targets: List<DeadTargetUi>, onBack: () -> Unit)
{
SubPage(stringResource(R.string.dead_targets_title), onBack)
{
Hint(stringResource(R.string.dead_targets_summary))
if (targets.isNotEmpty())
{
ShxCard(Modifier.fillMaxWidth())
{
Column {
targets.forEachIndexed { index, target ->
if (index > 0)
{
ShxSectionDivider(indent = 0.dp)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
)
{
Box(
modifier = Modifier
.size(8.dp)
.background(MaterialTheme.colorScheme.error, CircleShape),
)
Column(Modifier.weight(1f))
{
Text(
text = target.dst,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
)
Text(
text = listOfNotNull(
target.owner,
stringResource(R.string.dead_targets_fails_fmt, target.fails),
stringResource(R.string.dead_targets_cooldown_fmt, formatDuration(target.leftMs / 1000)),
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
@Composable
private fun AddRow(label: String, onClick: () -> Unit)
{
var expanded by rememberSaveable { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
.clickable(onClick = onClick)
.heightIn(min = 56.dp)
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
{
Text(
text = stringResource(if (expanded) R.string.routing_details_less else R.string.routing_details_more),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(ShxSpacing.xs))
Icon(
if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
modifier = Modifier.size(22.dp),
)
}
AnimatedVisibility(
visible = expanded,
enter = expandVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow)) +
fadeIn(tween(120)),
exit = shrinkVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMedium)) +
fadeOut(tween(90)),
)
{
Column(Modifier.padding(start = ShxSpacing.lg, end = ShxSpacing.lg, bottom = ShxSpacing.md))
{
Text(
text = stringResource(
if (mode == Constants.RUSPLIT_MODE_USERSPACE)
{
R.string.rusplit_mode_userspace_desc
}
else
{
R.string.rusplit_mode_system_desc
}
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.rusplit_source),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
Text(
text = stringResource(R.string.rusplit_beta_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = ShxSpacing.xs),
)
}
}
}
/** Freshness/count chips of the geoip list. */
@Composable
internal fun GeoipChipsRow(state: RuIpListState)
{
val status = LocalShxStatusColors.current
val fresh = state.lastUpdatedMs > 0L &&
System.currentTimeMillis() - state.lastUpdatedMs < 26 * 60 * 60 * 1000L
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(start = ShxSpacing.lg, end = ShxSpacing.lg, bottom = ShxSpacing.xs),
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
verticalArrangement = Arrangement.spacedBy(ShxSpacing.xs),
)
{
InfoChip(text = stringResource(R.string.routing_geoip_ipv4, state.ipv4Ranges))
InfoChip(text = stringResource(R.string.routing_geoip_ipv6, state.ipv6Ranges))
val freshnessText = when
{
state.lastUpdatedMs > 0L ->
stringResource(
R.string.routing_geoip_updated,
DateUtils.getRelativeTimeSpanString(state.lastUpdatedMs).toString(),
)
else -> stringResource(R.string.routing_geoip_never)
}
InfoChip(
text = freshnessText,
highlight = if (state.lastUpdatedMs > 0L && fresh) status.connected else null,
)
}
}
/** Small rounded info pill used across the screen. */
@Composable
internal fun InfoChip(text: String, highlight: Color? = null)
{
Surface(
shape = RoundedCornerShape(8.dp),
color = if (highlight != null) highlight.copy(alpha = 0.14f) else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = highlight ?: MaterialTheme.colorScheme.onSurfaceVariant,
)
{
Spacer(Modifier.width(ShxSpacing.lg))
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
@@ -368,44 +445,53 @@ internal fun ForcePrefixRow(
}
}
/** Trailing "add force-tunnel prefix" row. */
@Composable
internal fun AddForcePrefixRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
{
Icon(
Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(ShxSpacing.lg))
Text(
stringResource(R.string.routing_force_add),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
/** Add/edit dialog for a force-tunnel prefix; shows an error until valid. */
@Composable
internal fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean, onDismiss: () -> Unit)
{
TextInputDialog(
title = stringResource(R.string.routing_force_dialog_title),
hint = stringResource(R.string.routing_force_hint),
errorText = stringResource(R.string.routing_force_invalid),
confirmLabel = stringResource(R.string.routing_force_save),
initial = initial,
onConfirm = onConfirm,
onDismiss = onDismiss,
)
}
/** Add dialog for a package template. */
@Composable
internal fun PatternDialog(onConfirm: (String) -> Boolean, onDismiss: () -> Unit)
{
TextInputDialog(
title = stringResource(R.string.routing_patterns_dialog_title),
hint = stringResource(R.string.routing_patterns_input_hint),
errorText = stringResource(R.string.routing_patterns_invalid),
confirmLabel = stringResource(R.string.routing_force_save),
initial = "",
onConfirm = onConfirm,
onDismiss = onDismiss,
)
}
@Composable
private fun TextInputDialog(
title: String,
hint: String,
errorText: String,
confirmLabel: String,
initial: String,
onConfirm: (String) -> Boolean,
onDismiss: () -> Unit,
)
{
var text by remember { mutableStateOf(initial) }
var error by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.routing_force_dialog_title)) },
title = { Text(title) },
text = {
OutlinedTextField(
value = text,
@@ -415,10 +501,10 @@ internal fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean,
},
singleLine = true,
isError = error,
label = { Text(stringResource(R.string.routing_force_hint)) },
label = { Text(hint) },
supportingText = if (error)
{
{ Text(stringResource(R.string.routing_force_invalid)) }
{ Text(errorText) }
}
else
{
@@ -438,7 +524,7 @@ internal fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean,
}
})
{
Text(stringResource(R.string.routing_force_save))
Text(confirmLabel)
}
},
dismissButton = {
@@ -0,0 +1,185 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.compose.routing
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.theme.ShxSpacing
private const val STACK_ICONS = 3
/**
* "Apps direct" entry card: counter, a stack of real app icons and a chevron
* into the full list. While the list is empty it offers the one-tap preset
* for Russian apps instead.
*
* @param previewPackages a few direct packages for the icon stack (the rest is a "+N" bubble)
*/
@Composable
internal fun DirectAppsCard(
count: Int,
previewPackages: List<String>,
onOpen: () -> Unit,
onAddAllRf: () -> Unit,
modifier: Modifier = Modifier,
)
{
ShxCard(modifier = modifier.fillMaxWidth())
{
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpen)
.heightIn(min = 72.dp)
.padding(ShxSpacing.lg),
)
{
ShxIconBadge(Icons.Rounded.Apps)
Column(
Modifier
.weight(1f)
.padding(horizontal = ShxSpacing.lg),
)
{
Text(
text = stringResource(R.string.routing_apps_direct_title),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
text = if (count > 0)
{
pluralStringResource(R.plurals.routing_apps_direct_summary, count, count)
}
else
{
stringResource(R.string.routing_apps_direct_empty)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (count > 0)
{
IconStack(previewPackages.take(STACK_ICONS), extra = count - previewPackages.take(STACK_ICONS).size)
Spacer(Modifier.width(ShxSpacing.sm))
}
Icon(
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (count == 0)
{
FlowRow(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = Modifier.padding(start = 72.dp, end = ShxSpacing.lg, bottom = ShxSpacing.lg),
)
{
Button(onClick = onAddAllRf)
{
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.routing_apps_add_all_rf))
}
OutlinedButton(onClick = onOpen)
{
Text(stringResource(R.string.routing_apps_pick_manually))
}
}
}
}
}
}
/** Overlapping app icons with a "+N" bubble for the rest. */
@Composable
private fun IconStack(packages: List<String>, extra: Int)
{
val ring = MaterialTheme.colorScheme.surfaceContainerLow
Row(verticalAlignment = Alignment.CenterVertically)
{
packages.forEachIndexed { index, pkg ->
Box(
Modifier
.offset(x = (-8 * index).dp)
.size(28.dp)
.border(BorderStroke(2.dp, ring), CircleShape)
.padding(2.dp)
.clip(CircleShape),
)
{
AppIcon(pkg, 24.dp)
}
}
if (extra > 0)
{
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.offset(x = (-8 * packages.size).dp)
.heightIn(min = 28.dp),
)
{
Text(
text = "+$extra",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 5.dp),
)
}
}
}
}
@@ -0,0 +1,392 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.compose.routing
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import org.strongswan.android.R
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** "Apps direct": every installed internet app with a switch, grouped by category. */
@Composable
fun DirectAppsScreen(onBack: () -> Unit, viewModel: DirectAppsViewModel = viewModel())
{
val state by viewModel.ui.collectAsStateWithLifecycle()
androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.refresh() }
DirectAppsContent(
state = state,
onBack = onBack,
onQuery = viewModel::setQuery,
onFilter = viewModel::setFilter,
onShowSystem = viewModel::setShowSystem,
onToggleApp = viewModel::setAppDirect,
onToggleCategory = viewModel::setCategoryEnabled,
onAllRf = viewModel::enableAllRf,
)
}
/** Stateless body of [DirectAppsScreen] (previewable and testable). */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun DirectAppsContent(
state: DirectAppsUiState,
onBack: () -> Unit,
onQuery: (String) -> Unit,
onFilter: (AppsFilter) -> Unit,
onShowSystem: (Boolean) -> Unit,
onToggleApp: (String, Boolean) -> Unit,
onToggleCategory: (String, Boolean) -> Unit,
onAllRf: () -> Unit,
)
{
SecondaryScaffold(title = stringResource(R.string.routing_apps_direct_title), onBack = onBack) { padding ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = padding.calculateTopPadding(),
bottom = padding.calculateBottomPadding() + ShxSpacing.xl,
),
)
{
item(key = "summary") {
Text(
text = pluralStringResource(R.plurals.routing_apps_direct_summary, state.directCount, state.directCount),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.lg + ShxSpacing.xs),
)
}
item(key = "search") {
SearchField(
query = state.query,
onQuery = onQuery,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
}
item(key = "filters") {
Row(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
)
{
FilterTab(
label = stringResource(R.string.apps_filter_direct, state.directCount),
selected = state.filter == AppsFilter.DIRECT,
onClick = { onFilter(AppsFilter.DIRECT) },
)
FilterTab(
label = stringResource(R.string.apps_filter_vpn, state.tunnelCount),
selected = state.filter == AppsFilter.VPN,
onClick = { onFilter(AppsFilter.VPN) },
)
FilterTab(
label = stringResource(R.string.apps_filter_all),
selected = state.filter == AppsFilter.ALL,
onClick = { onFilter(AppsFilter.ALL) },
)
}
}
item(key = "quick") {
Column(Modifier.padding(top = ShxSpacing.lg)) {
Text(
text = stringResource(R.string.apps_quick_add),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.lg + ShxSpacing.xs, vertical = ShxSpacing.xs),
)
Row(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = ShxSpacing.lg),
)
{
state.quickChips.forEach { chip ->
QuickChip(
label = stringResource(categoryChipRes(chip.category)),
done = chip.done,
onClick = { onToggleCategory(chip.category, !chip.done) },
)
}
QuickChip(
label = stringResource(R.string.routing_apps_add_all_rf),
done = state.allRfDone,
onClick = onAllRf,
)
}
}
}
if (state.loading)
{
item(key = "loading") {
Box(
Modifier
.fillMaxWidth()
.padding(ShxSpacing.xl),
contentAlignment = Alignment.Center,
)
{
LoadingIndicator(Modifier.size(40.dp))
}
}
}
else if (state.groups.isEmpty())
{
item(key = "empty") {
Text(
text = stringResource(R.string.apps_nothing_found),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 48.dp, horizontal = ShxSpacing.xl),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
else
{
state.groups.forEach { group ->
item(key = "h:${group.category}") {
Text(
text = stringResource(categoryTitleRes(group.category)).uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(start = ShxSpacing.lg + ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm)
.semantics { heading() },
)
}
itemsIndexed(group.rows, key = { _, row -> "a:${row.packageName}" }) { index, row ->
AppRow(
row = row,
first = index == 0,
last = index == group.rows.lastIndex,
onToggle = { onToggleApp(row.packageName, it) },
)
}
}
}
item(key = "system") {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.toggleable(value = state.showSystem, role = Role.Checkbox, onValueChange = onShowSystem)
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm),
)
{
Checkbox(checked = state.showSystem, onCheckedChange = null)
Text(
text = stringResource(R.string.apps_show_system),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = ShxSpacing.md),
)
}
}
}
}
}
@Composable
private fun SearchField(query: String, onQuery: (String) -> Unit, modifier: Modifier = Modifier)
{
TextField(
value = query,
onValueChange = onQuery,
singleLine = true,
placeholder = { Text(stringResource(R.string.apps_search)) },
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
trailingIcon = if (query.isNotEmpty())
{
{
IconButton(onClick = { onQuery("") }) {
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.apps_search_clear))
}
}
}
else
{
null
},
shape = CircleShape,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
disabledIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
errorIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
modifier = modifier.fillMaxWidth(),
)
}
@Composable
private fun FilterTab(label: String, selected: Boolean, onClick: () -> Unit)
{
FilterChip(
selected = selected,
onClick = onClick,
label = { Text(label, maxLines = 1) },
shape = MaterialTheme.shapes.small,
)
}
@Composable
private fun QuickChip(label: String, done: Boolean, onClick: () -> Unit)
{
val status = LocalShxStatusColors.current
FilterChip(
selected = done,
onClick = onClick,
label = { Text(label, maxLines = 1) },
leadingIcon = {
Icon(
if (done) Icons.Rounded.Check else Icons.Rounded.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
},
shape = CircleShape,
colors = androidx.compose.material3.FilterChipDefaults.filterChipColors(
selectedContainerColor = status.connectedContainer,
selectedLabelColor = status.onConnectedContainer,
selectedLeadingIconColor = status.onConnectedContainer,
),
)
}
@Composable
private fun AppRow(row: DirectAppRow, first: Boolean, last: Boolean, onToggle: (Boolean) -> Unit)
{
val status = LocalShxStatusColors.current
val corner = 20.dp
val shape = RoundedCornerShape(
topStart = if (first) corner else 0.dp,
topEnd = if (first) corner else 0.dp,
bottomStart = if (last) corner else 0.dp,
bottomEnd = if (last) corner else 0.dp,
)
val reasonText = when (val reason = row.reason)
{
AppReason.Added -> stringResource(R.string.apps_reason_added)
is AppReason.Template -> stringResource(R.string.apps_reason_template, templateLabel(reason.match))
AppReason.Tunnel -> stringResource(R.string.apps_reason_vpn)
is AppReason.Override -> stringResource(R.string.apps_reason_override, templateLabel(reason.match))
}
val reasonColor = when (row.reason)
{
is AppReason.Added, is AppReason.Template -> status.connected
is AppReason.Override -> MaterialTheme.colorScheme.tertiary
AppReason.Tunnel -> MaterialTheme.colorScheme.onSurfaceVariant
}
val switchDescription = stringResource(R.string.apps_switch_desc, row.label)
Surface(
shape = shape,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
.toggleable(value = row.direct, role = Role.Switch, onValueChange = onToggle)
.semantics(mergeDescendants = true) { contentDescription = switchDescription }
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
{
AppIcon(row.packageName, 40.dp)
Column(
Modifier
.weight(1f)
.padding(horizontal = ShxSpacing.lg),
)
{
Text(
text = row.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = reasonText,
style = MaterialTheme.typography.bodySmall,
color = reasonColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Switch(checked = row.direct, onCheckedChange = null)
}
}
}
@@ -0,0 +1,241 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.compose.routing
import android.app.Application
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import androidx.compose.runtime.Immutable
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.logic.routing.RoutingConfig
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.logic.routing.RoutingPresets
import org.strongswan.android.logic.routing.TemplateMatch
import java.text.Collator
/** One installed app that can reach the network (holds INTERNET). */
@Immutable
data class InstalledApp(
val packageName: String,
val label: String,
/** A pure system app (not an updated one); hidden by default unless it is direct. */
val isSystem: Boolean,
)
enum class AppsFilter { DIRECT, VPN, ALL }
/** Why an app is in its current state; drives the grey/green line under the app name. */
@Immutable
sealed interface AppReason
{
/** Direct because the user switched it on. */
data object Added : AppReason
/** Direct because a category or a package template covers it. */
data class Template(val match: TemplateMatch) : AppReason
/** Goes through the VPN. */
data object Tunnel : AppReason
/** Goes through the VPN although a category or template would cover it. */
data class Override(val match: TemplateMatch) : AppReason
}
@Immutable
data class DirectAppRow(
val packageName: String,
val label: String,
val direct: Boolean,
val reason: AppReason,
)
/** Apps of one category; [category] null is the "Other" bucket. */
@Immutable
data class DirectAppsGroup(val category: String?, val rows: List<DirectAppRow>)
@Immutable
data class QuickChipState(val category: String, val done: Boolean)
@Immutable
data class DirectAppsUiState(
val loading: Boolean = true,
val groups: List<DirectAppsGroup> = emptyList(),
val directCount: Int = 0,
val tunnelCount: Int = 0,
val filter: AppsFilter = AppsFilter.DIRECT,
val query: String = "",
val showSystem: Boolean = false,
val quickChips: List<QuickChipState> = emptyList(),
/** Every category and the ru.* namespace are on. */
val allRfDone: Boolean = false,
)
/**
* State of the "Apps direct" screen: the installed internet apps (loaded off
* the main thread), joined with the routing config, grouped by category and
* filtered by search / tab. All heavy work runs on Dispatchers.Default.
*/
class DirectAppsViewModel(app: Application) : AndroidViewModel(app)
{
private val manager = RoutingManager.getInstance(app)
private val apps = MutableStateFlow<List<InstalledApp>?>(null)
private val query = MutableStateFlow("")
private val filter = MutableStateFlow(AppsFilter.DIRECT)
private val showSystem = MutableStateFlow(false)
val ui: StateFlow<DirectAppsUiState> = combine(
apps, manager.state, query, filter, showSystem,
) { installed, routing, q, f, system ->
DirectApps.buildUiState(installed, routing.config, q, f, system)
}
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DirectAppsUiState())
init
{
refresh()
}
/** (Re)loads the installed apps; the previous list stays visible meanwhile. */
fun refresh()
{
viewModelScope.launch {
apps.value = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(getApplication<Application>().packageManager, getApplication<Application>().packageName) }
}
}
fun setQuery(value: String)
{
query.value = value
}
fun setFilter(value: AppsFilter)
{
filter.value = value
}
fun setShowSystem(value: Boolean)
{
showSystem.value = value
}
fun setAppDirect(pkg: String, direct: Boolean) = manager.setAppDirect(pkg, direct)
fun setCategoryEnabled(category: String, enabled: Boolean) = manager.setCategoryEnabled(category, enabled)
fun enableAllRf() = manager.enableAllRfApps()
}
/** Pure parts of [DirectAppsViewModel]: loading and state building (unit tested). */
object DirectApps
{
/** All installed apps with the INTERNET permission, sorted by label; our own app is left out. */
fun loadInstalledApps(pm: PackageManager, ownPackage: String): List<InstalledApp>
{
val collator = Collator.getInstance()
return pm.getInstalledApplications(PackageManager.GET_META_DATA)
.asSequence()
.filter { it.packageName != ownPackage }
.filter { pm.checkPermission(android.Manifest.permission.INTERNET, it.packageName) == PackageManager.PERMISSION_GRANTED }
.map {
val flags = it.flags
val system = (flags and ApplicationInfo.FLAG_SYSTEM) != 0 && (flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) == 0
InstalledApp(it.packageName, it.loadLabel(pm)?.toString()?.takeIf { l -> l.isNotBlank() } ?: it.packageName, system)
}
.sortedWith { a, b -> collator.compare(a.label, b.label) }
.toList()
}
/** Pure state builder (also used by tests). */
fun buildUiState(
installed: List<InstalledApp>?,
config: RoutingConfig,
query: String,
filter: AppsFilter,
showSystem: Boolean,
): DirectAppsUiState
{
val quick = RoutingPresets.CATEGORIES.map { QuickChipState(it, it in config.enabledCategories) }
val allRf = RoutingPresets.ALL_CATEGORIES.all { it in config.enabledCategories }
if (installed == null)
{
return DirectAppsUiState(loading = true, filter = filter, query = query, showSystem = showSystem,
quickChips = quick, allRfDone = allRf)
}
val effective = RoutingManager.effectiveExcludes(config, installed.mapTo(HashSet()) { it.packageName })
val rows = installed.map { app ->
val direct = app.packageName in effective
val match = RoutingManager.templateMatch(app.packageName, config)
val reason = when
{
direct && app.packageName in config.directPackages -> AppReason.Added
direct && match != null -> AppReason.Template(match)
direct -> AppReason.Added
match != null && app.packageName in config.tunnelOverrides -> AppReason.Override(match)
else -> AppReason.Tunnel
}
app to DirectAppRow(app.packageName, app.label, direct, reason)
}
/* system apps are hidden behind the toggle, but a direct one always shows */
val visible = rows.filter { (app, row) -> showSystem || !app.isSystem || row.direct }
val q = query.trim()
val searched = if (q.isEmpty())
{
visible
}
else
{
visible.filter { (app, _) ->
app.label.contains(q, ignoreCase = true) || app.packageName.contains(q, ignoreCase = true)
}
}
val directCount = visible.count { it.second.direct }
val tunnelCount = visible.size - directCount
val shown = searched.filter { (_, row) ->
when (filter)
{
AppsFilter.DIRECT -> row.direct
AppsFilter.VPN -> !row.direct
AppsFilter.ALL -> true
}
}
val byCategory = shown.groupBy { (app, _) -> RoutingPresets.categoryOf(app.packageName) }
val groups = (RoutingPresets.CATEGORIES + listOf<String?>(null)).mapNotNull { category ->
byCategory[category]?.let { DirectAppsGroup(category, it.map { pair -> pair.second }) }
}
return DirectAppsUiState(
loading = false,
groups = groups,
directCount = directCount,
tunnelCount = tunnelCount,
filter = filter,
query = query,
showSystem = showSystem,
quickChips = quick,
allRfDone = allRf,
)
}
}
@@ -4,134 +4,74 @@
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose.routing
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import android.app.Activity
import android.content.Intent
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.Block
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Lan
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Route
import androidx.compose.material.icons.rounded.Rule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.routing.ForceTunnelPrefix
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.logic.routing.RuIpListState
import org.strongswan.android.logic.routing.RuIpListStatus
import org.strongswan.android.utils.Constants
import org.strongswan.android.ui.RouteCheckActivity
import org.strongswan.android.ui.SelectedApplicationsActivity
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Hero card: master switch plus a live summary of what the rules currently affect. */
/** Plain-words summary of what currently bypasses the VPN. */
@Composable
internal fun routingSummary(enabled: Boolean, ruEnabled: Boolean, appCount: Int): String
{
if (!enabled)
{
return stringResource(R.string.routing_hero_off_summary)
}
val parts = ArrayList<String>()
if (ruEnabled)
{
parts.add(stringResource(R.string.routing_hero_part_sites))
}
if (appCount > 0)
{
parts.add(pluralStringResource(R.plurals.routing_apps_n, appCount, appCount))
}
return if (parts.isEmpty())
{
stringResource(R.string.routing_hero_nothing)
}
else
{
stringResource(R.string.routing_hero_summary, parts.joinToString(" · "))
}
}
/** Hero card: the master switch plus a one-line summary of what bypasses the VPN. */
@Composable
internal fun RoutingHeroCard(
enabled: Boolean,
setCount: Int,
appCount: Int,
summary: String,
onToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier,
)
@@ -146,7 +86,9 @@ internal fun RoutingHeroCard(
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = ShxSpacing.lg, top = 14.dp, bottom = 14.dp, end = ShxSpacing.sm),
modifier = Modifier
.toggleable(value = enabled, role = Role.Switch, onValueChange = onToggle)
.padding(start = ShxSpacing.lg, top = 14.dp, bottom = 14.dp, end = ShxSpacing.lg),
)
{
Icon(
@@ -158,23 +100,16 @@ internal fun RoutingHeroCard(
Column(Modifier.weight(1f))
{
Text(
text = stringResource(if (enabled) R.string.routing_hero_on else R.string.routing_hero_off),
text = stringResource(R.string.routing_hero_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = if (enabled)
{
stringResource(R.string.routing_hero_summary, setCount, appCount)
}
else
{
stringResource(R.string.routing_hero_off_summary)
},
style = MaterialTheme.typography.bodySmall,
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = if (enabled)
{
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f)
}
else
{
@@ -182,38 +117,8 @@ internal fun RoutingHeroCard(
},
)
}
Switch(checked = enabled, onCheckedChange = onToggle)
Spacer(Modifier.width(ShxSpacing.sm))
}
}
}
/** Quick action: create/enable rule sets for all RF bundle presets. */
@Composable
internal fun RfActionRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
{
ShxIconBadge(Icons.Rounded.Block)
Spacer(Modifier.width(ShxSpacing.lg))
Column(Modifier.weight(1f))
{
Text(
stringResource(R.string.routing_rf_quick_title),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
stringResource(R.string.routing_rf_quick_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(ShxSpacing.md))
Switch(checked = enabled, onCheckedChange = null)
}
}
}
@@ -0,0 +1,123 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.compose.routing
import android.util.LruCache
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.logic.routing.RoutingPresets
import org.strongswan.android.logic.routing.TemplateMatch
/** Process-wide cache of decoded launcher icons, so scrolling a long list never re-decodes. */
private object AppIconCache
{
private val cache = object : LruCache<String, ImageBitmap>(256) {}
fun get(key: String): ImageBitmap? = cache.get(key)
fun put(key: String, value: ImageBitmap)
{
cache.put(key, value)
}
}
/** The launcher icon of an installed app, decoded off the main thread; a tinted square while loading. */
@Composable
fun AppIcon(packageName: String, size: Dp, modifier: Modifier = Modifier)
{
val context = LocalContext.current
val px = with(LocalDensity.current) { size.roundToPx() }
val key = "$packageName@$px"
val bitmap by produceState(initialValue = AppIconCache.get(key), key) {
if (value == null)
{
value = withContext(Dispatchers.IO) {
try
{
context.packageManager.getApplicationIcon(packageName).toBitmap(px, px).asImageBitmap()
.also { AppIconCache.put(key, it) }
}
catch (_: Exception)
{
null
}
}
}
}
val image = bitmap
if (image != null)
{
Image(bitmap = image, contentDescription = null, modifier = modifier.size(size))
}
else
{
Box(
modifier
.size(size)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
)
}
}
/** Short chip label of a built-in category. */
@StringRes
fun categoryChipRes(category: String): Int = when (category)
{
RoutingPresets.BANKS -> R.string.routing_cat_banks
RoutingPresets.GOV -> R.string.routing_cat_gov
RoutingPresets.SHOP -> R.string.routing_cat_shop
RoutingPresets.SOCIAL -> R.string.routing_cat_social
RoutingPresets.TRAVEL -> R.string.routing_cat_travel
RoutingPresets.SERVICES -> R.string.routing_cat_services
else -> R.string.routing_cat_namespace
}
/** Group heading of a category in the apps list (null = the "Other" bucket). */
@StringRes
fun categoryTitleRes(category: String?): Int = when (category)
{
RoutingPresets.BANKS -> R.string.routing_cat_banks_title
RoutingPresets.GOV -> R.string.routing_cat_gov_title
RoutingPresets.SHOP -> R.string.routing_cat_shop_title
RoutingPresets.SOCIAL -> R.string.routing_cat_social_title
RoutingPresets.TRAVEL -> R.string.routing_cat_travel_title
RoutingPresets.SERVICES -> R.string.routing_cat_services_title
else -> R.string.routing_cat_other_title
}
/** The name to show in "by template ..." for a match: the category label or the user's pattern. */
@Composable
fun templateLabel(match: TemplateMatch): String =
match.category?.let { stringResource(categoryChipRes(it)) } ?: match.pattern.orEmpty()
@@ -0,0 +1,277 @@
/*
* 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))
Text(text = title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
if (beta)
{
Spacer(Modifier.width(6.dp))
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,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
}
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** "Address database: N networks - updated 3 h ago" with the refresh button. */
@OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun DatabaseStatusRow(state: RuIpListState, onRefresh: () -> Unit)
{
val text = if (state.lastUpdatedMs > 0L)
{
stringResource(
R.string.routing_db_status,
NumberFormat.getIntegerInstance().format(state.ipv4Ranges + state.ipv6Ranges),
DateUtils.getRelativeTimeSpanString(state.lastUpdatedMs).toString(),
)
}
else
{
stringResource(R.string.routing_db_never)
}
Row(verticalAlignment = Alignment.CenterVertically)
{
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (state.status == RuIpListStatus.UPDATING)
{
LoadingIndicator(Modifier.size(24.dp))
Spacer(Modifier.width(ShxSpacing.md))
}
else
{
IconButton(onClick = onRefresh)
{
Icon(
Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.routing_db_refresh),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
@@ -1,198 +0,0 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose.routing
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import android.app.Activity
import android.content.Intent
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.Block
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Lan
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Route
import androidx.compose.material.icons.rounded.Rule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.routing.ForceTunnelPrefix
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.logic.routing.RuIpListState
import org.strongswan.android.logic.routing.RuIpListStatus
import org.strongswan.android.utils.Constants
import org.strongswan.android.ui.RouteCheckActivity
import org.strongswan.android.ui.SelectedApplicationsActivity
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Collapsed rule set row: tap opens the editor sheet, the switch toggles in place. */
@Composable
internal fun RuleSetRow(
ruleSet: RuleSet,
onToggle: (Boolean) -> Unit,
onOpen: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpen)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
{
ShxIconBadge(
Icons.Rounded.Rule,
tint = if (ruleSet.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(ShxSpacing.lg))
Column(Modifier.weight(1f))
{
Text(
ruleSet.name.ifBlank { stringResource(R.string.routing_rule_name) },
style = MaterialTheme.typography.bodyLarge,
color = if (ruleSet.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stringResource(
R.string.routing_rule_subtitle,
ruleSet.packages.size,
ruleSet.prefixes.size,
ruleSet.keywords.size,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = ruleSet.enabled, onCheckedChange = onToggle)
}
}
/** Trailing "add rule set" row of the rule sets card. */
@Composable
internal fun AddRuleRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
{
Icon(
Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(ShxSpacing.lg))
Text(
stringResource(R.string.routing_add_rule),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
@@ -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()
}
}