android: split RoutingScreen into routing/{RoutingHero,RuleSetList,AdvancedRouting}

This commit is contained in:
Denozordec committed 2026-10-10 00:09:44 +07:00
1 parent 8bd65d535e
commit a451386e0a
4 files changed
+882 -487

No files matched your search

@@ -126,6 +126,17 @@ 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
import org.strongswan.android.ui.compose.routing.RoutingHeroCard
import org.strongswan.android.ui.compose.routing.RuToggleRow
import org.strongswan.android.ui.compose.routing.RuModeSelector
import org.strongswan.android.ui.compose.routing.RuDetailsDisclosure
import org.strongswan.android.ui.compose.routing.GeoipChipsRow
import org.strongswan.android.ui.compose.routing.RfActionRow
import org.strongswan.android.ui.compose.routing.RuleSetRow
import org.strongswan.android.ui.compose.routing.AddRuleRow
import org.strongswan.android.ui.compose.routing.ForcePrefixRow
import org.strongswan.android.ui.compose.routing.AddForcePrefixRow
import org.strongswan.android.ui.compose.routing.ForcePrefixDialog
@Composable
fun RoutingScreen()
@@ -548,490 +559,3 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
)
}
}
/** Hero card: master switch plus a live summary of what the rules currently affect. */
@Composable
private fun RoutingHeroCard(
enabled: Boolean,
setCount: Int,
appCount: Int,
onToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier,
)
{
Surface(
shape = MaterialTheme.shapes.large,
color = if (enabled) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerLow,
contentColor = if (enabled) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
border = if (enabled) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = modifier.fillMaxWidth(),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 14.dp, end = 8.dp),
)
{
Icon(
Icons.Rounded.AltRoute,
contentDescription = null,
tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f))
{
Text(
text = stringResource(if (enabled) R.string.routing_hero_on else R.string.routing_hero_off),
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,
color = if (enabled)
{
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
}
else
{
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
Switch(checked = enabled, onCheckedChange = onToggle)
Spacer(Modifier.width(8.dp))
}
}
}
/** Geoip toggle row with a refresh/update slot in front of the switch. */
@Composable
private fun RuToggleRow(
title: String,
subtitle: String,
checked: Boolean,
updating: Boolean,
onCheckedChange: (Boolean) -> Unit,
onUpdate: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onCheckedChange(!checked) }
.padding(horizontal = 16.dp, vertical = 14.dp),
)
{
ShxIconBadge(Icons.Rounded.Block)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f))
{
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (updating)
{
LoadingIndicator(Modifier.size(14.dp))
Spacer(Modifier.width(12.dp))
}
else
{
IconButton(onClick = onUpdate)
{
Icon(
Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.rusplit_update),
)
}
}
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.
*/
@Composable
private fun RuModeSelector(mode: String, onModeChange: (String) -> Unit)
{
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
)
{
SegmentedButton(
selected = mode == Constants.RUSPLIT_MODE_SYSTEM,
onClick = { onModeChange(Constants.RUSPLIT_MODE_SYSTEM) },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
)
{
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))
}
}
}
/** Mode description, list source and beta note behind a "Details" disclosure. */
@Composable
private fun RuDetailsDisclosure(mode: String)
{
var expanded by rememberSaveable { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(horizontal = 16.dp, vertical = 10.dp),
)
{
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(4.dp))
Icon(
if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.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 = 16.dp, end = 16.dp, bottom = 12.dp))
{
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 = 4.dp),
)
}
}
}
/** Freshness/count chips of the geoip list. */
@Composable
private 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 = 16.dp, end = 16.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
)
{
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
private fun InfoChip(text: String, highlight: Color? = null)
{
Surface(
shape = RoundedCornerShape(8.dp),
color = if (highlight != null) highlight.copy(alpha = 0.14f) else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = highlight ?: MaterialTheme.colorScheme.onSurfaceVariant,
)
{
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
/** Quick action: create/enable rule sets for all RF bundle presets. */
@Composable
private fun RfActionRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
)
{
ShxIconBadge(Icons.Rounded.Block)
Spacer(Modifier.width(16.dp))
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,
)
}
}
}
/** Collapsed rule set row: tap opens the editor sheet, the switch toggles in place. */
@Composable
private fun RuleSetRow(
ruleSet: RuleSet,
onToggle: (Boolean) -> Unit,
onOpen: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpen)
.padding(horizontal = 16.dp, vertical = 14.dp),
)
{
ShxIconBadge(
Icons.Rounded.Rule,
tint = if (ruleSet.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(16.dp))
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
private fun AddRuleRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
)
{
Icon(
Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(16.dp))
Text(
stringResource(R.string.routing_add_rule),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
/** One force-tunnel prefix: tap edits, switch toggles, trash deletes. */
@Composable
private fun ForcePrefixRow(
entry: ForceTunnelPrefix,
onToggle: (Boolean) -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onEdit)
.padding(horizontal = 16.dp, vertical = 8.dp),
)
{
ShxIconBadge(
Icons.Rounded.Lan,
tint = if (entry.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(16.dp))
Text(
entry.prefix,
style = MaterialTheme.typography.bodyLarge,
color = if (entry.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDelete)
{
Icon(
Icons.Rounded.Delete,
contentDescription = stringResource(R.string.routing_force_delete),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = entry.enabled, onCheckedChange = onToggle)
}
}
/** Trailing "add force-tunnel prefix" row. */
@Composable
private fun AddForcePrefixRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
)
{
Icon(
Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(16.dp))
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
private fun ForcePrefixDialog(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)) },
text = {
OutlinedTextField(
value = text,
onValueChange = {
text = it
error = false
},
singleLine = true,
isError = error,
label = { Text(stringResource(R.string.routing_force_hint)) },
supportingText = if (error)
{
{ Text(stringResource(R.string.routing_force_invalid)) }
}
else
{
null
},
)
},
confirmButton = {
TextButton(onClick = {
if (onConfirm(text.trim()))
{
onDismiss()
}
else
{
error = true
}
})
{
Text(stringResource(R.string.routing_force_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss)
{
Text(stringResource(android.R.string.cancel))
}
},
)
}
@@ -0,0 +1,452 @@
/*
* 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.ScreenPadding
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Geoip toggle row with a refresh/update slot in front of the switch. */
@Composable
internal fun RuToggleRow(
title: String,
subtitle: String,
checked: Boolean,
updating: Boolean,
onCheckedChange: (Boolean) -> Unit,
onUpdate: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onCheckedChange(!checked) }
.padding(horizontal = 16.dp, vertical = 14.dp),
)
{
ShxIconBadge(Icons.Rounded.Block)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f))
{
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (updating)
{
LoadingIndicator(Modifier.size(14.dp))
Spacer(Modifier.width(12.dp))
}
else
{
IconButton(onClick = onUpdate)
{
Icon(
Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.rusplit_update),
)
}
}
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.
*/
@Composable
internal fun RuModeSelector(mode: String, onModeChange: (String) -> Unit)
{
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
)
{
SegmentedButton(
selected = mode == Constants.RUSPLIT_MODE_SYSTEM,
onClick = { onModeChange(Constants.RUSPLIT_MODE_SYSTEM) },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
)
{
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))
}
}
}
/** Mode description, list source and beta note behind a "Details" disclosure. */
@Composable
internal fun RuDetailsDisclosure(mode: String)
{
var expanded by rememberSaveable { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(horizontal = 16.dp, vertical = 10.dp),
)
{
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(4.dp))
Icon(
if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.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 = 16.dp, end = 16.dp, bottom = 12.dp))
{
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 = 4.dp),
)
}
}
}
/** 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 = 16.dp, end = 16.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
)
{
InfoChip(text = stringResource(R.string.routing_geoip_ipv4, state.ipv4Ranges))
InfoChip(text = stringResource(R.string.routing_geoip_ipv6, state.ipv6Ranges))
val freshnessText = when
{
state.lastUpdatedMs > 0L ->
stringResource(
R.string.routing_geoip_updated,
DateUtils.getRelativeTimeSpanString(state.lastUpdatedMs).toString(),
)
else -> stringResource(R.string.routing_geoip_never)
}
InfoChip(
text = freshnessText,
highlight = if (state.lastUpdatedMs > 0L && fresh) status.connected else null,
)
}
}
/** Small rounded info pill used across the screen. */
@Composable
internal fun InfoChip(text: String, highlight: Color? = null)
{
Surface(
shape = RoundedCornerShape(8.dp),
color = if (highlight != null) highlight.copy(alpha = 0.14f) else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = highlight ?: MaterialTheme.colorScheme.onSurfaceVariant,
)
{
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
/** One force-tunnel prefix: tap edits, switch toggles, trash deletes. */
@Composable
internal fun ForcePrefixRow(
entry: ForceTunnelPrefix,
onToggle: (Boolean) -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onEdit)
.padding(horizontal = 16.dp, vertical = 8.dp),
)
{
ShxIconBadge(
Icons.Rounded.Lan,
tint = if (entry.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(16.dp))
Text(
entry.prefix,
style = MaterialTheme.typography.bodyLarge,
color = if (entry.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDelete)
{
Icon(
Icons.Rounded.Delete,
contentDescription = stringResource(R.string.routing_force_delete),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = entry.enabled, onCheckedChange = onToggle)
}
}
/** Trailing "add force-tunnel prefix" row. */
@Composable
internal fun AddForcePrefixRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
)
{
Icon(
Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(16.dp))
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)
{
var text by remember { mutableStateOf(initial) }
var error by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.routing_force_dialog_title)) },
text = {
OutlinedTextField(
value = text,
onValueChange = {
text = it
error = false
},
singleLine = true,
isError = error,
label = { Text(stringResource(R.string.routing_force_hint)) },
supportingText = if (error)
{
{ Text(stringResource(R.string.routing_force_invalid)) }
}
else
{
null
},
)
},
confirmButton = {
TextButton(onClick = {
if (onConfirm(text.trim()))
{
onDismiss()
}
else
{
error = true
}
})
{
Text(stringResource(R.string.routing_force_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss)
{
Text(stringResource(android.R.string.cancel))
}
},
)
}
@@ -0,0 +1,220 @@
/*
* 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.ScreenPadding
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Hero card: master switch plus a live summary of what the rules currently affect. */
@Composable
internal fun RoutingHeroCard(
enabled: Boolean,
setCount: Int,
appCount: Int,
onToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier,
)
{
Surface(
shape = MaterialTheme.shapes.large,
color = if (enabled) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerLow,
contentColor = if (enabled) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
border = if (enabled) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = modifier.fillMaxWidth(),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 14.dp, end = 8.dp),
)
{
Icon(
Icons.Rounded.AltRoute,
contentDescription = null,
tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f))
{
Text(
text = stringResource(if (enabled) R.string.routing_hero_on else R.string.routing_hero_off),
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,
color = if (enabled)
{
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
}
else
{
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
Switch(checked = enabled, onCheckedChange = onToggle)
Spacer(Modifier.width(8.dp))
}
}
}
/** 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 = 16.dp, vertical = 14.dp),
)
{
ShxIconBadge(Icons.Rounded.Block)
Spacer(Modifier.width(16.dp))
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,
)
}
}
}
@@ -0,0 +1,199 @@
/*
* 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.ScreenPadding
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 = 16.dp, vertical = 14.dp),
)
{
ShxIconBadge(
Icons.Rounded.Rule,
tint = if (ruleSet.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(16.dp))
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 = 16.dp, vertical = 14.dp),
)
{
Icon(
Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(16.dp))
Text(
stringResource(R.string.routing_add_rule),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}