From df1575fe2774ec0fb1a1c4e23f9ba4379957d97a Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 12 Oct 2026 21:35:17 +0700 Subject: [PATCH] android: one order for the network rules and the scenarios (top wins), hand actions beat a running scenario until it ends Co-Authored-By: Claude Opus 5.5 --- .../strongswan/android/logic/PauseManager.kt | 1 + .../logic/autoconnect/AutoConnectManager.kt | 7 +- .../android/logic/scenarios/Scenarios.kt | 121 +++++++++++++++++- .../android/ui/ScenariosActivity.kt | 50 +++++--- .../android/ui/VpnProfileControlActivity.java | 4 +- .../android/ui/compose/HomeViewModel.kt | 4 + .../app/src/main/res/values-ru/strings.xml | 1 + .../app/src/main/res/values/strings.xml | 1 + .../android/logic/scenarios/ScenariosTest.kt | 31 +++++ 9 files changed, 197 insertions(+), 23 deletions(-) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt index 4bf3561a0..fae4c197d 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt @@ -333,6 +333,7 @@ class PauseReceiver : BroadcastReceiver() { if (intent?.action == PauseManager.ACTION_PAUSE) { + org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(context) val service = VpnStateService.getLiveInstance() ?: return val profile = service.profile ?: return PauseManager.pause(context, service, profile.getUUID().toString(), 15) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt index 2c958328d..4cdb6df31 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt @@ -252,7 +252,12 @@ class AutoConnectManager private constructor( whiteListBlocksReconnect = WhiteListManager.getInstance(context).shouldBlockReconnect(), ) /* the user paused the VPN: the pause alarm brings it back, not a network change */ - return if (action == Action.CONNECT && PauseManager.isPaused(context)) Action.IGNORE else action + if (action == Action.CONNECT && PauseManager.isPaused(context)) + { + return Action.IGNORE + } + /* a user scenario above the network rules is in force */ + return if (action != Action.IGNORE && org.strongswan.android.logic.scenarios.ScenarioEngine.inControl(context)) Action.IGNORE else action } /** Re-runs the connect decision, e.g. when the white-list gate clears. */ diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt index 33e1e6a38..955b6cd05 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt @@ -106,6 +106,50 @@ internal fun activeScenario(list: List, now: ScenarioInputs): Scenario } } +/** The two network rules of F_Automation, as entries of the common order. */ +const val BUILTIN_TRUSTED = "builtin:trusted" +const val BUILTIN_UNTRUSTED = "builtin:untrusted" + +/** What the built-in network rules say about the current network. */ +internal data class BuiltinRules( + val enabled: Boolean, + val parkOnTrusted: Boolean, + val connectOnUntrusted: Boolean, + val trustedSsids: Set, +) + +/** The order of everything: [order] first (known ids), the rest after it (built-ins before new scenarios). */ +internal fun orderedIds(order: List, scenarios: List): List +{ + val all = listOf(BUILTIN_TRUSTED, BUILTIN_UNTRUSTED) + scenarios.map { it.id } + return order.filter { it in all } + all.filter { it !in order } +} + +/** + * The top entry of the common order whose condition holds (built-in rule + * id or scenario id), null when nothing applies. «Upper wins». + */ +internal fun topEntry(order: List, scenarios: List, rules: BuiltinRules, now: ScenarioInputs): String? +{ + val byId = scenarios.associateBy { it.id } + val trustedHere = now.wifi && now.ssid != null && rules.trustedSsids.any { it.equals(now.ssid, ignoreCase = true) } + for (id in orderedIds(order, scenarios)) + { + val holds = when (id) + { + BUILTIN_TRUSTED -> rules.enabled && rules.parkOnTrusted && trustedHere + /* like the auto-connect: a Wi-Fi with an unreadable name counts as foreign */ + BUILTIN_UNTRUSTED -> rules.enabled && rules.connectOnUntrusted && ((now.wifi && !trustedHere) || (!now.wifi && now.mobile)) + else -> byId[id]?.let { activeScenario(listOf(it), now) != null } ?: false + } + if (holds) + { + return id + } + } + return null +} + /** * Runs the user scenarios of F_Automation. Re-evaluated on network * changes, at the edges of time windows (alarm), while an app trigger @@ -120,6 +164,9 @@ object ScenarioEngine private const val PREF_APPLIED = "pref_scenario_applied" private const val PREF_WAS_ON = "pref_scenario_was_on" private const val PREF_WAS_PROFILE = "pref_scenario_was_profile" + /* the scenario the user overrode by hand: it stays out until it ends */ + private const val PREF_SUSPENDED = "pref_scenario_suspended" + private const val PREF_ORDER = "pref_scenario_order" private val _list = MutableStateFlow>(emptyList()) val list: StateFlow> = _list @@ -200,15 +247,26 @@ object ScenarioEngine return } val now = inputs(app) - val active = activeScenario(_list.value, now) + val top = topEntry(order(app), _list.value, builtinRules(app), now) val prefs = PreferenceManager.getDefaultSharedPreferences(app) + /* a scenario overridden by hand stays out until it ends */ + val suspended = prefs.getString(PREF_SUSPENDED, null) + if (suspended != null) + { + if (top == suspended) + { + return + } + prefs.edit().remove(PREF_SUSPENDED).apply() + } + val active = _list.value.firstOrNull { it.id == top } val applied = prefs.getString(PREF_APPLIED, null) - if (active?.id == applied) + if (active?.id == applied && active != null) { return } if (active == null) - { + { /* nothing, or a built-in network rule above: the auto-connect acts */ restoreIfApplied(app) return } @@ -255,6 +313,63 @@ object ScenarioEngine ) } + /** The common order of the built-in rules and the scenarios (F_Automation, top wins). */ + fun order(context: Context): List + { + val raw = PreferenceManager.getDefaultSharedPreferences(context).getString(PREF_ORDER, null) + return orderedIds(raw?.split(',')?.filter { it.isNotBlank() } ?: emptyList(), flow(context).value) + } + + fun saveOrder(context: Context, order: List) + { + PreferenceManager.getDefaultSharedPreferences(context).edit().putString(PREF_ORDER, order.joinToString(",")).apply() + _orderChanges.value = _orderChanges.value + 1 + evaluate(context) + } + + private val _orderChanges = MutableStateFlow(0) + /** Bumps when the order changed (the list screen redraws). */ + val orderChanges: StateFlow = _orderChanges + + /** Moves [id] one place up (-1) or down (+1) in the common order. */ + fun move(context: Context, id: String, delta: Int) + { + val list = order(context).toMutableList() + val i = list.indexOf(id) + val j = i + delta + if (i < 0 || j < 0 || j >= list.size) + { + return + } + list.add(j, list.removeAt(i)) + saveOrder(context, list) + } + + /** + * The user turned the VPN on or off or paused it by hand: the running + * scenario steps back until it ends and puts nothing back afterwards. + */ + @JvmStatic + fun onManual(context: Context) + { + val prefs = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) + val applied = prefs.getString(PREF_APPLIED, null) ?: return + DiagLog.i(TAG, "manual action, scenario steps back until it ends") + prefs.edit().putString(PREF_SUSPENDED, applied) + .remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply() + } + + /** A scenario is in force: the built-in network rules (auto-connect) stand aside. */ + @JvmStatic + fun inControl(context: Context): Boolean = + PreferenceManager.getDefaultSharedPreferences(context.applicationContext).getString(PREF_APPLIED, null) != null + + private fun builtinRules(context: Context): BuiltinRules + { + val c = org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).currentConfig + return BuiltinRules(c.enabled, c.parkOnTrusted, c.connectOnUntrusted, c.trustedSsids) + } + private fun restoreIfApplied(context: Context) { val prefs = PreferenceManager.getDefaultSharedPreferences(context) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ScenariosActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ScenariosActivity.kt index d6ef6acd2..a55174db8 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ScenariosActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ScenariosActivity.kt @@ -148,6 +148,7 @@ private fun ScenariosScreen(onBack: () -> Unit) val scenarios by remember { ScenarioEngine.flow(context) }.collectAsStateWithLifecycle() var editing by remember { mutableStateOf(null) } var pickRulesServer by remember { mutableStateOf(false) } + val orderChanges by ScenarioEngine.orderChanges.collectAsStateWithLifecycle() var creating by remember { mutableStateOf(false) } var resumes by remember { mutableIntStateOf(0) } val owner = LocalLifecycleOwner.current @@ -205,10 +206,24 @@ private fun ScenariosScreen(onBack: () -> Unit) stringResource(R.string.scenarios_intro), style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal), color = c.textSoft, modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 4.dp), ) - RuleCard(ShxIcons.Wifi, c.accentContainer, c.onAccentContainer, stringResource(R.string.scenarios_rule_trusted), stringResource(R.string.scenario_do_off), config.enabled && config.parkOnTrusted, null) - { on -> updateAuto { it.copy(parkOnTrusted = on, connectOnUntrusted = if (on && !it.enabled) true else it.connectOnUntrusted) } } - RuleCard(ShxIcons.Phone, c.accentContainer, c.onAccentContainer, stringResource(R.string.scenarios_rule_untrusted), stringResource(R.string.scenario_do_on), config.enabled && config.connectOnUntrusted, null) - { on -> updateAuto { it.copy(connectOnUntrusted = on, parkOnTrusted = if (on && !it.enabled) true else it.parkOnTrusted) } } + /* one order for the network rules and the scenarios: the top one that applies wins */ + val order = remember(scenarios, orderChanges) { ScenarioEngine.order(context) } + order.forEach { id -> + when (id) + { + org.strongswan.android.logic.scenarios.BUILTIN_TRUSTED -> + RuleCard(ShxIcons.Wifi, c.accentContainer, c.onAccentContainer, stringResource(R.string.scenarios_rule_trusted), stringResource(R.string.scenario_do_off), config.enabled && config.parkOnTrusted, null) + { on -> updateAuto { it.copy(parkOnTrusted = on, connectOnUntrusted = if (on && !it.enabled) true else it.connectOnUntrusted) } } + org.strongswan.android.logic.scenarios.BUILTIN_UNTRUSTED -> + RuleCard(ShxIcons.Phone, c.accentContainer, c.onAccentContainer, stringResource(R.string.scenarios_rule_untrusted), stringResource(R.string.scenario_do_on), config.enabled && config.connectOnUntrusted, null) + { on -> updateAuto { it.copy(connectOnUntrusted = on, parkOnTrusted = if (on && !it.enabled) true else it.parkOnTrusted) } } + else -> scenarios.firstOrNull { it.id == id }?.let { s -> + val (icon, bg, fg) = triggerLook(s.trigger) + RuleCard(icon, bg, fg, ScenarioEngine.describeWhen(context, s), stringResource(ScenarioEngine.actionLabel(s.action)), s.enabled, onClick = { editing = s }) + { on -> ScenarioEngine.save(context, scenarios.map { if (it.id == s.id) it.copy(enabled = on) else it }) } + } + } + } /* the server the two network rules connect (was the old «Trusted networks» screen) */ if (config.enabled) { @@ -227,11 +242,6 @@ private fun ScenariosScreen(onBack: () -> Unit) Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp)) } } - scenarios.forEach { s -> - val (icon, bg, fg) = triggerLook(s.trigger) - RuleCard(icon, bg, fg, ScenarioEngine.describeWhen(context, s), stringResource(ScenarioEngine.actionLabel(s.action)), s.enabled, onClick = { editing = s }) - { on -> ScenarioEngine.save(context, scenarios.map { if (it.id == s.id) it.copy(enabled = on) else it }) } - } ShxSectionHeader(stringResource(R.string.scenarios_trusted).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 10.dp)) ShxPanel() { @@ -323,12 +333,9 @@ private fun ScenariosScreen(onBack: () -> Unit) editing = null }, onDelete = editing?.let { e -> { ScenarioEngine.save(context, scenarios.filter { it.id != e.id }); editing = null } }, - onRaise = editing?.takeIf { e -> scenarios.indexOfFirst { it.id == e.id } > 0 }?.let { e -> - { - val i = scenarios.indexOfFirst { it.id == e.id } - val list = scenarios.toMutableList() - list.add(i - 1, list.removeAt(i)) - ScenarioEngine.save(context, list) + onMove = editing?.let { e -> + { delta: Int -> + ScenarioEngine.move(context, e.id, delta) editing = null } }, @@ -389,7 +396,7 @@ private fun ScenarioSheet( onDismiss: () -> Unit, onSave: (Scenario) -> Unit, onDelete: (() -> Unit)?, - onRaise: (() -> Unit)?, + onMove: ((Int) -> Unit)?, ) { val c = LocalShxColors.current @@ -508,11 +515,18 @@ private fun ScenarioSheet( Text(stringResource(R.string.scenario_app_access), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.onWarningContainer, modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(c.warningContainer).padding(12.dp)) } - if (onDelete != null || onRaise != null) + if (onMove != null) + { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) + { + ShxButton(stringResource(R.string.scenario_raise), { onMove(-1) }, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.ArrowUp) + ShxButton(stringResource(R.string.scenario_lower), { onMove(1) }, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.ArrowDown) + } + } + if (onDelete != null) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - onRaise?.let { ShxButton(stringResource(R.string.scenario_raise), it, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f)) } onDelete?.let { ShxButton(stringResource(R.string.server_delete_confirm), it, style = ShxButtonStyle.DANGER, height = 48.dp, modifier = Modifier.weight(1f)) } } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileControlActivity.java b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileControlActivity.java index c12045817..dfb3a0207 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileControlActivity.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileControlActivity.java @@ -536,11 +536,13 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD String action = normalizeAction(intent); if (START_PROFILE.equals(action)) - { + { /* app, tile, widget, shortcut: a hand action wins over a scenario */ + org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(this); startVpnProfile(intent); } else if (DISCONNECT.equals(action)) { + org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(this); disconnect(intent); } else if (TOGGLE_VPN.equals(action)) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt index 351d47a15..d9311936b 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt @@ -451,6 +451,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V fun disconnect() { + org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication()) /* counted per Wi-Fi for the «turn it off here automatically?» suggestion */ org.strongswan.android.ui.compose.home.ScenarioHints.manualOff(getApplication(), if (_networkKinds.value.first) _ssid.value else null) service?.disconnect() @@ -464,6 +465,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V /** Disconnects for [minutes]; the tunnel comes back by itself when the pause ends. */ fun pause(minutes: Int) { + org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication()) val svc = service ?: return val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return PauseManager.pause(getApplication(), svc, profile.getUUID().toString(), minutes) @@ -477,6 +479,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V /** Pause until 07:00 (today or tomorrow). */ fun pauseUntilMorning() { + org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication()) val svc = service ?: return val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return PauseManager.pauseUntil(getApplication(), svc, profile.getUUID().toString(), PauseManager.nextMorning(System.currentTimeMillis())) @@ -485,6 +488,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V /** Pause until the device moves to another network. */ fun pauseUntilNetworkChange() { + org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication()) val svc = service ?: return val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return PauseManager.pauseUntilNetworkChange(getApplication(), svc, profile.getUUID().toString()) 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 339bfb16b..92eaf311c 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 @@ -1337,4 +1337,5 @@ Выберите сервер Все серверы Добавить + Опустить ниже 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 043afca2c..48cbd00eb 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -1323,4 +1323,5 @@ Choose a server All servers Add + Move down diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/scenarios/ScenariosTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/scenarios/ScenariosTest.kt index 560fdd765..8e1630c84 100644 --- a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/scenarios/ScenariosTest.kt +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/scenarios/ScenariosTest.kt @@ -55,4 +55,35 @@ class ScenariosTest assertThat(activeScenario(listOf(mobile), idle.copy(wifi = true))).isNull() assertThat(activeScenario(listOf(mobile), idle)?.id).isEqualTo("m") } + + private val rules = BuiltinRules(enabled = true, parkOnTrusted = true, connectOnUntrusted = true, trustedSsids = setOf("Home_5G")) + private val night = Scenario(id = "n", trigger = ScenarioTrigger.TIME, startMinute = 60, endMinute = 420, action = ScenarioAction.PAUSE) + + @Test + fun builtinsComeFirstByDefault() + { + assertThat(orderedIds(emptyList(), listOf(night))).containsExactly(BUILTIN_TRUSTED, BUILTIN_UNTRUSTED, "n") + /* unknown ids in the stored order are dropped, new ones go last */ + assertThat(orderedIds(listOf("n", "gone", BUILTIN_TRUSTED), listOf(night))).containsExactly("n", BUILTIN_TRUSTED, BUILTIN_UNTRUSTED) + } + + @Test + fun upperRuleWins() + { + val nightOnMobile = idle.copy(minute = 3 * 60) + /* default order: the mobile rule is above the night pause */ + assertThat(topEntry(emptyList(), listOf(night), rules, nightOnMobile)).isEqualTo(BUILTIN_UNTRUSTED) + /* the user moved the night pause up */ + assertThat(topEntry(listOf("n"), listOf(night), rules, nightOnMobile)).isEqualTo("n") + } + + @Test + fun trustedWifiAndDisabledRules() + { + val home = idle.copy(wifi = true, ssid = "home_5g", mobile = false) + assertThat(topEntry(emptyList(), emptyList(), rules, home)).isEqualTo(BUILTIN_TRUSTED) + assertThat(topEntry(emptyList(), emptyList(), rules, home.copy(ssid = "Cafe"))).isEqualTo(BUILTIN_UNTRUSTED) + assertThat(topEntry(emptyList(), listOf(night), rules.copy(enabled = false), idle.copy(minute = 3 * 60))).isEqualTo("n") + assertThat(topEntry(emptyList(), emptyList(), rules.copy(enabled = false), home)).isNull() + } }