android: Routing tab redesign - RU sites card, direct apps screen, advanced pages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.logic.updates.UpdateManager;
|
||||||
import org.strongswan.android.ui.compose.ShxAppKt;
|
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.appcompat.app.AppCompatActivity;
|
||||||
import androidx.core.splashscreen.SplashScreen;
|
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";
|
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
|
* Use "bring your own device" (BYOD) features
|
||||||
*/
|
*/
|
||||||
@@ -47,6 +54,7 @@ public class MainActivity extends AppCompatActivity
|
|||||||
SplashScreen.Companion.installSplashScreen(this);
|
SplashScreen.Companion.installSplashScreen(this);
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
handleInstallUpdate(getIntent());
|
handleInstallUpdate(getIntent());
|
||||||
|
handleOpenRouting(getIntent());
|
||||||
ShxAppKt.installShxUi(this);
|
ShxAppKt.installShxUi(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +72,16 @@ public class MainActivity extends AppCompatActivity
|
|||||||
{
|
{
|
||||||
super.onNewIntent(intent);
|
super.onNewIntent(intent);
|
||||||
handleInstallUpdate(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)
|
private void handleInstallUpdate(Intent intent)
|
||||||
|
|||||||
+257
-415
@@ -4,103 +4,61 @@
|
|||||||
* This program is free software; you can redistribute it and/or modify it
|
* 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
|
* 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
|
* 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
|
package org.strongswan.android.ui.compose
|
||||||
|
|
||||||
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.text.format.DateUtils
|
import androidx.compose.animation.AnimatedContent
|
||||||
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.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.verticalScroll
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.material.icons.Icons
|
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.Route
|
||||||
import androidx.compose.material.icons.rounded.Rule
|
import androidx.compose.material.icons.rounded.Shield
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Scaffold
|
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.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.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.produceState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -108,51 +66,75 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
import org.strongswan.android.data.VpnProfileDataSource
|
|
||||||
import org.strongswan.android.logic.CharonVpnService
|
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.RoutingManager
|
||||||
import org.strongswan.android.logic.routing.RuIpListManager
|
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.RouteCheckActivity
|
||||||
import org.strongswan.android.ui.SelectedApplicationsActivity
|
import org.strongswan.android.ui.compose.components.PredictiveBackScale
|
||||||
import org.strongswan.android.ui.compose.components.ShxEmptyState
|
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
|
||||||
import org.strongswan.android.ui.compose.components.ShxIconBadge
|
import org.strongswan.android.ui.compose.routing.AdvancedPage
|
||||||
import org.strongswan.android.ui.compose.components.ShxSection
|
import org.strongswan.android.ui.compose.routing.AdvancedSection
|
||||||
import org.strongswan.android.ui.compose.components.ShxSectionDivider
|
import org.strongswan.android.ui.compose.routing.DeadHostsPage
|
||||||
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
|
import org.strongswan.android.ui.compose.routing.DirectAppsCard
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
import org.strongswan.android.ui.compose.routing.DirectAppsScreen
|
||||||
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.routing.ForcePrefixDialog
|
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
|
@Composable
|
||||||
fun RoutingScreen()
|
fun RoutingScreen()
|
||||||
{
|
{
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
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(
|
Scaffold(
|
||||||
snackbarHost = { ShxSnackbarHost(snackbarHostState) },
|
snackbarHost = { ShxSnackbarHost(snackbarHostState) },
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun RoutingContent(snackbarHostState: SnackbarHostState)
|
private fun RoutingPageContent(
|
||||||
|
page: RoutingPage,
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
|
onNavigate: (RoutingPage) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
)
|
||||||
{
|
{
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val manager = remember { RoutingManager.getInstance(context) }
|
val manager = remember { RoutingManager.getInstance(context) }
|
||||||
@@ -160,58 +142,40 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
|
|||||||
val state by manager.state.collectAsStateWithLifecycle()
|
val state by manager.state.collectAsStateWithLifecycle()
|
||||||
val ruState by ruList.state.collectAsStateWithLifecycle()
|
val ruState by ruList.state.collectAsStateWithLifecycle()
|
||||||
val config = state.config
|
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 scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val pickApps = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
/* force-prefix dialog: null = hidden, "" = add, otherwise the entry id */
|
||||||
val id = pickTarget
|
var forceDialogId by remember { mutableStateOf<String?>(null) }
|
||||||
if (result.resultCode == Activity.RESULT_OK && id != null)
|
var patternDialog by remember { mutableStateOf(false) }
|
||||||
{
|
|
||||||
val packages = result.data?.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
|
|
||||||
?: arrayListOf()
|
|
||||||
manager.updateRuleSetPackages(id, packages.toSet())
|
|
||||||
}
|
|
||||||
pickTarget = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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) */
|
* against installed apps (cached per config version inside the manager) */
|
||||||
var excludedApps by remember { mutableStateOf(0) }
|
val directPackages by produceState(initialValue = emptyList<String>(), config) {
|
||||||
LaunchedEffect(config)
|
value = if (config.enabled)
|
||||||
{
|
|
||||||
excludedApps = if (config.enabled)
|
|
||||||
{
|
{
|
||||||
withContext(Dispatchers.IO) { manager.computeExcludedPackages().size }
|
withContext(Dispatchers.IO) { manager.computeExcludedPackages().filter { it != context.packageName }.sorted() }
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
0
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addNewSet()
|
/* dead direct destinations the engine keeps refusing */
|
||||||
|
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>())
|
||||||
{
|
{
|
||||||
editingId = manager.addRuleSet()
|
while (true)
|
||||||
focusName = true
|
{
|
||||||
}
|
val raw = try
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
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(
|
when (page)
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(bottom = ShxSpacing.xl),
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
TopAppBar(
|
RoutingPage.APPS -> DirectAppsScreen(onBack = onBack)
|
||||||
title = { Text(stringResource(R.string.routing_title), fontWeight = FontWeight.SemiBold) },
|
RoutingPage.FORCE -> ForcePrefixPage(
|
||||||
actions = {
|
prefixes = config.forceTunnelPrefixes,
|
||||||
IconButton(onClick = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) })
|
onToggle = manager::setForcePrefixEnabled,
|
||||||
{
|
onEdit = { forceDialogId = it },
|
||||||
Icon(
|
onDelete = manager::deleteForcePrefix,
|
||||||
Icons.Rounded.Route,
|
onAdd = { forceDialogId = "" },
|
||||||
contentDescription = stringResource(R.string.route_check_title),
|
onBack = onBack,
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
RoutingPage.PATTERNS -> PatternsPage(
|
||||||
/* ---- hero: master toggle + live summary ---- */
|
patterns = config.packagePatterns.sorted(),
|
||||||
RoutingHeroCard(
|
onDelete = manager::removePattern,
|
||||||
|
onAdd = { patternDialog = true },
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
RoutingPage.DEAD -> DeadHostsPage(targets = deadTargets, onBack = onBack)
|
||||||
|
RoutingPage.MAIN -> MainPage(
|
||||||
enabled = config.enabled,
|
enabled = config.enabled,
|
||||||
setCount = config.ruleSets.count { it.enabled },
|
ruEnabled = ruState.enabled,
|
||||||
appCount = excludedApps,
|
ruState = ruState,
|
||||||
onToggle = { manager.setGlobalEnabled(it) },
|
directPackages = directPackages,
|
||||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
|
forceCount = config.forceTunnelPrefixes.size,
|
||||||
)
|
patternCount = config.packagePatterns.size,
|
||||||
|
deadCount = deadTargets.size,
|
||||||
/* kill switch + excluded apps: excluded apps are added to the VPN's disallowed list
|
onMasterToggle = manager::setGlobalEnabled,
|
||||||
* (CharonVpnService.addDisallowedApplication), so the system lockdown does not
|
onRuToggle = ruList::setEnabled,
|
||||||
* cover them - on a tunnel drop they keep using the normal network */
|
onRuMode = ruList::setMode,
|
||||||
if (config.enabled && excludedApps > 0 && remember { context.isVpnLockdownEnabled() })
|
onRefresh = { refreshGeoip() },
|
||||||
{
|
onOpenApps = { onNavigate(RoutingPage.APPS) },
|
||||||
Text(
|
onAddAllRf = manager::enableAllRfApps,
|
||||||
text = stringResource(R.string.routing_killswitch_note, excludedApps),
|
onOpenAdvanced = {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
onNavigate(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
when (it)
|
||||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg + ShxSpacing.xs, vertical = ShxSpacing.sm),
|
{
|
||||||
)
|
AdvancedPage.FORCE -> RoutingPage.FORCE
|
||||||
}
|
AdvancedPage.PATTERNS -> RoutingPage.PATTERNS
|
||||||
|
AdvancedPage.DEAD -> RoutingPage.DEAD
|
||||||
/* ---- 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),
|
|
||||||
)
|
)
|
||||||
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)
|
if (forceDialogId != null)
|
||||||
{
|
{
|
||||||
val initial = if (forceDialogId == "")
|
val initial = if (forceDialogId == "")
|
||||||
@@ -557,4 +266,137 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
|
|||||||
onDismiss = { forceDialogId = null },
|
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.NavigationBarItemDefaults
|
||||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||||
import androidx.compose.material3.NavigationRailItemDefaults
|
import androidx.compose.material3.NavigationRailItemDefaults
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.foundation.layout.Box
|
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.data.VpnProfileDataSource
|
||||||
import org.strongswan.android.logic.StrongSwanApplication
|
import org.strongswan.android.logic.StrongSwanApplication
|
||||||
import org.strongswan.android.logic.TrustedCertificateManager
|
import org.strongswan.android.logic.TrustedCertificateManager
|
||||||
|
import org.strongswan.android.logic.routing.RoutingManager
|
||||||
import org.strongswan.android.ui.LogActivity
|
import org.strongswan.android.ui.LogActivity
|
||||||
import org.strongswan.android.ui.VpnProfileDetailActivity
|
import org.strongswan.android.ui.VpnProfileDetailActivity
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
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)) }
|
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 */
|
/* the home list keeps its scroll state across tab switches */
|
||||||
val homeListState = rememberLazyListState()
|
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
|
* 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
|
* 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
|
* 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
|
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.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.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.Delete
|
||||||
import androidx.compose.material.icons.rounded.ExpandLess
|
|
||||||
import androidx.compose.material.icons.rounded.ExpandMore
|
import androidx.compose.material.icons.rounded.ExpandMore
|
||||||
import androidx.compose.material.icons.rounded.Lan
|
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.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.Icon
|
||||||
import androidx.compose.material3.IconButton
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
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.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.produceState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
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.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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.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.ForceTunnelPrefix
|
||||||
import org.strongswan.android.logic.routing.RuleSet
|
import org.strongswan.android.ui.compose.DeadTargetUi
|
||||||
import org.strongswan.android.logic.routing.RoutingManager
|
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||||
import org.strongswan.android.logic.routing.RuIpListManager
|
import org.strongswan.android.ui.compose.components.ShxCard
|
||||||
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.ShxIconBadge
|
||||||
import org.strongswan.android.ui.compose.components.ShxSection
|
|
||||||
import org.strongswan.android.ui.compose.components.ShxSectionDivider
|
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
|
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
|
@Composable
|
||||||
internal fun RuToggleRow(
|
private fun AdvancedRow(title: String, subtitle: String, count: Int, warn: Boolean, onClick: () -> Unit)
|
||||||
title: String,
|
|
||||||
subtitle: String,
|
|
||||||
checked: Boolean,
|
|
||||||
updating: Boolean,
|
|
||||||
onCheckedChange: (Boolean) -> Unit,
|
|
||||||
onUpdate: () -> Unit,
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { onCheckedChange(!checked) }
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
|
.heightIn(min = 56.dp)
|
||||||
|
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
ShxIconBadge(Icons.Rounded.Block)
|
|
||||||
Spacer(Modifier.width(ShxSpacing.lg))
|
|
||||||
Column(Modifier.weight(1f))
|
Column(Modifier.weight(1f))
|
||||||
{
|
{
|
||||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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))
|
Text(
|
||||||
Spacer(Modifier.width(ShxSpacing.md))
|
text = count.toString(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
else
|
Icon(
|
||||||
{
|
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||||
IconButton(onClick = onUpdate)
|
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(
|
Icon(
|
||||||
Icons.Rounded.Refresh,
|
Icons.Rounded.ExpandMore,
|
||||||
contentDescription = stringResource(R.string.rusplit_update),
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/* ------------------------------ sub pages ------------------------------ */
|
||||||
* Selector for how the RU split is routed: OS routes with the coarsened
|
|
||||||
* complement (as before) or per-packet classification in native code.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RuModeSelector(mode: String, onModeChange: (String) -> Unit)
|
private fun SubPage(title: String, onBack: () -> Unit, content: @Composable () -> Unit)
|
||||||
{
|
{
|
||||||
SingleChoiceSegmentedButtonRow(
|
SecondaryScaffold(title = title, onBack = onBack) { padding ->
|
||||||
modifier = Modifier
|
Column(
|
||||||
.fillMaxWidth()
|
Modifier
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
|
.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(
|
Hint(stringResource(R.string.routing_force_summary))
|
||||||
selected = mode == Constants.RUSPLIT_MODE_SYSTEM,
|
ShxCard(Modifier.fillMaxWidth())
|
||||||
onClick = { onModeChange(Constants.RUSPLIT_MODE_SYSTEM) },
|
|
||||||
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
Text(stringResource(R.string.rusplit_mode_system))
|
Column {
|
||||||
}
|
prefixes.forEach { entry ->
|
||||||
SegmentedButton(
|
ForcePrefixRow(
|
||||||
selected = mode == Constants.RUSPLIT_MODE_USERSPACE,
|
entry = entry,
|
||||||
onClick = { onModeChange(Constants.RUSPLIT_MODE_USERSPACE) },
|
onToggle = { onToggle(entry.id, it) },
|
||||||
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
|
onEdit = { onEdit(entry.id) },
|
||||||
)
|
onDelete = { onDelete(entry.id) },
|
||||||
{
|
)
|
||||||
Text(stringResource(R.string.rusplit_mode_userspace_short))
|
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
|
@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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { expanded = !expanded }
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
|
.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(
|
Icon(
|
||||||
if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
|
Icons.Rounded.Add,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
}
|
Spacer(Modifier.width(ShxSpacing.lg))
|
||||||
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,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
label,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
maxLines = 1,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
overflow = TextOverflow.Ellipsis,
|
fontWeight = FontWeight.Medium,
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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. */
|
/** Add/edit dialog for a force-tunnel prefix; shows an error until valid. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean, onDismiss: () -> Unit)
|
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 text by remember { mutableStateOf(initial) }
|
||||||
var error by remember { mutableStateOf(false) }
|
var error by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(stringResource(R.string.routing_force_dialog_title)) },
|
title = { Text(title) },
|
||||||
text = {
|
text = {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = text,
|
value = text,
|
||||||
@@ -415,10 +501,10 @@ internal fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean,
|
|||||||
},
|
},
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
isError = error,
|
isError = error,
|
||||||
label = { Text(stringResource(R.string.routing_force_hint)) },
|
label = { Text(hint) },
|
||||||
supportingText = if (error)
|
supportingText = if (error)
|
||||||
{
|
{
|
||||||
{ Text(stringResource(R.string.routing_force_invalid)) }
|
{ Text(errorText) }
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -438,7 +524,7 @@ internal fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean,
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
Text(stringResource(R.string.routing_force_save))
|
Text(confirmLabel)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
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
|
* 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
|
* 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
|
* 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
|
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.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.Column
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
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.Icons
|
||||||
import androidx.compose.material.icons.rounded.Add
|
|
||||||
import androidx.compose.material.icons.rounded.AltRoute
|
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.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.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.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
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.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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
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.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.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
|
@Composable
|
||||||
internal fun RoutingHeroCard(
|
internal fun RoutingHeroCard(
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
setCount: Int,
|
summary: String,
|
||||||
appCount: Int,
|
|
||||||
onToggle: (Boolean) -> Unit,
|
onToggle: (Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
)
|
)
|
||||||
@@ -146,7 +86,9 @@ internal fun RoutingHeroCard(
|
|||||||
{
|
{
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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(
|
Icon(
|
||||||
@@ -158,23 +100,16 @@ internal fun RoutingHeroCard(
|
|||||||
Column(Modifier.weight(1f))
|
Column(Modifier.weight(1f))
|
||||||
{
|
{
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = if (enabled)
|
text = summary,
|
||||||
{
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
stringResource(R.string.routing_hero_summary, setCount, appCount)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
stringResource(R.string.routing_hero_off_summary)
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = if (enabled)
|
color = if (enabled)
|
||||||
{
|
{
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
|
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f)
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -182,38 +117,8 @@ internal fun RoutingHeroCard(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Switch(checked = enabled, onCheckedChange = onToggle)
|
Spacer(Modifier.width(ShxSpacing.md))
|
||||||
Spacer(Modifier.width(ShxSpacing.sm))
|
Switch(checked = enabled, onCheckedChange = null)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+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