From 25754b5cc4ca0a63b6ff1fad26be1b2b16f4ecbf Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 11 Nov 2026 09:18:44 +0700 Subject: [PATCH] =?UTF-8?q?android:=20Routes=20rules=20can=20be=20deleted?= =?UTF-8?q?=20with=20one=20tap=20and=20undone:=20each=20rule=20row=20gets?= =?UTF-8?q?=20a=20trash=20button,=20the=20rule=20disappears=20at=20once=20?= =?UTF-8?q?and=20is=20really=20removed=20when=20the=20=C2=ABUndo=C2=BB=20s?= =?UTF-8?q?nackbar=20goes=20away=20or=20the=20page=20is=20left=20(so=20und?= =?UTF-8?q?oing=20a=20package=20pattern=20does=20not=20reconnect=20the=20t?= =?UTF-8?q?unnel=20twice);=20tapping=20a=20rule=20opens=20a=20sheet=20with?= =?UTF-8?q?=20its=20on/off=20switch,=20the=20direction=20(direct=20/=20thr?= =?UTF-8?q?ough=20the=20VPN,=20changed=20in=20place=20keeping=20the=20id)?= =?UTF-8?q?=20and=20delete;=20rows=20say=20=C2=ABsingle=20address=C2=BB,?= =?UTF-8?q?=20=C2=ABnetwork=C2=BB,=20the=20number=20of=20resolved=20addres?= =?UTF-8?q?ses=20or=20matching=20apps,=20long=20values=20wrap;=20the=20Rou?= =?UTF-8?q?tes=20tab=20shows=20what=20the=20rules=20do=20(=C2=AB1=20via=20?= =?UTF-8?q?VPN=20=C2=B7=202=20direct=20=C2=B7=201=20off=C2=BB)=20and=20the?= =?UTF-8?q?=20hero=20no=20longer=20counts=20switched-off=20rules;=20mockup?= =?UTF-8?q?=20F=5FRules,=20F=5FRuleEdit,=20F=5FRuleDeleted,=20F=5FRouting?= =?UTF-8?q?=20and=20their=20dark=20pairs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.2 --- .../android/logic/routing/RoutingManager.kt | 21 ++ .../android/ui/compose/RoutingScreen.kt | 16 +- .../android/ui/compose/routing/RoutingHub.kt | 21 +- .../android/ui/compose/routing/RulesScreen.kt | 257 ++++++++++++++---- .../app/src/main/res/values-de/strings.xml | 14 +- .../app/src/main/res/values-pl/strings.xml | 14 +- .../app/src/main/res/values-ru/strings.xml | 14 +- .../app/src/main/res/values-uk/strings.xml | 14 +- .../src/main/res/values-zh-rCN/strings.xml | 14 +- .../src/main/res/values-zh-rTW/strings.xml | 14 +- .../app/src/main/res/values/strings.xml | 14 +- .../android/logic/routing/RoutingRulesTest.kt | 26 ++ src/frontends/android/docs/design/README.md | 8 +- .../docs/design/final-2026/D_Routing.dc.html | 4 +- .../design/final-2026/D_RuleDeleted.dc.html | 97 +++++++ .../docs/design/final-2026/D_RuleEdit.dc.html | 65 +++++ .../docs/design/final-2026/D_Rules.dc.html | 34 ++- .../docs/design/final-2026/F_Routing.dc.html | 4 +- .../design/final-2026/F_RuleDeleted.dc.html | 97 +++++++ .../docs/design/final-2026/F_RuleEdit.dc.html | 65 +++++ .../docs/design/final-2026/F_Rules.dc.html | 34 ++- .../docs/design/final-2026/canvas.json | 42 ++- 22 files changed, 755 insertions(+), 134 deletions(-) create mode 100644 src/frontends/android/docs/design/final-2026/D_RuleDeleted.dc.html create mode 100644 src/frontends/android/docs/design/final-2026/D_RuleEdit.dc.html create mode 100644 src/frontends/android/docs/design/final-2026/F_RuleDeleted.dc.html create mode 100644 src/frontends/android/docs/design/final-2026/F_RuleEdit.dc.html diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt index 48d434bcd..96f294ffd 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt @@ -509,6 +509,12 @@ class RoutingManager private constructor(private val context: Context) } } + /** Sends an existing site or address rule the other way, keeping its id and on/off state. */ + fun setRuleDirect(id: String, direct: Boolean) + { + updateConfigNoReconnect { withRuleDirect(it, id, direct) } + } + /** * Resolves the site rules again and pushes the result to the engine. * Called when a rule is added and when the tunnel comes up; repeats every @@ -852,6 +858,21 @@ class RoutingManager private constructor(private val context: Context) /** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */ const val CONFIG_VERSION = 2 + /** [config] with the site or address rule [id] routed [direct] (or through the VPN). */ + fun withRuleDirect(config: RoutingConfig, id: String, direct: Boolean): RoutingConfig + { + val (from, to) = if (direct) config.forceTunnelPrefixes to config.directPrefixes else config.directPrefixes to config.forceTunnelPrefixes + val moved = from.firstOrNull { it.id == id } + val newFrom = from.filter { it.id != id } + /* the same address may already be on the other side: the moved entry replaces it */ + val newTo = if (moved == null) to else to.filter { it.prefix != moved.prefix } + moved + return config.copy( + siteRules = config.siteRules.map { if (it.id == id) it.copy(direct = direct) else it }, + forceTunnelPrefixes = if (direct) newFrom else newTo, + directPrefixes = if (direct) newTo else newFrom, + ) + } + /** Validates and canonicalizes a CIDR/address; null if invalid. */ fun normalizePrefix(cidr: String): String? = try { diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt index 1b88dfe0c..3490f7e38 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt @@ -109,7 +109,9 @@ import org.strongswan.android.ui.compose.components.ShxSnackbarHost import org.strongswan.android.ui.compose.routing.DeadHostsPage import org.strongswan.android.ui.compose.routing.DirectAppsScreen import org.strongswan.android.ui.compose.routing.RecentAppsPage +import org.strongswan.android.ui.compose.routing.RuleRow import org.strongswan.android.ui.compose.routing.RulesPage +import org.strongswan.android.ui.compose.routing.buildRuleRows import org.strongswan.android.ui.compose.routing.routingSummary import org.strongswan.android.ui.compose.theme.LocalShxStatusColors import org.strongswan.android.ui.compose.theme.ShxSpacing @@ -259,14 +261,14 @@ private fun RoutingPageContent( snackbarHostState.showSnackbar(message) } } - val ruleCount = config.siteRules.size + config.forceTunnelPrefixes.size + config.directPrefixes.size + config.packagePatterns.size - /* the hero says what bypasses the VPN: only the "direct" rules count there */ - val directRuleCount = config.siteRules.count { it.direct } + config.directPrefixes.size + config.packagePatterns.size + val rules = remember(config) { buildRuleRows(config, emptyMap()) } + /* the hero says what bypasses the VPN: only the working "direct" rules count there */ + val directRuleCount = rules.count { it.direct && it.enabled } when (page) { RoutingPage.APPS -> DirectAppsScreen(onBack = onBack) - RoutingPage.RULES -> RulesPage(onBack = onBack) + RoutingPage.RULES -> RulesPage(onBack = onBack, snackbarHostState = snackbarHostState) RoutingPage.RECENT -> RecentAppsPage( deadTargets = deadTargets, onForceTunnel = { dst -> forceTunnel(manager, dst) }, @@ -282,7 +284,7 @@ private fun RoutingPageContent( enabled = config.enabled, ruState = ruState, directPackages = directPackages, - ruleCount = ruleCount, + rules = rules, directRuleCount = directRuleCount, deadCount = deadTargets.count { !it.viaTunnel }, split = split, @@ -309,7 +311,7 @@ private fun MainPage( enabled: Boolean, ruState: org.strongswan.android.logic.routing.RuIpListState, directPackages: List, - ruleCount: Int, + rules: List, directRuleCount: Int, deadCount: Int, split: Pair?, @@ -350,7 +352,7 @@ private fun MainPage( ShxSectionHeader(stringResource(R.string.routing_section_exceptions)) ExceptionsPanel( directPackages = directPackages, - ruleCount = ruleCount, + rules = rules, onApps = onOpenApps, onRules = { onOpen(RoutingPage.RULES) }, ) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHub.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHub.kt index b49fabafc..ce3eedec3 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHub.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHub.kt @@ -174,7 +174,7 @@ internal fun RuSitesPanel(state: RuIpListState, onToggle: (Boolean) -> Unit, onM /** «Exceptions» card: direct apps (with an icon stack) and rules (with a count). */ @Composable -internal fun ExceptionsPanel(directPackages: List, ruleCount: Int, onApps: () -> Unit, onRules: () -> Unit) +internal fun ExceptionsPanel(directPackages: List, rules: List, onApps: () -> Unit, onRules: () -> Unit) { val c = LocalShxColors.current ShxPanel() @@ -235,17 +235,30 @@ internal fun ExceptionsPanel(directPackages: List, ruleCount: Int, onApp Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(stringResource(R.string.rules_title), style = ShxType.rowTitle, color = c.text) - Text(stringResource(R.string.routing_rules_sub), style = ShxType.sub, color = c.textSub) + Text(rulesBreakdown(rules), style = ShxType.sub, color = c.textSub) } - if (ruleCount > 0) + if (rules.isNotEmpty()) { - Text(ruleCount.toString(), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.textSub) + Text(rules.size.toString(), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.textSub) } Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp)) } } } +/** "2 via VPN · 3 direct · 1 off": what the user rules do right now. */ +@Composable +private fun rulesBreakdown(rules: List): String +{ + if (rules.isEmpty()) + { + return stringResource(R.string.routing_rules_sub) + } + val on = stringResource(R.string.routing_rules_breakdown, rules.count { it.enabled && !it.direct }, rules.count { it.enabled && it.direct }) + val off = rules.count { !it.enabled } + return if (off > 0) stringResource(R.string.routing_rules_breakdown_off, on, off) else on +} + /** One app of the «Recent apps» preview. */ data class RecentPreview(val packageName: String, val label: String, val direct: Boolean, val failing: Boolean) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RulesScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RulesScreen.kt index 485c143db..da4a52189 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RulesScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RulesScreen.kt @@ -17,7 +17,22 @@ 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.animation.core.animateDpAsState +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.produceState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.strongswan.android.logic.routing.PackagePatterns +import org.strongswan.android.ui.compose.design.ShxTag +import org.strongswan.android.ui.compose.design.ShxToggleRow import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding @@ -105,18 +120,54 @@ fun buildRuleRows(config: RoutingConfig, resolved: Map>): L /** «Rules» (F_Rules / F_RulesEmpty): sites, addresses and package templates that override the RU base. */ @Composable -internal fun RulesPage(onBack: () -> Unit) +internal fun RulesPage(onBack: () -> Unit, snackbarHostState: SnackbarHostState) { val context = LocalContext.current val c = LocalShxColors.current val manager = remember { RoutingManager.getInstance(context) } + val scope = rememberCoroutineScope() val state by manager.state.collectAsStateWithLifecycle() val resolved by manager.resolvedSites.collectAsStateWithLifecycle() - val rows = remember(state.config, resolved) { buildRuleRows(state.config, resolved) } + /* a deleted rule only disappears from the list at first; it is really removed when the + * «Undo» snackbar goes away or the page is left, so undoing a package pattern does not + * reconnect the tunnel twice */ + var pending by remember { mutableStateOf(null) } + val pendingNow = rememberUpdatedState(pending) + DisposableEffect(Unit) { onDispose { pendingNow.value?.let { delete(manager, it) } } } + val rows = remember(state.config, resolved, pending) { + buildRuleRows(state.config, resolved).filterNot { it.sameRule(pending) } + } + val installed by produceState(emptySet()) { + value = withContext(Dispatchers.IO) { RoutingManager.loadInternetPackages(context.packageManager) } + } var filter by rememberSaveable { mutableStateOf(null) } var adding by remember { mutableStateOf(false) } var actionsFor by remember { mutableStateOf(null) } val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) } + val fabLift by animateDpAsState(if (snackbarHostState.currentSnackbarData != null) 96.dp else 24.dp, label = "fabLift") + + fun deleteWithUndo(row: RuleRow) + { + pending?.let { delete(manager, it) } + pending = row + scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + val result = snackbarHostState.showSnackbar( + message = context.getString(R.string.rules_deleted, row.value), + actionLabel = context.getString(R.string.action_undo), + duration = SnackbarDuration.Long, + ) + /* a newer deletion has already committed this one */ + if (row.sameRule(pending)) + { + if (result != SnackbarResult.ActionPerformed) + { + delete(manager, row) + } + pending = null + } + } + } Box(Modifier.fillMaxSize().background(c.background)) { @@ -143,7 +194,11 @@ internal fun RulesPage(onBack: () -> Unit) { ShxChip(stringResource(R.string.rules_filter_all, rows.size), filter == null, { filter = null }) RuleKind.entries.forEach { kind -> - ShxChip(stringResource(kind.pluralTitleRes()), filter == kind, { filter = kind }) + val count = rows.count { it.kind == kind } + if (count > 0 || filter == kind) + { + ShxChip(stringResource(kind.pluralTitleRes()) + " · " + count, filter == kind, { filter = kind }) + } } } val shown = rows.filter { filter == null || it.kind == filter } @@ -165,7 +220,13 @@ internal fun RulesPage(onBack: () -> Unit) ShxPanel() { group.forEachIndexed { i, row -> - RuleRowItem(row, onToggle = { toggle(manager, row, it) }, onClick = { actionsFor = row }) + RuleRowItem( + row, + apps = if (row.kind == RuleKind.PACKAGE && installed.isNotEmpty()) installed.count { PackagePatterns.matches(row.value, it) } else -1, + onToggle = { toggle(manager, row, it) }, + onClick = { actionsFor = row }, + onDelete = { deleteWithUndo(row) }, + ) if (i < group.lastIndex) { Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider)) @@ -180,33 +241,94 @@ internal fun RulesPage(onBack: () -> Unit) } if (rows.isNotEmpty()) { - AddRuleFab(Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 16.dp, bottom = 24.dp)) { adding = true } + AddRuleFab(Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 16.dp, bottom = fabLift)) { adding = true } } } if (adding) { RuleAddSheet(manager = manager, rulesActive = rulesActive, onDismiss = { adding = false }) } - actionsFor?.let { row -> - ShxSheet(onDismiss = { actionsFor = null }) + /* the sheet follows the live rule: its switch and direction change it in place */ + actionsFor?.let { opened -> rows.firstOrNull { it.sameRule(opened) } }?.let { row -> + RuleEditSheet( + row = row, + addresses = state.config.siteRules.firstOrNull { it.id == row.id }?.let { resolved[it.domain] }.orEmpty(), + onToggle = { toggle(manager, row, it) }, + onDirect = { manager.setRuleDirect(row.id, it) }, + onDelete = { + actionsFor = null + deleteWithUndo(row) + }, + onDismiss = { actionsFor = null }, + ) + } +} + +/** Same rule, whatever its on/off state and direction are by now. */ +private fun RuleRow.sameRule(other: RuleRow?) = other != null && kind == other.kind && id == other.id + +/** «Rule» sheet (F_RuleEdit): on/off, direction, delete. */ +@Composable +private fun RuleEditSheet( + row: RuleRow, + addresses: List, + onToggle: (Boolean) -> Unit, + onDirect: (Boolean) -> Unit, + onDelete: () -> Unit, + onDismiss: () -> Unit, +) +{ + val c = LocalShxColors.current + val (tagBg, tagFg) = row.kind.tagColors() + ShxSheet(onDismiss = onDismiss) + { + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp)) { - ShxSheetTitle(row.value) - Text( - stringResource(if (row.direct) R.string.rules_to_direct else R.string.rules_to_vpn) + " · " + stringResource(row.kind.tagRes()), - style = ShxType.note.copy(fontSize = 14.sp), - color = c.textSub, - modifier = Modifier.padding(horizontal = 4.dp), - ) - ShxButton( - stringResource(R.string.rules_delete), - { - delete(manager, row) - actionsFor = null - }, - style = ShxButtonStyle.DANGER, - modifier = Modifier.fillMaxWidth(), - icon = ShxIcons.Trash, - ) + ShxTag(stringResource(row.kind.tagRes()).uppercase(), tagBg, tagFg) + Text(row.value, style = ShxType.mono.copy(fontSize = 22.sp, fontWeight = FontWeight.ExtraBold), color = c.text) + } + ShxPanel() + { + ShxToggleRow(stringResource(R.string.rules_enabled), row.enabled, onToggle, subtitle = stringResource(R.string.rules_enabled_sub)) + } + if (row.kind != RuleKind.PACKAGE) + { + Text(stringResource(R.string.rules_where), style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) + { + ShxChoiceCard( + title = stringResource(R.string.rules_to_direct), + subtitle = stringResource(R.string.rules_direct_short), + selected = row.direct, + onClick = { onDirect(true) }, + tone = ShxChoiceTone.SUCCESS, + modifier = Modifier.weight(1f), + ) + ShxChoiceCard( + title = stringResource(R.string.rules_to_vpn), + subtitle = stringResource(if (row.kind == RuleKind.SITE) R.string.rules_vpn_short_site else R.string.rules_vpn_short), + selected = !row.direct, + onClick = { onDirect(false) }, + modifier = Modifier.weight(1f), + ) + } + } + ShxNote( + when + { + row.kind == RuleKind.PACKAGE -> stringResource(R.string.rules_package_note) + row.kind == RuleKind.ADDRESS -> stringResource(if (row.direct) R.string.rules_direct_note else R.string.rules_vpn_note) + addresses.isEmpty() -> stringResource(R.string.rules_site_note) + else -> stringResource( + R.string.rules_site_now, + addresses.take(4).joinToString(", ") { it.substringBefore('/') } + if (addresses.size > 4) ", …" else "", + ) + } + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) + { + ShxButton(stringResource(R.string.routing_force_delete), onDelete, style = ShxButtonStyle.DANGER, modifier = Modifier.weight(1f), icon = ShxIcons.Trash) + ShxButton(stringResource(R.string.action_done), onDismiss, modifier = Modifier.weight(1f)) } } } @@ -246,53 +368,76 @@ private fun RuleKind.pluralTitleRes(): Int = when (this) RuleKind.PACKAGE -> R.string.rules_filter_packages } -/** Row of the mockup: tag, monospace value with a sub line, switch; 64dp. */ @Composable -private fun RuleRowItem(row: RuleRow, onToggle: (Boolean) -> Unit, onClick: () -> Unit) +private fun RuleKind.tagColors(): Pair { val c = LocalShxColors.current - val (tagBg, tagFg) = when (row.kind) + return when (this) { RuleKind.SITE -> c.infoContainer to c.onInfo RuleKind.ADDRESS -> c.tile to c.textSoft RuleKind.PACKAGE -> c.successContainer to c.onSuccess } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().height(64.dp).clickable(role = Role.Button, onClick = onClick).padding(horizontal = 14.dp), - ) +} + +/** A single host ("1.2.3.4", "1.2.3.4/32", "::1/128") rather than a network. */ +internal fun isSingleAddress(prefix: String): Boolean = + prefix.substringAfter('/', "") in setOf("", if (prefix.contains(':')) "128" else "32") + +/** Row of the mockup: tag, monospace value with a sub line (opens the rule), switch, delete; 64dp. */ +@Composable +private fun RuleRowItem(row: RuleRow, apps: Int, onToggle: (Boolean) -> Unit, onClick: () -> Unit, onDelete: () -> Unit) +{ + val c = LocalShxColors.current + val (tagBg, tagFg) = row.kind.tagColors() + val what = when (row.kind) { - Text( - stringResource(row.kind.tagRes()).uppercase(), - style = ShxType.tag.copy(fontSize = 10.sp), - color = tagFg, - modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(tagBg).padding(horizontal = 7.dp, vertical = 3.dp), + RuleKind.SITE -> if (row.addresses >= 0) pluralStringResource(R.plurals.rules_site_addresses, row.addresses, row.addresses) + else stringResource(R.string.rules_site_unresolved) + RuleKind.ADDRESS -> stringResource(if (isSingleAddress(row.value)) R.string.rules_address_one else R.string.rules_address_net) + RuleKind.PACKAGE -> if (apps >= 0) pluralStringResource(R.plurals.routing_apps_n, apps, apps) else null + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(end = 6.dp)) + { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.weight(1f).heightIn(min = 64.dp).clickable(role = Role.Button, onClick = onClick).padding(start = 14.dp, end = 10.dp, top = 8.dp, bottom = 8.dp), ) - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( - row.value, - style = ShxType.mono.copy(fontWeight = FontWeight.Bold), - color = if (row.enabled) c.text else c.textSub, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - when (row.kind) - { - RuleKind.SITE -> if (row.addresses >= 0) pluralStringResource(R.plurals.rules_site_addresses, row.addresses, row.addresses) - else stringResource(R.string.rules_site_unresolved) - RuleKind.ADDRESS -> stringResource(if (row.direct) R.string.rules_address_direct else R.string.rules_address_vpn) - RuleKind.PACKAGE -> stringResource(R.string.rules_package_sub) - }, - style = ShxType.meta.copy(fontWeight = FontWeight.Normal), - color = c.textSub, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + stringResource(row.kind.tagRes()).uppercase(), + style = ShxType.tag.copy(fontSize = 10.sp), + color = tagFg, + modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(tagBg).padding(horizontal = 7.dp, vertical = 3.dp), ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) + { + /* a long address wraps on a narrow screen instead of being cut; "/32" says nothing next to «single address» */ + Text( + if (row.kind == RuleKind.ADDRESS && isSingleAddress(row.value)) row.value.substringBefore('/') else row.value, + style = ShxType.mono.copy(fontWeight = FontWeight.Bold), + color = if (row.enabled) c.text else c.textSub, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + listOfNotNull(what, if (row.enabled) null else stringResource(R.string.rules_off)).joinToString(" · "), + style = ShxType.meta.copy(fontWeight = FontWeight.Normal), + color = c.textSub, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } ShxSwitch(checked = row.enabled, onCheckedChange = onToggle) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(start = 4.dp).size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onDelete), + ) + { + Icon(ShxIcons.Trash, contentDescription = stringResource(R.string.rules_delete), tint = c.textSub, modifier = Modifier.size(20.dp)) + } } } diff --git a/src/frontends/android/app/src/main/res/values-de/strings.xml b/src/frontends/android/app/src/main/res/values-de/strings.xml index f2df73bb2..efe03d686 100644 --- a/src/frontends/android/app/src/main/res/values-de/strings.xml +++ b/src/frontends/android/app/src/main/res/values-de/strings.xml @@ -638,7 +638,7 @@ Nicht bei den direkten Apps: über das VPN. Regeln für Websites und Adressen wirken, wenn „Russische Websites direkt“ im Modus „Flexibel“ an ist. Regeln - Wohin Websites, Adressen und Apps gehen. Eine Regel hat Vorrang vor der russischen Datenbank. + Wohin Websites, Adressen und Apps gehen. Eine Regel hat Vorrang vor der russischen Datenbank. Tippen Sie auf eine Regel, um sie zu ändern. Alle · %1$d Websites Adressen @@ -650,10 +650,14 @@ Adresse Paket Adressen noch nicht aufgelöst - immer direkt - immer über das VPN, auch in der russischen Datenbank - Apps mit passendem Paket gehen direkt + eine Adresse + Netz + aus Regel löschen + Regel %1$s gelöscht + Regel aktiv + Eine deaktivierte Regel bleibt in der Liste + Aktuelle Adressen der Website: %1$s. Sie werden beim Verbinden und alle 30 Minuten aktualisiert. Noch keine Regeln, und das ist in Ordnung Russische Websites und die gewählten Apps gehen schon direkt. Eine Regel braucht es nur, wenn eine Website den falschen Weg nimmt. Oft hinzugefügt @@ -697,6 +701,8 @@ Nicht jetzt Ausnahmen Websites, Adressen und Paketvorlagen + %1$d über VPN · %2$d direkt + %1$s · %2$d aus Prüfung Wie eine Website, IP oder App läuft Wer das Netz genutzt hat und auf welchem Weg diff --git a/src/frontends/android/app/src/main/res/values-pl/strings.xml b/src/frontends/android/app/src/main/res/values-pl/strings.xml index 4b5a42ff8..988053dec 100644 --- a/src/frontends/android/app/src/main/res/values-pl/strings.xml +++ b/src/frontends/android/app/src/main/res/values-pl/strings.xml @@ -528,7 +528,7 @@ Nie ma wśród aplikacji bezpośrednich: przez VPN. Reguły dla stron i adresów działają, gdy „Rosyjskie strony bezpośrednio” są włączone w trybie „Elastyczny”. Reguły - Dokąd idą strony, adresy i aplikacje. Reguła ma pierwszeństwo przed bazą rosyjską. + Dokąd idą strony, adresy i aplikacje. Reguła ma pierwszeństwo przed bazą rosyjską. Dotknij reguły, aby ją zmienić. Wszystkie · %1$d Strony Adresy @@ -540,10 +540,14 @@ Adres Pakiet adresy jeszcze nieustalone - zawsze bezpośrednio - zawsze przez VPN, nawet jeśli w bazie rosyjskiej - aplikacje z takim pakietem — bezpośrednio + jeden adres + sieć + wyłączona Usuń regułę + Reguła %1$s usunięta + Reguła włączona + Wyłączona reguła zostaje na liście + Adresy strony teraz: %1$s. Odświeżane przy połączeniu i co 30 minut. Jeszcze brak reguł — i to w porządku Rosyjskie strony i wybrane aplikacje już idą bezpośrednio. Reguła jest potrzebna tylko, gdy jakaś strona idzie złą drogą. Często dodawane @@ -587,6 +591,8 @@ Nie teraz Wyjątki Strony, adresy i szablony pakietów + %1$d przez VPN · %2$d bezpośrednio + %1$s · %2$d wyłączone Sprawdzanie Jak idzie strona, IP lub aplikacja Kto korzystał z sieci i jaką drogą diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index 16b29c3ae..ff73573b9 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -658,7 +658,7 @@ Правила для сайтов и адресов работают, когда «Сайты РФ напрямую» включены в режиме «Гибкий». Правила - Куда идут сайты, адреса и приложения. Правило важнее базы РФ. + Куда идут сайты, адреса и приложения. Правило важнее базы РФ. Нажмите на правило, чтобы изменить его. Все · %1$d Сайты Адреса @@ -670,10 +670,14 @@ Адрес Пакет адреса ещё не определены - всегда напрямую - всегда через VPN, даже если в базе РФ - приложения с таким пакетом — напрямую + один адрес + сеть + выключено Удалить правило + Правило %1$s удалено + Правило включено + Выключенное остаётся в списке + Адреса сайта сейчас: %1$s. Обновляются при подключении и раз в 30 минут. Правил пока нет — и это нормально Сайты РФ и выбранные приложения уже идут напрямую. Правило нужно, только если какой-то сайт идёт не тем путём. Часто добавляют @@ -717,6 +721,8 @@ Не сейчас Исключения Сайты, адреса и шаблоны пакетов + %1$d через VPN · %2$d напрямую + %1$s · %2$d выключено Проверка Как ходит сайт, IP или приложение Через VPN или напрямую — и почему diff --git a/src/frontends/android/app/src/main/res/values-uk/strings.xml b/src/frontends/android/app/src/main/res/values-uk/strings.xml index b90203f5b..033c97a7d 100644 --- a/src/frontends/android/app/src/main/res/values-uk/strings.xml +++ b/src/frontends/android/app/src/main/res/values-uk/strings.xml @@ -538,7 +538,7 @@ Не в списку «напряму» — через VPN. Правила для сайтів і адрес працюють, коли «Сайти РФ напряму» увімкнено в режимі «Гнучкий». Правила - Куди йдуть сайти, адреси й застосунки. Правило важливіше за базу РФ. + Куди йдуть сайти, адреси й застосунки. Правило важливіше за базу РФ. Натисніть на правило, щоб змінити його. Усі · %1$d Сайти Адреси @@ -550,10 +550,14 @@ Адреса Пакет адреси ще не визначено - завжди напряму - завжди через VPN, навіть якщо в базі РФ - застосунки з таким пакетом — напряму + одна адреса + мережа + вимкнено Видалити правило + Правило %1$s видалено + Правило ввімкнено + Вимкнене залишається у списку + Адреси сайту зараз: %1$s. Оновлюються під час підключення та кожні 30 хвилин. Правил поки немає — і це нормально Сайти РФ і вибрані застосунки вже йдуть напряму. Правило потрібне, лише якщо якийсь сайт іде не тим шляхом. Часто додають @@ -597,6 +601,8 @@ Не зараз Винятки Сайти, адреси та шаблони пакетів + %1$d через VPN · %2$d напряму + %1$s · %2$d вимкнено Перевірка Як іде сайт, IP або застосунок Хто виходив у мережу і яким шляхом diff --git a/src/frontends/android/app/src/main/res/values-zh-rCN/strings.xml b/src/frontends/android/app/src/main/res/values-zh-rCN/strings.xml index 0ee87314d..4aa93c4ed 100644 --- a/src/frontends/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/src/frontends/android/app/src/main/res/values-zh-rCN/strings.xml @@ -537,7 +537,7 @@ 不在直连应用中:经 VPN。 网站和地址规则仅在“灵活”模式下开启“俄罗斯网站直连”时生效。 规则 - 网站、地址和应用的去向。规则优先于俄罗斯地址库。 + 网站、地址和应用的去向。规则优先于俄罗斯地址库。点按规则即可修改。 全部 · %1$d 网站 地址 @@ -549,10 +549,14 @@ 地址 包名 地址尚未解析 - 始终直连 - 始终经 VPN,即使在俄罗斯地址库中 - 包名匹配的应用直连 + 单个地址 + 网段 + 已关闭 删除规则 + 规则 %1$s 已删除 + 规则已启用 + 关闭的规则仍保留在列表中 + 网站当前地址:%1$s。连接时及每 30 分钟刷新一次。 暂无规则,这很正常 俄罗斯网站和所选应用已经直连。只有某个网站走错路径时才需要规则。 常用 @@ -596,6 +600,8 @@ 暂不 例外 网站、地址和包名模板 + %1$d 条经 VPN · %2$d 条直连 + %1$s · %2$d 条已关闭 检查 网站、IP 或应用的走向 谁联网过以及走哪条路径 diff --git a/src/frontends/android/app/src/main/res/values-zh-rTW/strings.xml b/src/frontends/android/app/src/main/res/values-zh-rTW/strings.xml index 8a18a28c9..3ddce7f6c 100644 --- a/src/frontends/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/src/frontends/android/app/src/main/res/values-zh-rTW/strings.xml @@ -522,7 +522,7 @@ 不在直連應用程式中:經 VPN。 網站與位址規則僅在「靈活」模式下開啟「俄羅斯網站直連」時生效。 規則 - 網站、位址和應用程式的去向。規則優先於俄羅斯位址庫。 + 網站、位址和應用程式的去向。規則優先於俄羅斯位址庫。輕觸規則即可修改。 全部 · %1$d 網站 位址 @@ -534,10 +534,14 @@ 位址 套件 位址尚未解析 - 一律直連 - 一律經 VPN,即使在俄羅斯位址庫中 - 套件名稱相符的應用程式直連 + 單一位址 + 網段 + 已關閉 刪除規則 + 規則 %1$s 已刪除 + 規則已啟用 + 關閉的規則仍保留在清單中 + 網站目前位址:%1$s。連線時及每 30 分鐘重新整理一次。 暫無規則,這很正常 俄羅斯網站與所選應用程式已經直連。只有某個網站走錯路徑時才需要規則。 常用 @@ -581,6 +585,8 @@ 暫不 例外 網站、位址與套件範本 + %1$d 條經 VPN · %2$d 條直連 + %1$s · %2$d 條已關閉 檢查 網站、IP 或應用程式的走向 誰連網過以及走哪條路徑 diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index 151a2c167..189783d85 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -655,7 +655,7 @@ Rules for sites and addresses work when «Russian sites direct» is on in the «Flexible» mode. Rules - Where sites, addresses and apps go. A rule wins over the RU base. + Where sites, addresses and apps go. A rule wins over the RU base. Tap a rule to change it. All · %1$d Sites Addresses @@ -667,10 +667,14 @@ Address Package addresses not resolved yet - always direct - always through the VPN, even inside the RU base - apps whose package matches go direct + single address + network + off Delete rule + Rule %1$s deleted + Rule enabled + A disabled rule stays in the list + Site addresses now: %1$s. They are refreshed on connect and every 30 minutes. No rules yet, and that is fine Russian sites and the selected apps already go direct. A rule is only needed when some site takes the wrong way. Often added @@ -714,6 +718,8 @@ Not now Exceptions Sites, addresses and package templates + %1$d via VPN · %2$d direct + %1$s · %2$d off Check How a site, IP or app goes Through the VPN or direct, and why diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/RoutingRulesTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/RoutingRulesTest.kt index ce5bf22ba..ac6ff8607 100644 --- a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/RoutingRulesTest.kt +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/RoutingRulesTest.kt @@ -20,6 +20,7 @@ import org.strongswan.android.ui.compose.DeadTargetUi import org.strongswan.android.ui.compose.routing.RuleKind import org.strongswan.android.ui.compose.routing.buildRecentApps import org.strongswan.android.ui.compose.routing.buildRuleRows +import org.strongswan.android.ui.compose.routing.isSingleAddress import org.strongswan.android.utils.IPRange import java.net.InetAddress @@ -82,6 +83,31 @@ class RoutingRulesTest assertThat(rows.first { it.value == "2.2.2.0/24" }.enabled).isFalse() } + @Test + fun ruleChangesDirectionInPlace() + { + val config = RoutingConfig( + forceTunnelPrefixes = listOf(ForceTunnelPrefix("f", "1.1.1.1/32", enabled = false), ForceTunnelPrefix("keep", "3.3.3.3/32")), + directPrefixes = listOf(ForceTunnelPrefix("dup", "1.1.1.1/32")), + siteRules = listOf(SiteRule("s", "chatgpt.com", direct = false, enabled = false)), + ) + val moved = RoutingManager.withRuleDirect(config, "f", direct = true) + assertThat(moved.forceTunnelPrefixes.map { it.id }).containsExactly("keep") + /* same id and on/off state; the duplicate on the other side is replaced */ + assertThat(moved.directPrefixes).containsExactly(ForceTunnelPrefix("f", "1.1.1.1/32", enabled = false)) + assertThat(RoutingManager.withRuleDirect(moved, "f", direct = false).forceTunnelPrefixes.map { it.id }).containsExactly("keep", "f") + assertThat(RoutingManager.withRuleDirect(config, "s", direct = true).siteRules) + .containsExactly(SiteRule("s", "chatgpt.com", direct = true, enabled = false)) + assertThat(RoutingManager.withRuleDirect(config, "f", direct = false)).isEqualTo(config) + } + + @Test + fun singleAddressOrNetwork() + { + assertThat(listOf("1.2.3.4", "1.2.3.4/32", "2001:db8::1/128").all(::isSingleAddress)).isTrue() + assertThat(listOf("10.8.0.0/24", "2001:db8::/32").none(::isSingleAddress)).isTrue() + } + @Test fun recentAppsPutFailuresFirst() { diff --git a/src/frontends/android/docs/design/README.md b/src/frontends/android/docs/design/README.md index a7aa26d43..4672ac802 100644 --- a/src/frontends/android/docs/design/README.md +++ b/src/frontends/android/docs/design/README.md @@ -3,7 +3,7 @@ Утверждённый дизайн SHX VPN. Все экраны приложения сделаны строго по нему. - **Живой холст (смотреть и править):** https://claude.ai/artifact/UCC88C9hkU3hDU2YwMck1a. Это артефакт типа «Design» в Claude, доступ только у владельца. -- **Копия в репозитории:** `final-2026/` — исходники артбордов и раскладка холста (`canvas.json`) на 2026-10-13, версия 2.18.0. +- **Копия в репозитории:** `final-2026/` — исходники артбордов и раскладка холста (`canvas.json`) на 2026-10-13, версия 2.18.0; раздел «Правила» (`F_Rules`, `F_RuleEdit`, `F_RuleDeleted`, `F_Routing`) — на 2026-11-10. ## Как устроены файлы @@ -17,8 +17,8 @@ | Префикс | Страница | Что это | |---|---|---| | `O_*` | Обзор | Анализ, фичи по приоритетам, решения по дизайну, карта экранов | -| `F_*` | Финал 2026 | 57 артбордов светлой темы — **эталон** | -| `D_*` | Финал 2026 · тёмная | 54 артборда тёмной темы, те же экраны | +| `F_*` | Финал 2026 | 59 артбордов светлой темы — **эталон** | +| `D_*` | Финал 2026 · тёмная | 56 артбордов тёмной темы, те же экраны | | остальные | Исследование | Ранние варианты (Главная 2.0, простой режим, «Для опытных»). Не реализуются, оставлены для истории | `D_*` не рисуются вручную: они получаются из `F_*` заменой цветов светлой палитры на тёмную (`LightShxColors` → `DarkShxColors` в `ShxDesign.kt`). Исключение — `D_QsTile`, он правился руками. Когда меняется `F_*`, обновите и парный `D_*`. @@ -45,7 +45,7 @@ | `F_Check` | Как ходит трафик | `ui/compose/RouteCheckScreen.kt` | | `F_Routing` | Маршруты | `ui/compose/routing/RoutingHub.kt` | | `F_Apps`, `F_AppPicker` | Приложения напрямую | `ui/compose/routing/DirectAppsScreen.kt` | -| `F_Rules`, `F_RulesEmpty`, `F_RuleAdd` | Правила | `ui/compose/routing/RulesScreen.kt` | +| `F_Rules`, `F_RulesEmpty`, `F_RuleAdd`, `F_RuleEdit`, `F_RuleDeleted` | Правила | `ui/compose/routing/RulesScreen.kt` | | `F_Recent`, `F_RecentEmpty` | Недавние приложения | `ui/compose/routing/RecentAppsScreen.kt` | | `F_Automation`, `F_ScenarioAdd` | Сценарии | `ui/ScenariosActivity.kt`, `logic/scenarios/Scenarios.kt` | | `F_Settings` | Настройки | `ui/compose/SettingsScreen.kt` | diff --git a/src/frontends/android/docs/design/final-2026/D_Routing.dc.html b/src/frontends/android/docs/design/final-2026/D_Routing.dc.html index 9d389423f..b41823687 100644 --- a/src/frontends/android/docs/design/final-2026/D_Routing.dc.html +++ b/src/frontends/android/docs/design/final-2026/D_Routing.dc.html @@ -78,9 +78,9 @@ a{color:#B69DFF}a:hover{color:#D0C2FF} Правила - Домены, адреса и шаблоны пакетов + 2 через VPN · 3 напрямую · 1 выключено - 5 + 6 diff --git a/src/frontends/android/docs/design/final-2026/D_RuleDeleted.dc.html b/src/frontends/android/docs/design/final-2026/D_RuleDeleted.dc.html new file mode 100644 index 000000000..b0fca2c21 --- /dev/null +++ b/src/frontends/android/docs/design/final-2026/D_RuleDeleted.dc.html @@ -0,0 +1,97 @@ + + + + +Тёмная · Правило удалено — можно отменить + + + + + + + + +
+
+ + + +

Правила

+
+ +
+

Куда идут сайты, адреса и приложения. Правило важнее базы РФ. Нажмите на правило, чтобы изменить его.

+ +
+ + + + +
+ + +
+

{{g.title}}

+ +
+
+
+ + + Правило + +
+ Правило chatgpt.com удалено + Отменить +
+
+
+ + + diff --git a/src/frontends/android/docs/design/final-2026/D_RuleEdit.dc.html b/src/frontends/android/docs/design/final-2026/D_RuleEdit.dc.html new file mode 100644 index 000000000..4ba9e9a55 --- /dev/null +++ b/src/frontends/android/docs/design/final-2026/D_RuleEdit.dc.html @@ -0,0 +1,65 @@ + + + + +Тёмная · Правило: изменить или удалить + + + + + + + + +
+
+ + +
+ САЙТ +

chatgpt.com

+
+ +
+ + Правило включено + Выключенное остаётся в списке + + +
+ + Куда направлять +
+ + +
+ +
Сейчас у сайта 3 адреса: 104.18.32.47, 172.64.155.209, 2606:4700::6812:202f. Они обновляются при подключении и раз в 30 минут.
+ + +
+
+
+ + + diff --git a/src/frontends/android/docs/design/final-2026/D_Rules.dc.html b/src/frontends/android/docs/design/final-2026/D_Rules.dc.html index ffabfa873..3f2f11913 100644 --- a/src/frontends/android/docs/design/final-2026/D_Rules.dc.html +++ b/src/frontends/android/docs/design/final-2026/D_Rules.dc.html @@ -23,13 +23,13 @@ a{color:#B69DFF}a:hover{color:#D0C2FF}
-

Куда идут сайты, адреса и приложения. Правило важнее базы РФ.

+

Куда идут сайты, адреса и приложения. Правило важнее базы РФ. Нажмите на правило, чтобы изменить его.

- - - + + +
@@ -37,13 +37,18 @@ a{color:#B69DFF}a:hover{color:#D0C2FF}

{{g.title}}

-
- {{r.kind}} - - {{r.value}} - {{r.sub}} - +
@@ -64,20 +69,21 @@ class Component extends DCLogic { return { title: title, dot: dot, rows: rows.map(function (r, i) { return { kind: r[0], tagBg: tag[r[0]][0], tagFg: tag[r[0]][1], value: r[1], sub: r[2], on: r[3] ? 'true' : 'false', track: r[3] ? '#6D4AFF' : '#48454E', align: r[3] ? 'flex-end' : 'flex-start', + fg: r[3] ? '#E5E1E9' : '#A9A3BF', divider: i < rows.length - 1 ? '1px solid #2B2930' : '0' }; }) }; }; return { groups: [ grp('ВСЕГДА ЧЕРЕЗ VPN', '#6D4AFF', [ - ['САЙТ', 'chatgpt.com', 'и поддомены', true], + ['САЙТ', 'chatgpt.com', '3 адреса', true], ['АДРЕС', '130.49.213.176', 'один адрес', true], - ['АДРЕС', '10.8.0.0/24', 'подсеть', false] + ['АДРЕС', '10.8.0.0/24', 'сеть · выключено', false] ]), grp('ВСЕГДА НАПРЯМУЮ', '#3FBF7F', [ ['ПАКЕТ', 'ru.*', '14 приложений', true], - ['ПАКЕТ', '*yandex*', '6 приложений · 1 исключение', true], - ['САЙТ', '*.mos.ru', 'и поддомены', true] + ['ПАКЕТ', '*yandex*', '6 приложений', true], + ['САЙТ', 'mos.ru', '2 адреса', true] ]) ] }; diff --git a/src/frontends/android/docs/design/final-2026/F_Routing.dc.html b/src/frontends/android/docs/design/final-2026/F_Routing.dc.html index 1768383cc..cc9b203f2 100644 --- a/src/frontends/android/docs/design/final-2026/F_Routing.dc.html +++ b/src/frontends/android/docs/design/final-2026/F_Routing.dc.html @@ -78,9 +78,9 @@ a{color:#6D4AFF}a:hover{color:#4B2FD1} Правила - Домены, адреса и шаблоны пакетов + 2 через VPN · 3 напрямую · 1 выключено - 5 + 6 diff --git a/src/frontends/android/docs/design/final-2026/F_RuleDeleted.dc.html b/src/frontends/android/docs/design/final-2026/F_RuleDeleted.dc.html new file mode 100644 index 000000000..32af619d1 --- /dev/null +++ b/src/frontends/android/docs/design/final-2026/F_RuleDeleted.dc.html @@ -0,0 +1,97 @@ + + + + +Правило удалено — можно отменить + + + + + + + + +
+
+ + + +

Правила

+
+ +
+

Куда идут сайты, адреса и приложения. Правило важнее базы РФ. Нажмите на правило, чтобы изменить его.

+ +
+ + + + +
+ + +
+

{{g.title}}

+ +
+
+
+ + + Правило + +
+ Правило chatgpt.com удалено + Отменить +
+
+
+ + + diff --git a/src/frontends/android/docs/design/final-2026/F_RuleEdit.dc.html b/src/frontends/android/docs/design/final-2026/F_RuleEdit.dc.html new file mode 100644 index 000000000..a420a4f3f --- /dev/null +++ b/src/frontends/android/docs/design/final-2026/F_RuleEdit.dc.html @@ -0,0 +1,65 @@ + + + + +Правило: изменить или удалить + + + + + + + + +
+
+ + +
+ САЙТ +

chatgpt.com

+
+ +
+ + Правило включено + Выключенное остаётся в списке + + +
+ + Куда направлять +
+ + +
+ +
Сейчас у сайта 3 адреса: 104.18.32.47, 172.64.155.209, 2606:4700::6812:202f. Они обновляются при подключении и раз в 30 минут.
+ + +
+
+
+ + + diff --git a/src/frontends/android/docs/design/final-2026/F_Rules.dc.html b/src/frontends/android/docs/design/final-2026/F_Rules.dc.html index 6907390e0..ae35c5c03 100644 --- a/src/frontends/android/docs/design/final-2026/F_Rules.dc.html +++ b/src/frontends/android/docs/design/final-2026/F_Rules.dc.html @@ -23,13 +23,13 @@ a{color:#6D4AFF}a:hover{color:#4B2FD1}
-

Куда идут сайты, адреса и приложения. Правило важнее базы РФ.

+

Куда идут сайты, адреса и приложения. Правило важнее базы РФ. Нажмите на правило, чтобы изменить его.

- - - + + +
@@ -37,13 +37,18 @@ a{color:#6D4AFF}a:hover{color:#4B2FD1}

{{g.title}}

-
- {{r.kind}} - - {{r.value}} - {{r.sub}} - +
@@ -64,20 +69,21 @@ class Component extends DCLogic { return { title: title, dot: dot, rows: rows.map(function (r, i) { return { kind: r[0], tagBg: tag[r[0]][0], tagFg: tag[r[0]][1], value: r[1], sub: r[2], on: r[3] ? 'true' : 'false', track: r[3] ? '#6D4AFF' : '#C9C5D0', align: r[3] ? 'flex-end' : 'flex-start', + fg: r[3] ? '#1C1B20' : '#5E5A7D', divider: i < rows.length - 1 ? '1px solid #F0ECF4' : '0' }; }) }; }; return { groups: [ grp('ВСЕГДА ЧЕРЕЗ VPN', '#6D4AFF', [ - ['САЙТ', 'chatgpt.com', 'и поддомены', true], + ['САЙТ', 'chatgpt.com', '3 адреса', true], ['АДРЕС', '130.49.213.176', 'один адрес', true], - ['АДРЕС', '10.8.0.0/24', 'подсеть', false] + ['АДРЕС', '10.8.0.0/24', 'сеть · выключено', false] ]), grp('ВСЕГДА НАПРЯМУЮ', '#3FBF7F', [ ['ПАКЕТ', 'ru.*', '14 приложений', true], - ['ПАКЕТ', '*yandex*', '6 приложений · 1 исключение', true], - ['САЙТ', '*.mos.ru', 'и поддомены', true] + ['ПАКЕТ', '*yandex*', '6 приложений', true], + ['САЙТ', 'mos.ru', '2 адреса', true] ]) ] }; diff --git a/src/frontends/android/docs/design/final-2026/canvas.json b/src/frontends/android/docs/design/final-2026/canvas.json index 7f0e7ec78..09ff83147 100644 --- a/src/frontends/android/docs/design/final-2026/canvas.json +++ b/src/frontends/android/docs/design/final-2026/canvas.json @@ -1137,6 +1137,38 @@ "h": 1000, "title": "Виджеты на рабочем столе", "page": "final_dark" + }, + "F_RuleEdit.dc.html": { + "x": 2350, + "y": 1540, + "w": 390, + "h": 844, + "page": "final", + "title": "Правило: изменить или удалить" + }, + "F_RuleDeleted.dc.html": { + "x": 2820, + "y": 1540, + "w": 390, + "h": 900, + "page": "final", + "title": "Правило удалено — «Отменить»" + }, + "D_RuleEdit.dc.html": { + "x": 2350, + "y": 1540, + "w": 390, + "h": 844, + "page": "final_dark", + "title": "Правило: изменить или удалить" + }, + "D_RuleDeleted.dc.html": { + "x": 2820, + "y": 1540, + "w": 390, + "h": 900, + "page": "final_dark", + "title": "Правило удалено — «Отменить»" } }, "createdOnFiles": { @@ -1162,7 +1194,7 @@ "fB": { "color": "green", "kind": "title1", - "maxW": 2270, + "maxW": 3210, "page": "final", "text": "Финал 2026 · Маршруты", "w": 240, @@ -1339,7 +1371,7 @@ "dfB": { "color": "purple", "kind": "title1", - "maxW": 2270, + "maxW": 3210, "page": "final_dark", "text": "Тёмная тема · Маршруты", "w": 240, @@ -1589,7 +1621,11 @@ "D_HomeOffline.dc.html", "D_HomeUnreachable.dc.html", "D_QsTile.dc.html", - "D_Widgets.dc.html" + "D_Widgets.dc.html", + "F_RuleEdit.dc.html", + "F_RuleDeleted.dc.html", + "D_RuleEdit.dc.html", + "D_RuleDeleted.dc.html" ], "pages": [ {