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 883347bd8..b3c67329f 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 @@ -61,6 +61,12 @@ 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 @@ -119,6 +125,7 @@ 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.ScreenPadding +import org.strongswan.android.ui.compose.theme.ShxSpacing @Composable fun RoutingScreen() @@ -246,6 +253,19 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState) modifier = Modifier.padding(horizontal = ScreenPadding), ) + /* 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 = ScreenPadding + ShxSpacing.xs, vertical = ShxSpacing.sm), + ) + } + /* ---- dead direct destinations the engine keeps refusing ---- */ val deadTargets by produceState(initialValue = emptyList()) { @@ -322,55 +342,6 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState) onCheckedChange = { ruList.setEnabled(it) }, onUpdate = { refreshGeoip() }, ) - if (ruState.enabled) - { - ShxSectionDivider() - RuModeSelector( - mode = ruState.mode, - onModeChange = { ruList.setMode(it) }, - ) - if (ruState.status == RuIpListStatus.UPDATING) - { - LinearWavyProgressIndicator( - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - ) - } - 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 = 16.dp, 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 sets ---- */ @@ -410,6 +381,89 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState) 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 = ScreenPadding + 16.dp, vertical = 16.dp), + ) + { + 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 = 16.dp, vertical = 4.dp), + ) + } + 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 = 16.dp, 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 ---- */ diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt index b7bff8d2b..a56c9ce87 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt @@ -612,7 +612,7 @@ private fun updateStatusText(state: UpdateState): String * Checks whether the system-level lockdown ("Block connections without VPN") is * active for our package. Returns false if not determinable. */ -private fun Context.isVpnLockdownEnabled(): Boolean +internal fun Context.isVpnLockdownEnabled(): Boolean { return try { diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index d794091b0..af4628d26 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -491,4 +491,6 @@ Управление профилями Показать детали Скрыть детали + Дополнительно + Исключённые приложения (%1$d) всегда идут мимо VPN, в том числе при обрыве туннеля: kill switch их не блокирует. diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index a9eb360e5..062294e9d 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -496,4 +496,6 @@ Manage profiles Show details Hide details + Advanced + %1$d excluded app(s) always use the normal network, even when the tunnel drops: the kill switch does not block them.