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:
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)
|
||||
|
||||
+6
-1
@@ -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. */
|
||||
|
||||
+118
-3
@@ -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)
|
||||
|
||||
+32
-18
@@ -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)) }
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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))
|
||||
|
||||
+4
@@ -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>
|
||||
+31
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user