diff --git a/src/frontends/android/app/src/androidTest/java/org/strongswan/android/ui/compose/DirectAppsScreenTest.kt b/src/frontends/android/app/src/androidTest/java/org/strongswan/android/ui/compose/DirectAppsScreenTest.kt new file mode 100644 index 000000000..6ab12e10d --- /dev/null +++ b/src/frontends/android/app/src/androidTest/java/org/strongswan/android/ui/compose/DirectAppsScreenTest.kt @@ -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 . + * + * 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? = 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) + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/MainActivity.java b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/MainActivity.java index a264d2936..73e064800 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/MainActivity.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/MainActivity.java @@ -21,6 +21,8 @@ import android.os.Bundle; import org.strongswan.android.logic.updates.UpdateManager; import org.strongswan.android.ui.compose.ShxAppKt; +import org.strongswan.android.ui.compose.ShxNavigation; +import org.strongswan.android.ui.compose.ShxTab; import androidx.appcompat.app.AppCompatActivity; import androidx.core.splashscreen.SplashScreen; @@ -35,6 +37,11 @@ public class MainActivity extends AppCompatActivity */ public static final String EXTRA_INSTALL_UPDATE = "org.strongswan.android.extra.INSTALL_UPDATE"; + /** + * Set as an intent extra to open the Routing tab (e.g. from the profile form) + */ + public static final String EXTRA_OPEN_ROUTING = "org.strongswan.android.extra.OPEN_ROUTING"; + /** * Use "bring your own device" (BYOD) features */ @@ -47,6 +54,7 @@ public class MainActivity extends AppCompatActivity SplashScreen.Companion.installSplashScreen(this); super.onCreate(savedInstanceState); handleInstallUpdate(getIntent()); + handleOpenRouting(getIntent()); ShxAppKt.installShxUi(this); } @@ -64,6 +72,16 @@ public class MainActivity extends AppCompatActivity { super.onNewIntent(intent); handleInstallUpdate(intent); + handleOpenRouting(intent); + } + + private void handleOpenRouting(Intent intent) + { + if (intent != null && intent.getBooleanExtra(EXTRA_OPEN_ROUTING, false)) + { + intent.removeExtra(EXTRA_OPEN_ROUTING); + ShxNavigation.INSTANCE.request(ShxTab.ROUTING); + } } private void handleInstallUpdate(Intent intent) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt index 9612d4e83..73ffc52b3 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt @@ -4,103 +4,61 @@ * This program is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the * Free Software Foundation; either version 2 of the License, or (at your - * option) any later version. + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. */ -@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) - package org.strongswan.android.ui.compose -import org.strongswan.android.ui.compose.components.ShxSnackbarHost -import android.app.Activity import android.content.Intent -import android.text.format.DateUtils -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.AltRoute -import androidx.compose.material.icons.rounded.Block -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.ExpandLess -import androidx.compose.material.icons.rounded.ExpandMore -import androidx.compose.material.icons.rounded.Lan -import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Route -import androidx.compose.material.icons.rounded.Rule -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator +import androidx.compose.material.icons.rounded.Shield import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearWavyProgressIndicator -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.clickable -import androidx.compose.material.icons.rounded.ExpandLess -import androidx.compose.material.icons.rounded.ExpandMore -import androidx.compose.ui.semantics.heading -import androidx.compose.ui.semantics.semantics import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.Dispatchers @@ -108,51 +66,75 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.strongswan.android.R -import org.strongswan.android.data.VpnProfileDataSource import org.strongswan.android.logic.CharonVpnService -import org.strongswan.android.logic.routing.ForceTunnelPrefix -import org.strongswan.android.logic.routing.RuleSet import org.strongswan.android.logic.routing.RoutingManager import org.strongswan.android.logic.routing.RuIpListManager -import org.strongswan.android.logic.routing.RuIpListState -import org.strongswan.android.logic.routing.RuIpListStatus -import org.strongswan.android.utils.Constants import org.strongswan.android.ui.RouteCheckActivity -import org.strongswan.android.ui.SelectedApplicationsActivity -import org.strongswan.android.ui.compose.components.ShxEmptyState -import org.strongswan.android.ui.compose.components.ShxIconBadge -import org.strongswan.android.ui.compose.components.ShxSection -import org.strongswan.android.ui.compose.components.ShxSectionDivider -import org.strongswan.android.ui.compose.theme.LocalShxStatusColors -import org.strongswan.android.ui.compose.theme.ShxSpacing -import org.strongswan.android.ui.compose.routing.RoutingHeroCard -import org.strongswan.android.ui.compose.routing.RuToggleRow -import org.strongswan.android.ui.compose.routing.RuModeSelector -import org.strongswan.android.ui.compose.routing.RuDetailsDisclosure -import org.strongswan.android.ui.compose.routing.GeoipChipsRow -import org.strongswan.android.ui.compose.routing.RfActionRow -import org.strongswan.android.ui.compose.routing.RuleSetRow -import org.strongswan.android.ui.compose.routing.AddRuleRow -import org.strongswan.android.ui.compose.routing.ForcePrefixRow -import org.strongswan.android.ui.compose.routing.AddForcePrefixRow +import org.strongswan.android.ui.compose.components.PredictiveBackScale +import org.strongswan.android.ui.compose.components.ShxSnackbarHost +import org.strongswan.android.ui.compose.routing.AdvancedPage +import org.strongswan.android.ui.compose.routing.AdvancedSection +import org.strongswan.android.ui.compose.routing.DeadHostsPage +import org.strongswan.android.ui.compose.routing.DirectAppsCard +import org.strongswan.android.ui.compose.routing.DirectAppsScreen import org.strongswan.android.ui.compose.routing.ForcePrefixDialog +import org.strongswan.android.ui.compose.routing.ForcePrefixPage +import org.strongswan.android.ui.compose.routing.PatternDialog +import org.strongswan.android.ui.compose.routing.PatternsPage +import org.strongswan.android.ui.compose.routing.RoutingHeroCard +import org.strongswan.android.ui.compose.routing.RuSitesCard +import org.strongswan.android.ui.compose.routing.routingSummary +import org.strongswan.android.ui.compose.theme.ShxSpacing + +/** In-tab pages of the Routing tab; "back" returns to the main page, not out of the tab. */ +private enum class RoutingPage { MAIN, APPS, FORCE, PATTERNS, DEAD } @Composable fun RoutingScreen() { val snackbarHostState = remember { SnackbarHostState() } + var page by rememberSaveable { mutableStateOf(RoutingPage.MAIN) } + val effects = MaterialTheme.motionScheme.defaultEffectsSpec() + val spatial = MaterialTheme.motionScheme.defaultSpatialSpec() Scaffold( snackbarHost = { ShxSnackbarHost(snackbarHostState) }, containerColor = MaterialTheme.colorScheme.background, ) { _ -> - RoutingContent(snackbarHostState) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) + { + PredictiveBackScale( + enabled = page != RoutingPage.MAIN, + onBack = { page = RoutingPage.MAIN }, + modifier = Modifier + .widthIn(max = 640.dp) + .fillMaxSize(), + ) + { + AnimatedContent( + targetState = page, + transitionSpec = { + val forward = targetState != RoutingPage.MAIN + (fadeIn(effects) + slideInHorizontally(spatial) { if (forward) it / 8 else -it / 8 }) togetherWith + (fadeOut(effects) + slideOutHorizontally(spatial) { if (forward) -it / 8 else it / 8 }) + }, + label = "routingPage", + ) { current -> + RoutingPageContent(current, snackbarHostState, onNavigate = { page = it }, onBack = { page = RoutingPage.MAIN }) + } + } + } } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun RoutingContent(snackbarHostState: SnackbarHostState) +private fun RoutingPageContent( + page: RoutingPage, + snackbarHostState: SnackbarHostState, + onNavigate: (RoutingPage) -> Unit, + onBack: () -> Unit, +) { val context = LocalContext.current val manager = remember { RoutingManager.getInstance(context) } @@ -160,58 +142,40 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState) val state by manager.state.collectAsStateWithLifecycle() val ruState by ruList.state.collectAsStateWithLifecycle() val config = state.config - var editingId by rememberSaveable { mutableStateOf(null) } - var focusName by rememberSaveable { mutableStateOf(false) } - var pickTarget by remember { mutableStateOf(null) } - /* force-prefix dialog: null = hidden, "" = add, otherwise the entry id */ - var forceDialogId by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() - val pickApps = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val id = pickTarget - if (result.resultCode == Activity.RESULT_OK && id != null) - { - val packages = result.data?.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST) - ?: arrayListOf() - manager.updateRuleSetPackages(id, packages.toSet()) - } - pickTarget = null - } + /* force-prefix dialog: null = hidden, "" = add, otherwise the entry id */ + var forceDialogId by remember { mutableStateOf(null) } + var patternDialog by remember { mutableStateOf(false) } - /* real number of apps excluded from the tunnel: prefixes/keywords expanded + /* real packages that bypass the tunnel: categories and templates expanded * against installed apps (cached per config version inside the manager) */ - var excludedApps by remember { mutableStateOf(0) } - LaunchedEffect(config) - { - excludedApps = if (config.enabled) + val directPackages by produceState(initialValue = emptyList(), config) { + value = if (config.enabled) { - withContext(Dispatchers.IO) { manager.computeExcludedPackages().size } + withContext(Dispatchers.IO) { manager.computeExcludedPackages().filter { it != context.packageName }.sorted() } } else { - 0 + emptyList() } } - fun addNewSet() + /* dead direct destinations the engine keeps refusing */ + val deadTargets by produceState(initialValue = emptyList()) { - editingId = manager.addRuleSet() - focusName = true - } - - fun deleteSetWithUndo(ruleSet: RuleSet) - { - scope.launch { - manager.deleteRuleSet(ruleSet.id) - val result = snackbarHostState.showSnackbar( - message = context.getString(R.string.routing_delete_snackbar), - actionLabel = context.getString(R.string.action_undo), - duration = SnackbarDuration.Long, - ) - if (result == SnackbarResult.ActionPerformed) + while (true) + { + val raw = try { - manager.restoreRuleSet(ruleSet) + withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() } } + catch (_: Throwable) + { + null + } + value = HomeViewModel.parseDeadTargets(raw) + delay(5_000) } } @@ -231,307 +195,52 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState) } } - Column( - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(bottom = ShxSpacing.xl), - ) + when (page) { - TopAppBar( - title = { Text(stringResource(R.string.routing_title), fontWeight = FontWeight.SemiBold) }, - actions = { - IconButton(onClick = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) }) - { - Icon( - Icons.Rounded.Route, - contentDescription = stringResource(R.string.route_check_title), - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background, - ), + RoutingPage.APPS -> DirectAppsScreen(onBack = onBack) + RoutingPage.FORCE -> ForcePrefixPage( + prefixes = config.forceTunnelPrefixes, + onToggle = manager::setForcePrefixEnabled, + onEdit = { forceDialogId = it }, + onDelete = manager::deleteForcePrefix, + onAdd = { forceDialogId = "" }, + onBack = onBack, ) - - /* ---- hero: master toggle + live summary ---- */ - RoutingHeroCard( + RoutingPage.PATTERNS -> PatternsPage( + patterns = config.packagePatterns.sorted(), + onDelete = manager::removePattern, + onAdd = { patternDialog = true }, + onBack = onBack, + ) + RoutingPage.DEAD -> DeadHostsPage(targets = deadTargets, onBack = onBack) + RoutingPage.MAIN -> MainPage( enabled = config.enabled, - setCount = config.ruleSets.count { it.enabled }, - appCount = excludedApps, - onToggle = { manager.setGlobalEnabled(it) }, - modifier = Modifier.padding(horizontal = ShxSpacing.lg), - ) - - /* kill switch + excluded apps: excluded apps are added to the VPN's disallowed list - * (CharonVpnService.addDisallowedApplication), so the system lockdown does not - * cover them - on a tunnel drop they keep using the normal network */ - if (config.enabled && excludedApps > 0 && remember { context.isVpnLockdownEnabled() }) - { - Text( - text = stringResource(R.string.routing_killswitch_note, excludedApps), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = ShxSpacing.lg + ShxSpacing.xs, vertical = ShxSpacing.sm), - ) - } - - /* ---- dead direct destinations the engine keeps refusing ---- */ - val deadTargets by produceState(initialValue = emptyList()) - { - while (true) - { - val raw = try - { - withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() } - } - catch (_: Throwable) - { - null - } - value = HomeViewModel.parseDeadTargets(raw) - delay(5_000) - } - } - if (deadTargets.isNotEmpty()) - { - ShxSection(title = stringResource(R.string.dead_targets_title)) - { - Text( - text = stringResource(R.string.dead_targets_summary), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 6.dp), + ruEnabled = ruState.enabled, + ruState = ruState, + directPackages = directPackages, + forceCount = config.forceTunnelPrefixes.size, + patternCount = config.packagePatterns.size, + deadCount = deadTargets.size, + onMasterToggle = manager::setGlobalEnabled, + onRuToggle = ruList::setEnabled, + onRuMode = ruList::setMode, + onRefresh = { refreshGeoip() }, + onOpenApps = { onNavigate(RoutingPage.APPS) }, + onAddAllRf = manager::enableAllRfApps, + onOpenAdvanced = { + onNavigate( + when (it) + { + AdvancedPage.FORCE -> RoutingPage.FORCE + AdvancedPage.PATTERNS -> RoutingPage.PATTERNS + AdvancedPage.DEAD -> RoutingPage.DEAD + } ) - deadTargets.forEachIndexed { index, target -> - ShxSectionDivider() - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = ShxSpacing.lg, vertical = 10.dp), - ) { - Box( - modifier = Modifier - .size(8.dp) - .background(MaterialTheme.colorScheme.error, CircleShape), - ) - Column(Modifier.weight(1f)) { - Text( - text = target.dst, - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - ) - Text( - text = listOfNotNull( - target.owner, - stringResource(R.string.dead_targets_fails_fmt, target.fails), - stringResource( - R.string.dead_targets_cooldown_fmt, - formatDuration(target.leftMs / 1000), - ), - ).joinToString(" · "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } - - /* ---- RU networks direct: geoip list + routing mode ---- */ - ShxSection(title = stringResource(R.string.routing_section_ru)) - { - RuToggleRow( - title = stringResource(R.string.rusplit_toggle_title), - subtitle = stringResource(R.string.rusplit_toggle_summary), - checked = ruState.enabled, - updating = ruState.status == RuIpListStatus.UPDATING, - onCheckedChange = { ruList.setEnabled(it) }, - onUpdate = { refreshGeoip() }, - ) - } - - /* ---- rule sets ---- */ - ShxSection(title = stringResource(R.string.routing_rules_section)) - { - if (config.enabled) - { - RfActionRow(onClick = { manager.applyRfBundle() }) - if (config.ruleSets.isNotEmpty()) - { - ShxSectionDivider() - } - } - if (config.ruleSets.isEmpty()) - { - ShxEmptyState( - icon = Icons.Rounded.AltRoute, - message = stringResource(R.string.routing_empty), - actionLabel = stringResource(R.string.routing_add_rule), - onAction = { addNewSet() }, - ) - } - else - { - config.ruleSets.forEachIndexed { index, ruleSet -> - RuleSetRow( - ruleSet = ruleSet, - onToggle = { manager.setRuleSetEnabled(ruleSet.id, it) }, - onOpen = { editingId = ruleSet.id; focusName = false }, - ) - if (index < config.ruleSets.lastIndex) - { - ShxSectionDivider() - } - } - ShxSectionDivider() - AddRuleRow(onClick = { addNewSet() }) - } - } - - /* ---- advanced: userspace mode, GeoIP list, force-tunnel prefixes ---- */ - var advancedOpen by rememberSaveable { mutableStateOf(false) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { advancedOpen = !advancedOpen } - .padding(horizontal = ShxSpacing.lg + ShxSpacing.lg, vertical = ShxSpacing.lg), - ) - { - Text( - text = stringResource(R.string.routing_advanced).uppercase(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f).semantics { heading() }, - ) - Icon( - if (advancedOpen) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - AnimatedVisibility(visible = advancedOpen) - { - Column { - if (ruState.enabled) - { - ShxSection(title = stringResource(R.string.routing_section_ru)) - { - if (ruState.enabled) - { - RuModeSelector( - mode = ruState.mode, - onModeChange = { ruList.setMode(it) }, - ) - if (ruState.status == RuIpListStatus.UPDATING) - { - LinearWavyProgressIndicator( - Modifier - .fillMaxWidth() - .padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.xs), - ) - } - GeoipChipsRow(state = ruState) - RuDetailsDisclosure(mode = ruState.mode) - } - - } - } - /* ---- force-tunnel prefixes: always through the tunnel ---- */ - ShxSection(title = stringResource(R.string.routing_force_section)) - { - if (config.forceTunnelPrefixes.isEmpty()) - { - Text( - text = stringResource(R.string.routing_force_summary), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 6.dp), - ) - } - else - { - config.forceTunnelPrefixes.forEachIndexed { index, entry -> - ForcePrefixRow( - entry = entry, - onToggle = { manager.setForcePrefixEnabled(entry.id, it) }, - onEdit = { forceDialogId = entry.id }, - onDelete = { manager.deleteForcePrefix(entry.id) }, - ) - if (index < config.forceTunnelPrefixes.lastIndex) - { - ShxSectionDivider() - } - } - } - ShxSectionDivider() - AddForcePrefixRow(onClick = { forceDialogId = "" }) - } - - } - } - } - - /* ---- rule set editor, hosted outside the scrolling column ---- */ - val editing = editingId?.let { id -> config.ruleSets.firstOrNull { it.id == id } } - if (editing != null) - { - RuleSetEditorSheet( - ruleSet = editing, - requestNameFocus = focusName, - onDismiss = { editingId = null; focusName = false }, - onRename = { manager.updateRuleSetName(editing.id, it) }, - onPickApps = { - pickTarget = editing.id - val intent = Intent(context, SelectedApplicationsActivity::class.java) - intent.putStringArrayListExtra( - VpnProfileDataSource.KEY_SELECTED_APPS_LIST, - ArrayList(editing.packages), - ) - pickApps.launch(intent) - }, - onTogglePreset = { manager.togglePreset(editing.id, it) }, - onAddPrefix = { prefix -> - manager.updateConfig { cfg -> - cfg.copy(ruleSets = cfg.ruleSets.map { - if (it.id == editing.id) it.copy(prefixes = it.prefixes + prefix) else it - }) - } - }, - onRemovePrefix = { prefix -> - manager.updateConfig { cfg -> - cfg.copy(ruleSets = cfg.ruleSets.map { - if (it.id == editing.id) it.copy(prefixes = it.prefixes - prefix) else it - }) - } - }, - onAddKeyword = { keyword -> - manager.updateConfig { cfg -> - cfg.copy(ruleSets = cfg.ruleSets.map { - if (it.id == editing.id) it.copy(keywords = it.keywords + keyword.lowercase()) else it - }) - } - }, - onRemoveKeyword = { keyword -> - manager.updateConfig { cfg -> - cfg.copy(ruleSets = cfg.ruleSets.map { - if (it.id == editing.id) it.copy(keywords = it.keywords - keyword) else it - }) - } - }, - onDelete = { - deleteSetWithUndo(editing) - editingId = null - focusName = false }, ) } - /* ---- force prefix add/edit dialog, hosted outside the scrolling column ---- */ + /* ---- dialogs, hosted outside the scrolling pages ---- */ if (forceDialogId != null) { val initial = if (forceDialogId == "") @@ -557,4 +266,137 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState) onDismiss = { forceDialogId = null }, ) } + if (patternDialog) + { + PatternDialog(onConfirm = { manager.addPattern(it) }, onDismiss = { patternDialog = false }) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MainPage( + enabled: Boolean, + ruEnabled: Boolean, + ruState: org.strongswan.android.logic.routing.RuIpListState, + directPackages: List, + 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, + ) + } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt index 9d612c272..b48de0386 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt @@ -44,7 +44,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.NavigationRailItemDefaults +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.runtime.LaunchedEffect import androidx.compose.foundation.layout.Box @@ -73,6 +75,7 @@ import org.strongswan.android.data.VpnProfile import org.strongswan.android.data.VpnProfileDataSource import org.strongswan.android.logic.StrongSwanApplication import org.strongswan.android.logic.TrustedCertificateManager +import org.strongswan.android.logic.routing.RoutingManager import org.strongswan.android.ui.LogActivity import org.strongswan.android.ui.VpnProfileDetailActivity import org.strongswan.android.ui.compose.theme.ShxSpacing @@ -115,6 +118,37 @@ fun ShxApp(viewModel: HomeViewModel = viewModel()) viewModel.messages.collect { snackbarHostState.showSnackbar(context.getString(it)) } } + /* open-tab requests from other activities (profile form -> Routing) */ + val requestedTab by ShxNavigation.requestedTab.collectAsStateWithLifecycle() + LaunchedEffect(requestedTab) + { + requestedTab?.let { + viewModel.setTab(it) + ShxNavigation.consume() + } + } + + /* one-time notice after the 2.16 migration of "only selected apps" profiles */ + val routingManager = remember { RoutingManager.getInstance(context) } + val routingState by routingManager.state.collectAsStateWithLifecycle() + LaunchedEffect(routingState.migratedOnlyProfiles) + { + if (routingState.migratedOnlyProfiles > 0) + { + val result = snackbarHostState.showSnackbar( + message = context.getString(R.string.routing_migrated_only), + actionLabel = context.getString(R.string.dead_targets_open), + duration = SnackbarDuration.Indefinite, + withDismissAction = true, + ) + routingManager.consumeMigrationNotice() + if (result == SnackbarResult.ActionPerformed) + { + viewModel.setTab(ShxTab.ROUTING) + } + } + } + /* the home list keeps its scroll state across tab switches */ val homeListState = rememberLazyListState() diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxNavigation.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxNavigation.kt new file mode 100644 index 000000000..c53cdf597 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxNavigation.kt @@ -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 . + * + * 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(null) + val requestedTab: StateFlow = _requestedTab + + fun request(tab: ShxTab) + { + _requestedTab.value = tab + } + + fun consume() + { + _requestedTab.value = null + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/AdvancedRouting.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/AdvancedRouting.kt index a90641ea2..016b96f07 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/AdvancedRouting.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/AdvancedRouting.kt @@ -4,324 +4,401 @@ * This program is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the * Free Software Foundation; either version 2 of the License, or (at your - * option) any later version. + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. */ -@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) - package org.strongswan.android.ui.compose.routing -import org.strongswan.android.ui.compose.components.ShxSnackbarHost -import android.app.Activity -import android.content.Intent -import android.text.format.DateUtils -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.BorderStroke +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.AltRoute -import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.ExpandLess import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.Lan -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.Route -import androidx.compose.material.icons.rounded.Rule import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearWavyProgressIndicator -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.clickable -import androidx.compose.material.icons.rounded.ExpandLess -import androidx.compose.material.icons.rounded.ExpandMore -import androidx.compose.ui.semantics.heading -import androidx.compose.ui.semantics.semantics import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.draw.rotate import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.strongswan.android.R -import org.strongswan.android.data.VpnProfileDataSource -import org.strongswan.android.logic.CharonVpnService import org.strongswan.android.logic.routing.ForceTunnelPrefix -import org.strongswan.android.logic.routing.RuleSet -import org.strongswan.android.logic.routing.RoutingManager -import org.strongswan.android.logic.routing.RuIpListManager -import org.strongswan.android.logic.routing.RuIpListState -import org.strongswan.android.logic.routing.RuIpListStatus -import org.strongswan.android.utils.Constants -import org.strongswan.android.ui.RouteCheckActivity -import org.strongswan.android.ui.SelectedApplicationsActivity -import org.strongswan.android.ui.compose.components.ShxEmptyState +import org.strongswan.android.ui.compose.DeadTargetUi +import org.strongswan.android.ui.compose.SecondaryScaffold +import org.strongswan.android.ui.compose.components.ShxCard import org.strongswan.android.ui.compose.components.ShxIconBadge -import org.strongswan.android.ui.compose.components.ShxSection import org.strongswan.android.ui.compose.components.ShxSectionDivider -import org.strongswan.android.ui.compose.theme.LocalShxStatusColors +import org.strongswan.android.ui.compose.formatDuration import org.strongswan.android.ui.compose.theme.ShxSpacing -/** Geoip toggle row with a refresh/update slot in front of the switch. */ +/** Sub pages of the "Advanced" section. */ +enum class AdvancedPage { FORCE, PATTERNS, DEAD } + +/** One entry of the advanced list: title, hint, count badge and a chevron. */ @Composable -internal fun RuToggleRow( - title: String, - subtitle: String, - checked: Boolean, - updating: Boolean, - onCheckedChange: (Boolean) -> Unit, - onUpdate: () -> Unit, -) +private fun AdvancedRow(title: String, subtitle: String, count: Int, warn: Boolean, onClick: () -> Unit) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickable { onCheckedChange(!checked) } - .padding(horizontal = ShxSpacing.lg, vertical = 14.dp), + .clickable(onClick = onClick) + .heightIn(min = 56.dp) + .padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md), ) { - ShxIconBadge(Icons.Rounded.Block) - Spacer(Modifier.width(ShxSpacing.lg)) Column(Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.bodyLarge) + Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) Text( subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - if (updating) + Spacer(Modifier.width(ShxSpacing.md)) + Surface( + shape = MaterialTheme.shapes.small, + color = if (warn) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = if (warn) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant, + ) { - LoadingIndicator(Modifier.size(14.dp)) - Spacer(Modifier.width(ShxSpacing.md)) + Text( + text = count.toString(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) } - else - { - IconButton(onClick = onUpdate) + Icon( + Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** Collapsible "Advanced": always-via-VPN addresses, package templates and unreachable hosts. */ +@Composable +internal fun AdvancedSection( + forceCount: Int, + patternCount: Int, + deadCount: Int, + onOpen: (AdvancedPage) -> Unit, + modifier: Modifier = Modifier, +) +{ + var open by rememberSaveable { mutableStateOf(false) } + val rotation by animateFloatAsState(if (open) 180f else 0f, label = "advancedChevron") + ShxCard(modifier = modifier.fillMaxWidth()) + { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .toggleable(value = open, role = Role.Button, onValueChange = { open = it }) + .heightIn(min = 64.dp) + .padding(ShxSpacing.lg), + ) { + Column(Modifier.weight(1f)) + { + Text( + text = stringResource(R.string.routing_advanced), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + modifier = Modifier.semantics { heading() }, + ) + Text( + text = stringResource(R.string.routing_advanced_summary), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } Icon( - Icons.Rounded.Refresh, - contentDescription = stringResource(R.string.rusplit_update), + Icons.Rounded.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.rotate(rotation), ) } + AnimatedVisibility(visible = open) + { + Column(Modifier.padding(bottom = ShxSpacing.sm)) + { + AdvancedRow( + title = stringResource(R.string.routing_force_section), + subtitle = stringResource(R.string.routing_adv_force_sub), + count = forceCount, + warn = false, + onClick = { onOpen(AdvancedPage.FORCE) }, + ) + AdvancedRow( + title = stringResource(R.string.routing_patterns_title), + subtitle = stringResource(R.string.routing_adv_patterns_sub), + count = patternCount, + warn = false, + onClick = { onOpen(AdvancedPage.PATTERNS) }, + ) + AdvancedRow( + title = stringResource(R.string.dead_targets_title), + subtitle = stringResource(R.string.routing_adv_dead_sub), + count = deadCount, + warn = deadCount > 0, + onClick = { onOpen(AdvancedPage.DEAD) }, + ) + } + } } - Switch(checked = checked, onCheckedChange = onCheckedChange) } } -/** - * Selector for how the RU split is routed: OS routes with the coarsened - * complement (as before) or per-packet classification in native code. - */ +/* ------------------------------ sub pages ------------------------------ */ + @Composable -internal fun RuModeSelector(mode: String, onModeChange: (String) -> Unit) +private fun SubPage(title: String, onBack: () -> Unit, content: @Composable () -> Unit) { - SingleChoiceSegmentedButtonRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = ShxSpacing.lg, vertical = 10.dp), + SecondaryScaffold(title = title, onBack = onBack) { padding -> + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(padding) + .padding(horizontal = ShxSpacing.lg) + .padding(bottom = ShxSpacing.xl), + ) + { + content() + } + } +} + +@Composable +private fun Hint(text: String) +{ + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = ShxSpacing.xs, vertical = ShxSpacing.md), ) +} + +/** "Always via VPN": addresses that never go direct, even inside the RU ranges. */ +@Composable +internal fun ForcePrefixPage( + prefixes: List, + onToggle: (String, Boolean) -> Unit, + onEdit: (String) -> Unit, + onDelete: (String) -> Unit, + onAdd: () -> Unit, + onBack: () -> Unit, +) +{ + SubPage(stringResource(R.string.routing_force_section), onBack) { - SegmentedButton( - selected = mode == Constants.RUSPLIT_MODE_SYSTEM, - onClick = { onModeChange(Constants.RUSPLIT_MODE_SYSTEM) }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), - ) + Hint(stringResource(R.string.routing_force_summary)) + ShxCard(Modifier.fillMaxWidth()) { - Text(stringResource(R.string.rusplit_mode_system)) - } - SegmentedButton( - selected = mode == Constants.RUSPLIT_MODE_USERSPACE, - onClick = { onModeChange(Constants.RUSPLIT_MODE_USERSPACE) }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), - ) - { - Text(stringResource(R.string.rusplit_mode_userspace_short)) + Column { + prefixes.forEach { entry -> + ForcePrefixRow( + entry = entry, + onToggle = { onToggle(entry.id, it) }, + onEdit = { onEdit(entry.id) }, + onDelete = { onDelete(entry.id) }, + ) + ShxSectionDivider() + } + AddRow(stringResource(R.string.routing_force_add), onAdd) + } } } } -/** Mode description, list source and beta note behind a "Details" disclosure. */ +/** "Package templates": patterns like ru.* or *yandex* that select apps by package name. */ @Composable -internal fun RuDetailsDisclosure(mode: String) +internal fun PatternsPage( + patterns: List, + 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, onBack: () -> Unit) +{ + SubPage(stringResource(R.string.dead_targets_title), onBack) + { + Hint(stringResource(R.string.dead_targets_summary)) + if (targets.isNotEmpty()) + { + ShxCard(Modifier.fillMaxWidth()) + { + Column { + targets.forEachIndexed { index, target -> + if (index > 0) + { + ShxSectionDivider(indent = 0.dp) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ShxSpacing.lg, vertical = 10.dp), + ) + { + Box( + modifier = Modifier + .size(8.dp) + .background(MaterialTheme.colorScheme.error, CircleShape), + ) + Column(Modifier.weight(1f)) + { + Text( + text = target.dst, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + Text( + text = listOfNotNull( + target.owner, + stringResource(R.string.dead_targets_fails_fmt, target.fails), + stringResource(R.string.dead_targets_cooldown_fmt, formatDuration(target.leftMs / 1000)), + ).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + } +} + +@Composable +private fun AddRow(label: String, onClick: () -> Unit) { - var expanded by rememberSaveable { mutableStateOf(false) } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickable { expanded = !expanded } - .padding(horizontal = ShxSpacing.lg, vertical = 10.dp), + .clickable(onClick = onClick) + .heightIn(min = 56.dp) + .padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md), ) { - Text( - text = stringResource(if (expanded) R.string.routing_details_less else R.string.routing_details_more), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.width(ShxSpacing.xs)) Icon( - if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, + Icons.Rounded.Add, contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), + modifier = Modifier.size(22.dp), ) - } - AnimatedVisibility( - visible = expanded, - enter = expandVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow)) + - fadeIn(tween(120)), - exit = shrinkVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMedium)) + - fadeOut(tween(90)), - ) - { - Column(Modifier.padding(start = ShxSpacing.lg, end = ShxSpacing.lg, bottom = ShxSpacing.md)) - { - Text( - text = stringResource( - if (mode == Constants.RUSPLIT_MODE_USERSPACE) - { - R.string.rusplit_mode_userspace_desc - } - else - { - R.string.rusplit_mode_system_desc - } - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = stringResource(R.string.rusplit_source), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 6.dp), - ) - Text( - text = stringResource(R.string.rusplit_beta_note), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = ShxSpacing.xs), - ) - } - } -} - -/** Freshness/count chips of the geoip list. */ -@Composable -internal fun GeoipChipsRow(state: RuIpListState) -{ - val status = LocalShxStatusColors.current - val fresh = state.lastUpdatedMs > 0L && - System.currentTimeMillis() - state.lastUpdatedMs < 26 * 60 * 60 * 1000L - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(start = ShxSpacing.lg, end = ShxSpacing.lg, bottom = ShxSpacing.xs), - horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm), - verticalArrangement = Arrangement.spacedBy(ShxSpacing.xs), - ) - { - InfoChip(text = stringResource(R.string.routing_geoip_ipv4, state.ipv4Ranges)) - InfoChip(text = stringResource(R.string.routing_geoip_ipv6, state.ipv6Ranges)) - val freshnessText = when - { - state.lastUpdatedMs > 0L -> - stringResource( - R.string.routing_geoip_updated, - DateUtils.getRelativeTimeSpanString(state.lastUpdatedMs).toString(), - ) - else -> stringResource(R.string.routing_geoip_never) - } - InfoChip( - text = freshnessText, - highlight = if (state.lastUpdatedMs > 0L && fresh) status.connected else null, - ) - } -} - -/** Small rounded info pill used across the screen. */ -@Composable -internal fun InfoChip(text: String, highlight: Color? = null) -{ - Surface( - shape = RoundedCornerShape(8.dp), - color = if (highlight != null) highlight.copy(alpha = 0.14f) else MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = highlight ?: MaterialTheme.colorScheme.onSurfaceVariant, - ) - { + Spacer(Modifier.width(ShxSpacing.lg)) Text( - text = text, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, ) } } @@ -368,44 +445,53 @@ internal fun ForcePrefixRow( } } -/** Trailing "add force-tunnel prefix" row. */ -@Composable -internal fun AddForcePrefixRow(onClick: () -> Unit) -{ - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = ShxSpacing.lg, vertical = 14.dp), - ) - { - Icon( - Icons.Rounded.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp), - ) - Spacer(Modifier.width(ShxSpacing.lg)) - Text( - stringResource(R.string.routing_force_add), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - ) - } -} - /** Add/edit dialog for a force-tunnel prefix; shows an error until valid. */ @Composable internal fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean, onDismiss: () -> Unit) +{ + TextInputDialog( + title = stringResource(R.string.routing_force_dialog_title), + hint = stringResource(R.string.routing_force_hint), + errorText = stringResource(R.string.routing_force_invalid), + confirmLabel = stringResource(R.string.routing_force_save), + initial = initial, + onConfirm = onConfirm, + onDismiss = onDismiss, + ) +} + +/** Add dialog for a package template. */ +@Composable +internal fun PatternDialog(onConfirm: (String) -> Boolean, onDismiss: () -> Unit) +{ + TextInputDialog( + title = stringResource(R.string.routing_patterns_dialog_title), + hint = stringResource(R.string.routing_patterns_input_hint), + errorText = stringResource(R.string.routing_patterns_invalid), + confirmLabel = stringResource(R.string.routing_force_save), + initial = "", + onConfirm = onConfirm, + onDismiss = onDismiss, + ) +} + +@Composable +private fun TextInputDialog( + title: String, + hint: String, + errorText: String, + confirmLabel: String, + initial: String, + onConfirm: (String) -> Boolean, + onDismiss: () -> Unit, +) { var text by remember { mutableStateOf(initial) } var error by remember { mutableStateOf(false) } AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.routing_force_dialog_title)) }, + title = { Text(title) }, text = { OutlinedTextField( value = text, @@ -415,10 +501,10 @@ internal fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean, }, singleLine = true, isError = error, - label = { Text(stringResource(R.string.routing_force_hint)) }, + label = { Text(hint) }, supportingText = if (error) { - { Text(stringResource(R.string.routing_force_invalid)) } + { Text(errorText) } } else { @@ -438,7 +524,7 @@ internal fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean, } }) { - Text(stringResource(R.string.routing_force_save)) + Text(confirmLabel) } }, dismissButton = { diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DirectAppsCard.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DirectAppsCard.kt new file mode 100644 index 000000000..33820cbe7 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DirectAppsCard.kt @@ -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 . + * + * 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, + 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, 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), + ) + } + } + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DirectAppsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DirectAppsScreen.kt new file mode 100644 index 000000000..2df0719ca --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DirectAppsScreen.kt @@ -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 . + * + * 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) + } + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DirectAppsViewModel.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DirectAppsViewModel.kt new file mode 100644 index 000000000..9e940509f --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DirectAppsViewModel.kt @@ -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 . + * + * 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) + +@Immutable +data class QuickChipState(val category: String, val done: Boolean) + +@Immutable +data class DirectAppsUiState( + val loading: Boolean = true, + val groups: List = emptyList(), + val directCount: Int = 0, + val tunnelCount: Int = 0, + val filter: AppsFilter = AppsFilter.DIRECT, + val query: String = "", + val showSystem: Boolean = false, + val quickChips: List = 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?>(null) + private val query = MutableStateFlow("") + private val filter = MutableStateFlow(AppsFilter.DIRECT) + private val showSystem = MutableStateFlow(false) + + val ui: StateFlow = 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().packageManager, getApplication().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 + { + 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?, + 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(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, + ) + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHero.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHero.kt index 111dc34a8..1c5779471 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHero.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHero.kt @@ -4,134 +4,74 @@ * This program is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the * Free Software Foundation; either version 2 of the License, or (at your - * option) any later version. + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. */ -@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) - package org.strongswan.android.ui.compose.routing -import org.strongswan.android.ui.compose.components.ShxSnackbarHost -import android.app.Activity -import android.content.Intent -import android.text.format.DateUtils -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.AltRoute -import androidx.compose.material.icons.rounded.Block -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.ExpandLess -import androidx.compose.material.icons.rounded.ExpandMore -import androidx.compose.material.icons.rounded.Lan -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.Route -import androidx.compose.material.icons.rounded.Rule -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearWavyProgressIndicator -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.clickable -import androidx.compose.material.icons.rounded.ExpandLess -import androidx.compose.material.icons.rounded.ExpandMore -import androidx.compose.ui.semantics.heading -import androidx.compose.ui.semantics.semantics import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.strongswan.android.R -import org.strongswan.android.data.VpnProfileDataSource -import org.strongswan.android.logic.CharonVpnService -import org.strongswan.android.logic.routing.ForceTunnelPrefix -import org.strongswan.android.logic.routing.RuleSet -import org.strongswan.android.logic.routing.RoutingManager -import org.strongswan.android.logic.routing.RuIpListManager -import org.strongswan.android.logic.routing.RuIpListState -import org.strongswan.android.logic.routing.RuIpListStatus -import org.strongswan.android.utils.Constants -import org.strongswan.android.ui.RouteCheckActivity -import org.strongswan.android.ui.SelectedApplicationsActivity -import org.strongswan.android.ui.compose.components.ShxEmptyState -import org.strongswan.android.ui.compose.components.ShxIconBadge -import org.strongswan.android.ui.compose.components.ShxSection -import org.strongswan.android.ui.compose.components.ShxSectionDivider -import org.strongswan.android.ui.compose.theme.LocalShxStatusColors import org.strongswan.android.ui.compose.theme.ShxSpacing -/** Hero card: master switch plus a live summary of what the rules currently affect. */ +/** Plain-words summary of what currently bypasses the VPN. */ +@Composable +internal fun routingSummary(enabled: Boolean, ruEnabled: Boolean, appCount: Int): String +{ + if (!enabled) + { + return stringResource(R.string.routing_hero_off_summary) + } + val parts = ArrayList() + if (ruEnabled) + { + parts.add(stringResource(R.string.routing_hero_part_sites)) + } + if (appCount > 0) + { + parts.add(pluralStringResource(R.plurals.routing_apps_n, appCount, appCount)) + } + return if (parts.isEmpty()) + { + stringResource(R.string.routing_hero_nothing) + } + else + { + stringResource(R.string.routing_hero_summary, parts.joinToString(" · ")) + } +} + +/** Hero card: the master switch plus a one-line summary of what bypasses the VPN. */ @Composable internal fun RoutingHeroCard( enabled: Boolean, - setCount: Int, - appCount: Int, + summary: String, onToggle: (Boolean) -> Unit, modifier: Modifier = Modifier, ) @@ -146,7 +86,9 @@ internal fun RoutingHeroCard( { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(start = ShxSpacing.lg, top = 14.dp, bottom = 14.dp, end = ShxSpacing.sm), + modifier = Modifier + .toggleable(value = enabled, role = Role.Switch, onValueChange = onToggle) + .padding(start = ShxSpacing.lg, top = 14.dp, bottom = 14.dp, end = ShxSpacing.lg), ) { Icon( @@ -158,23 +100,16 @@ internal fun RoutingHeroCard( Column(Modifier.weight(1f)) { Text( - text = stringResource(if (enabled) R.string.routing_hero_on else R.string.routing_hero_off), + text = stringResource(R.string.routing_hero_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, ) Text( - text = if (enabled) - { - stringResource(R.string.routing_hero_summary, setCount, appCount) - } - else - { - stringResource(R.string.routing_hero_off_summary) - }, - style = MaterialTheme.typography.bodySmall, + text = summary, + style = MaterialTheme.typography.bodyMedium, color = if (enabled) { - MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f) } else { @@ -182,38 +117,8 @@ internal fun RoutingHeroCard( }, ) } - Switch(checked = enabled, onCheckedChange = onToggle) - Spacer(Modifier.width(ShxSpacing.sm)) - } - } -} - -/** Quick action: create/enable rule sets for all RF bundle presets. */ -@Composable -internal fun RfActionRow(onClick: () -> Unit) -{ - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = ShxSpacing.lg, vertical = 14.dp), - ) - { - ShxIconBadge(Icons.Rounded.Block) - Spacer(Modifier.width(ShxSpacing.lg)) - Column(Modifier.weight(1f)) - { - Text( - stringResource(R.string.routing_rf_quick_title), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - ) - Text( - stringResource(R.string.routing_rf_quick_summary), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Spacer(Modifier.width(ShxSpacing.md)) + Switch(checked = enabled, onCheckedChange = null) } } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingUiUtil.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingUiUtil.kt new file mode 100644 index 000000000..992e15df2 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingUiUtil.kt @@ -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 . + * + * 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(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() diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RuSitesCard.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RuSitesCard.kt new file mode 100644 index 000000000..d9d30ef73 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RuSitesCard.kt @@ -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 . + * + * 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, + ) + } + } + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RuleSetList.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RuleSetList.kt deleted file mode 100644 index 9e51d31b3..000000000 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RuleSetList.kt +++ /dev/null @@ -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, - ) - } -} diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/ui/compose/routing/DirectAppsStateTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/ui/compose/routing/DirectAppsStateTest.kt new file mode 100644 index 000000000..b7c787a2d --- /dev/null +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/ui/compose/routing/DirectAppsStateTest.kt @@ -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 . + * + * 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() + } +}