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