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 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 21:35:17 +07:00
1 parent a39993075e
commit df1575fe27
9 files changed
+197 -23

No files matched your search

@@ -333,6 +333,7 @@ class PauseReceiver : BroadcastReceiver()
{ {
if (intent?.action == PauseManager.ACTION_PAUSE) if (intent?.action == PauseManager.ACTION_PAUSE)
{ {
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(context)
val service = VpnStateService.getLiveInstance() ?: return val service = VpnStateService.getLiveInstance() ?: return
val profile = service.profile ?: return val profile = service.profile ?: return
PauseManager.pause(context, service, profile.getUUID().toString(), 15) PauseManager.pause(context, service, profile.getUUID().toString(), 15)
@@ -252,7 +252,12 @@ class AutoConnectManager private constructor(
whiteListBlocksReconnect = WhiteListManager.getInstance(context).shouldBlockReconnect(), whiteListBlocksReconnect = WhiteListManager.getInstance(context).shouldBlockReconnect(),
) )
/* the user paused the VPN: the pause alarm brings it back, not a network change */ /* 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. */ /** Re-runs the connect decision, e.g. when the white-list gate clears. */
@@ -106,6 +106,50 @@ internal fun activeScenario(list: List<Scenario>, 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<String>,
)
/** The order of everything: [order] first (known ids), the rest after it (built-ins before new scenarios). */
internal fun orderedIds(order: List<String>, scenarios: List<Scenario>): List<String>
{
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<String>, scenarios: List<Scenario>, 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 * Runs the user scenarios of F_Automation. Re-evaluated on network
* changes, at the edges of time windows (alarm), while an app trigger * 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_APPLIED = "pref_scenario_applied"
private const val PREF_WAS_ON = "pref_scenario_was_on" private const val PREF_WAS_ON = "pref_scenario_was_on"
private const val PREF_WAS_PROFILE = "pref_scenario_was_profile" 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<List<Scenario>>(emptyList()) private val _list = MutableStateFlow<List<Scenario>>(emptyList())
val list: StateFlow<List<Scenario>> = _list val list: StateFlow<List<Scenario>> = _list
@@ -200,15 +247,26 @@ object ScenarioEngine
return return
} }
val now = inputs(app) 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) 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) val applied = prefs.getString(PREF_APPLIED, null)
if (active?.id == applied) if (active?.id == applied && active != null)
{ {
return return
} }
if (active == null) if (active == null)
{ { /* nothing, or a built-in network rule above: the auto-connect acts */
restoreIfApplied(app) restoreIfApplied(app)
return 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<String>
{
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<String>)
{
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<Int> = _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) private fun restoreIfApplied(context: Context)
{ {
val prefs = PreferenceManager.getDefaultSharedPreferences(context) val prefs = PreferenceManager.getDefaultSharedPreferences(context)
@@ -148,6 +148,7 @@ private fun ScenariosScreen(onBack: () -> Unit)
val scenarios by remember { ScenarioEngine.flow(context) }.collectAsStateWithLifecycle() val scenarios by remember { ScenarioEngine.flow(context) }.collectAsStateWithLifecycle()
var editing by remember { mutableStateOf<Scenario?>(null) } var editing by remember { mutableStateOf<Scenario?>(null) }
var pickRulesServer by remember { mutableStateOf(false) } var pickRulesServer by remember { mutableStateOf(false) }
val orderChanges by ScenarioEngine.orderChanges.collectAsStateWithLifecycle()
var creating by remember { mutableStateOf(false) } var creating by remember { mutableStateOf(false) }
var resumes by remember { mutableIntStateOf(0) } var resumes by remember { mutableIntStateOf(0) }
val owner = LocalLifecycleOwner.current 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), 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), 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) /* one order for the network rules and the scenarios: the top one that applies wins */
{ on -> updateAuto { it.copy(parkOnTrusted = on, connectOnUntrusted = if (on && !it.enabled) true else it.connectOnUntrusted) } } val order = remember(scenarios, orderChanges) { ScenarioEngine.order(context) }
RuleCard(ShxIcons.Phone, c.accentContainer, c.onAccentContainer, stringResource(R.string.scenarios_rule_untrusted), stringResource(R.string.scenario_do_on), config.enabled && config.connectOnUntrusted, null) order.forEach { id ->
{ on -> updateAuto { it.copy(connectOnUntrusted = on, parkOnTrusted = if (on && !it.enabled) true else it.parkOnTrusted) } } 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) */ /* the server the two network rules connect (was the old «Trusted networks» screen) */
if (config.enabled) 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)) 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)) ShxSectionHeader(stringResource(R.string.scenarios_trusted).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 10.dp))
ShxPanel() ShxPanel()
{ {
@@ -323,12 +333,9 @@ private fun ScenariosScreen(onBack: () -> Unit)
editing = null editing = null
}, },
onDelete = editing?.let { e -> { ScenarioEngine.save(context, scenarios.filter { it.id != e.id }); 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 -> onMove = editing?.let { e ->
{ { delta: Int ->
val i = scenarios.indexOfFirst { it.id == e.id } ScenarioEngine.move(context, e.id, delta)
val list = scenarios.toMutableList()
list.add(i - 1, list.removeAt(i))
ScenarioEngine.save(context, list)
editing = null editing = null
} }
}, },
@@ -389,7 +396,7 @@ private fun ScenarioSheet(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (Scenario) -> Unit, onSave: (Scenario) -> Unit,
onDelete: (() -> Unit)?, onDelete: (() -> Unit)?,
onRaise: (() -> Unit)?, onMove: ((Int) -> Unit)?,
) )
{ {
val c = LocalShxColors.current 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, 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)) 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)) 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)) } onDelete?.let { ShxButton(stringResource(R.string.server_delete_confirm), it, style = ShxButtonStyle.DANGER, height = 48.dp, modifier = Modifier.weight(1f)) }
} }
} }
@@ -536,11 +536,13 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
String action = normalizeAction(intent); String action = normalizeAction(intent);
if (START_PROFILE.equals(action)) 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); startVpnProfile(intent);
} }
else if (DISCONNECT.equals(action)) else if (DISCONNECT.equals(action))
{ {
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(this);
disconnect(intent); disconnect(intent);
} }
else if (TOGGLE_VPN.equals(action)) else if (TOGGLE_VPN.equals(action))
@@ -451,6 +451,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
fun disconnect() fun disconnect()
{ {
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication())
/* counted per Wi-Fi for the «turn it off here automatically?» suggestion */ /* 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) org.strongswan.android.ui.compose.home.ScenarioHints.manualOff(getApplication(), if (_networkKinds.value.first) _ssid.value else null)
service?.disconnect() 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. */ /** Disconnects for [minutes]; the tunnel comes back by itself when the pause ends. */
fun pause(minutes: Int) fun pause(minutes: Int)
{ {
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication())
val svc = service ?: return val svc = service ?: return
val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return
PauseManager.pause(getApplication(), svc, profile.getUUID().toString(), minutes) 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). */ /** Pause until 07:00 (today or tomorrow). */
fun pauseUntilMorning() fun pauseUntilMorning()
{ {
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication())
val svc = service ?: return val svc = service ?: return
val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return
PauseManager.pauseUntil(getApplication(), svc, profile.getUUID().toString(), PauseManager.nextMorning(System.currentTimeMillis())) 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. */ /** Pause until the device moves to another network. */
fun pauseUntilNetworkChange() fun pauseUntilNetworkChange()
{ {
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication())
val svc = service ?: return val svc = service ?: return
val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return
PauseManager.pauseUntilNetworkChange(getApplication(), svc, profile.getUUID().toString()) PauseManager.pauseUntilNetworkChange(getApplication(), svc, profile.getUUID().toString())
@@ -1337,4 +1337,5 @@
<string name="picker_title">Выберите сервер</string> <string name="picker_title">Выберите сервер</string>
<string name="picker_all">Все серверы</string> <string name="picker_all">Все серверы</string>
<string name="picker_add">Добавить</string> <string name="picker_add">Добавить</string>
<string name="scenario_lower">Опустить ниже</string>
</resources> </resources>
@@ -1323,4 +1323,5 @@
<string name="picker_title">Choose a server</string> <string name="picker_title">Choose a server</string>
<string name="picker_all">All servers</string> <string name="picker_all">All servers</string>
<string name="picker_add">Add</string> <string name="picker_add">Add</string>
<string name="scenario_lower">Move down</string>
</resources> </resources>
@@ -55,4 +55,35 @@ class ScenariosTest
assertThat(activeScenario(listOf(mobile), idle.copy(wifi = true))).isNull() assertThat(activeScenario(listOf(mobile), idle.copy(wifi = true))).isNull()
assertThat(activeScenario(listOf(mobile), idle)?.id).isEqualTo("m") 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()
}
} }