android: split RoutingScreen into routing/{RoutingHero,RuleSetList,AdvancedRouting}
This commit is contained in:
1 parent
8bd65d535e
commit
a451386e0a
4 files changed
+882
-487
No files matched your search
+11
-487
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+452
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+220
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+199
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user