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 <[email protected]>
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)
{
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)
@@ -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. */
@@ -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
* 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<List<Scenario>>(emptyList())
val list: StateFlow<List<Scenario>> = _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<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)
{
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
@@ -148,6 +148,7 @@ private fun ScenariosScreen(onBack: () -> Unit)
val scenarios by remember { ScenarioEngine.flow(context) }.collectAsStateWithLifecycle()
var editing by remember { mutableStateOf<Scenario?>(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)) }
}
}
@@ -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))
@@ -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())
@@ -1337,4 +1337,5 @@
<string name="picker_title">Выберите сервер</string>
<string name="picker_all">Все серверы</string>
<string name="picker_add">Добавить</string>
<string name="scenario_lower">Опустить ниже</string>
</resources>
@@ -1323,4 +1323,5 @@
<string name="picker_title">Choose a server</string>
<string name="picker_all">All servers</string>
<string name="picker_add">Add</string>
<string name="scenario_lower">Move down</string>
</resources>
@@ -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()
}
}