diff --git a/src/frontends/android/app/src/main/AndroidManifest.xml b/src/frontends/android/app/src/main/AndroidManifest.xml
index c9e37ae66..b6a816d36 100644
--- a/src/frontends/android/app/src/main/AndroidManifest.xml
+++ b/src/frontends/android/app/src/main/AndroidManifest.xml
@@ -79,6 +79,9 @@
+
@@ -318,6 +321,10 @@
+
+
= emptySet(),
+ /** F_Automation «In a trusted Wi-Fi → turn the VPN off» */
+ val parkOnTrusted: Boolean = true,
+ /** F_Automation «Mobile network or a foreign Wi-Fi → turn the VPN on» */
+ val connectOnUntrusted: Boolean = true,
)
enum class NetworkKind
@@ -226,6 +230,7 @@ class AutoConnectManager private constructor(
}
currentNetwork = kind
evaluate()
+ org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context)
}
override fun stateChanged()
@@ -383,6 +388,8 @@ class AutoConnectManager private constructor(
arr.optString(i)?.takeIf { it.isNotBlank() }
}.toSet()
} ?: emptySet(),
+ parkOnTrusted = obj.optBoolean("parkOnTrusted", true),
+ connectOnUntrusted = obj.optBoolean("connectOnUntrusted", true),
)
}
catch (_: JSONException)
@@ -397,6 +404,8 @@ class AutoConnectManager private constructor(
obj.put("enabled", config.enabled)
obj.put("profileUuid", config.profileUuid ?: JSONObject.NULL)
obj.put("trustedSsids", JSONArray(config.trustedSsids.sorted()))
+ obj.put("parkOnTrusted", config.parkOnTrusted)
+ obj.put("connectOnUntrusted", config.connectOnUntrusted)
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
}
@@ -451,13 +460,13 @@ class AutoConnectManager private constructor(
{
null -> Action.IGNORE
NetworkKind.TRUSTED_WIFI ->
- if (vpnActive && autoSession) Action.DISCONNECT else Action.IGNORE
+ if (config.parkOnTrusted && vpnActive && autoSession) Action.DISCONNECT else Action.IGNORE
/* SSID is redacted in background until location is readable. Keep an
* existing tunnel (do not treat unknown as untrusted) and still
* auto-start if the VPN is down. */
- NetworkKind.UNKNOWN_WIFI -> if (!vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
+ NetworkKind.UNKNOWN_WIFI -> if (config.connectOnUntrusted && !vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
NetworkKind.UNTRUSTED_WIFI, NetworkKind.CELLULAR ->
- if (!vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
+ if (config.connectOnUntrusted && !vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
}
}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/WhiteListManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/WhiteListManager.kt
index db2dbe50c..32ceaad52 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/WhiteListManager.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/WhiteListManager.kt
@@ -442,12 +442,12 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
if (before.status != WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.ACTIVE)
{
Events.whitelist(context, true)
- android.os.Handler(android.os.Looper.getMainLooper()).post { org.strongswan.android.logic.profiles.Resilience.onWhitelist(context, true) }
+ android.os.Handler(android.os.Looper.getMainLooper()).post { org.strongswan.android.logic.profiles.Resilience.onWhitelist(context, true); org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context) }
}
else if (before.status == WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.INACTIVE)
{
Events.whitelist(context, false)
- android.os.Handler(android.os.Looper.getMainLooper()).post { org.strongswan.android.logic.profiles.Resilience.onWhitelist(context, false) }
+ android.os.Handler(android.os.Looper.getMainLooper()).post { org.strongswan.android.logic.profiles.Resilience.onWhitelist(context, false); org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context) }
}
if (machine.status == WhiteListStatus.ACTIVE)
{ /* keep watching for the whitelist to clear, at a low rate */
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
new file mode 100644
index 000000000..33e1e6a38
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt
@@ -0,0 +1,500 @@
+/*
+ * Copyright (C) 2026 SHX VPN
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the
+ * Free Software Foundation; either version 2 of the License, or (at your
+ * option) any later version. See .
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * for more details.
+ */
+
+package org.strongswan.android.logic.scenarios
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.app.usage.UsageEvents
+import android.app.usage.UsageStatsManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.net.VpnService
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.os.PowerManager
+import android.telephony.TelephonyManager
+import android.util.Log
+import androidx.preference.PreferenceManager
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.json.JSONArray
+import org.json.JSONObject
+import org.strongswan.android.R
+import org.strongswan.android.data.VpnProfileDataSource
+import org.strongswan.android.logic.DiagLog
+import org.strongswan.android.logic.PauseManager
+import org.strongswan.android.logic.VpnStateService
+import org.strongswan.android.logic.autoconnect.SsidReader
+import org.strongswan.android.logic.connectivity.WhiteListManager
+import org.strongswan.android.logic.connectivity.WhiteListStatus
+import org.strongswan.android.logic.events.EventCategory
+import org.strongswan.android.logic.events.EventKind
+import org.strongswan.android.logic.events.EventLog
+import org.strongswan.android.logic.routing.AppTraffic
+import org.strongswan.android.utils.Constants
+import java.io.File
+import java.util.Calendar
+import java.util.UUID
+
+/** «When» of a scenario (F_ScenarioAdd). */
+enum class ScenarioTrigger { WIFI, MOBILE, APP, TIME, ROAMING, WHITELIST }
+
+/** «What to do». */
+enum class ScenarioAction { VPN_ON, VPN_OFF, PAUSE, SWITCH_SERVER }
+
+/**
+ * A user scenario. While its trigger holds, its action is in force; when
+ * the trigger ends the VPN goes back to how it was («restore»).
+ */
+data class Scenario(
+ val id: String = UUID.randomUUID().toString(),
+ val enabled: Boolean = true,
+ val trigger: ScenarioTrigger,
+ /** WIFI: the networks; empty = any Wi-Fi */
+ val ssids: Set = emptySet(),
+ /** APP: the packages */
+ val packages: Set = emptySet(),
+ /** TIME: minutes of the day, the window may wrap past midnight */
+ val startMinute: Int = 60,
+ val endMinute: Int = 7 * 60,
+ val action: ScenarioAction,
+ /** VPN_ON / SWITCH_SERVER: the profile, null = the default one */
+ val profileUuid: String? = null,
+)
+
+/** Whether [minute] of the day is inside the (possibly wrapping) window. */
+internal fun inWindow(minute: Int, start: Int, end: Int): Boolean =
+ if (start == end) false else if (start < end) minute in start until end else minute >= start || minute < end
+
+/** What the engine sees right now; the decision is a pure function of it. */
+internal data class ScenarioInputs(
+ val ssid: String?,
+ val wifi: Boolean,
+ val mobile: Boolean,
+ val roaming: Boolean,
+ val foreground: String?,
+ val minute: Int,
+ val whitelist: Boolean,
+)
+
+/** The first enabled scenario whose trigger holds: the top one wins. */
+internal fun activeScenario(list: List, now: ScenarioInputs): Scenario? = list.firstOrNull { s ->
+ s.enabled && when (s.trigger)
+ {
+ ScenarioTrigger.WIFI -> now.wifi && (s.ssids.isEmpty() || s.ssids.any { it.equals(now.ssid, ignoreCase = true) })
+ ScenarioTrigger.MOBILE -> now.mobile && !now.wifi
+ ScenarioTrigger.APP -> now.foreground != null && now.foreground in s.packages
+ ScenarioTrigger.TIME -> inWindow(now.minute, s.startMinute, s.endMinute)
+ ScenarioTrigger.ROAMING -> now.roaming
+ ScenarioTrigger.WHITELIST -> now.whitelist
+ }
+}
+
+/**
+ * Runs the user scenarios of F_Automation. Re-evaluated on network
+ * changes, at the edges of time windows (alarm), while an app trigger
+ * exists (foreground app polled every few seconds, needs usage access)
+ * and when the whitelists come or go.
+ */
+object ScenarioEngine
+{
+ private const val TAG = "Scenarios"
+ private const val FILE_NAME = "scenarios.json"
+ private const val POLL_MS = 4000L
+ 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"
+
+ private val _list = MutableStateFlow>(emptyList())
+ val list: StateFlow> = _list
+ @Volatile private var loaded = false
+ private val handler = Handler(Looper.getMainLooper())
+ private var appContext: Context? = null
+ private var polling = false
+ private var lastForeground: String? = null
+
+ fun flow(context: Context): StateFlow>
+ {
+ load(context)
+ return list
+ }
+
+ fun save(context: Context, list: List)
+ {
+ load(context)
+ _list.value = list
+ persist(context)
+ start(context)
+ evaluate(context)
+ }
+
+ /** Starts listening (from the application); idempotent. */
+ @JvmStatic
+ fun start(context: Context)
+ {
+ val app = context.applicationContext
+ appContext = app
+ load(app)
+ scheduleAlarm(app)
+ val needPoll = _list.value.any { it.enabled && it.trigger == ScenarioTrigger.APP }
+ if (needPoll && !polling)
+ {
+ polling = true
+ handler.post(poll)
+ }
+ else if (!needPoll)
+ {
+ polling = false
+ handler.removeCallbacks(poll)
+ }
+ }
+
+ private val poll = object : Runnable
+ {
+ override fun run()
+ {
+ val app = appContext ?: return
+ if (!polling)
+ {
+ return
+ }
+ val screenOn = (app.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
+ if (screenOn)
+ {
+ val fg = foregroundApp(app)
+ if (fg != null && fg != lastForeground)
+ {
+ lastForeground = fg
+ evaluate(app)
+ }
+ }
+ handler.postDelayed(this, POLL_MS)
+ }
+ }
+
+ /** Network, whitelist or alarm: decide again. */
+ @JvmStatic
+ fun evaluate(context: Context)
+ {
+ val app = context.applicationContext
+ load(app)
+ if (_list.value.none { it.enabled })
+ {
+ restoreIfApplied(app)
+ return
+ }
+ val now = inputs(app)
+ val active = activeScenario(_list.value, now)
+ val prefs = PreferenceManager.getDefaultSharedPreferences(app)
+ val applied = prefs.getString(PREF_APPLIED, null)
+ if (active?.id == applied)
+ {
+ return
+ }
+ if (active == null)
+ {
+ restoreIfApplied(app)
+ return
+ }
+ val service = VpnStateService.getLiveInstance() ?: return
+ if (applied == null)
+ { /* remember how it was, to put it back when the scenario ends */
+ val on = service.state != VpnStateService.State.DISABLED
+ prefs.edit().putBoolean(PREF_WAS_ON, on)
+ .putString(PREF_WAS_PROFILE, service.profile?.getUUID()?.toString()).apply()
+ }
+ prefs.edit().putString(PREF_APPLIED, active.id).apply()
+ DiagLog.i(TAG, "scenario ${active.trigger} → ${active.action}")
+ apply(app, service, active)
+ }
+
+ private fun apply(context: Context, service: VpnStateService, scenario: Scenario)
+ {
+ val profile = scenario.profileUuid ?: defaultProfile(context) ?: service.profile?.getUUID()?.toString()
+ when (scenario.action)
+ {
+ ScenarioAction.VPN_ON, ScenarioAction.SWITCH_SERVER ->
+ {
+ val running = service.state != VpnStateService.State.DISABLED
+ if (profile != null && (!running || service.profile?.getUUID()?.toString() != profile))
+ {
+ connect(context, service, profile)
+ }
+ }
+ ScenarioAction.VPN_OFF -> if (service.state != VpnStateService.State.DISABLED) service.disconnect()
+ ScenarioAction.PAUSE ->
+ {
+ val current = service.profile?.getUUID()?.toString() ?: profile ?: return
+ val until = if (scenario.trigger == ScenarioTrigger.TIME) windowEnd(scenario) else System.currentTimeMillis() + 12L * 60 * 60 * 1000
+ if (service.state != VpnStateService.State.DISABLED)
+ {
+ PauseManager.pauseUntil(context, service, current, until)
+ }
+ }
+ }
+ EventLog.getInstance(context).record(
+ EventKind.INFO, EventCategory.CONNECTION,
+ context.getString(R.string.event_scenario_title, context.getString(actionLabel(scenario.action))),
+ context.getString(R.string.event_scenario_text, describeWhen(context, scenario)), key = "scenario:${scenario.id}",
+ )
+ }
+
+ private fun restoreIfApplied(context: Context)
+ {
+ val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+ if (prefs.getString(PREF_APPLIED, null) == null)
+ {
+ return
+ }
+ val wasOn = prefs.getBoolean(PREF_WAS_ON, false)
+ val wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
+ prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
+ val service = VpnStateService.getLiveInstance() ?: return
+ DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)")
+ if (PauseManager.isPaused(context))
+ {
+ if (wasOn) PauseManager.resumeNow(context) else PauseManager.cancel(context)
+ return
+ }
+ val running = service.state != VpnStateService.State.DISABLED
+ when
+ {
+ wasOn && wasProfile != null && (!running || service.profile?.getUUID()?.toString() != wasProfile) -> connect(context, service, wasProfile)
+ !wasOn && running -> service.disconnect()
+ }
+ }
+
+ private fun connect(context: Context, service: VpnStateService, uuid: String)
+ {
+ val needsConsent = runCatching { VpnService.prepare(context) != null }.getOrDefault(true)
+ if (needsConsent)
+ {
+ DiagLog.i(TAG, "VPN permission missing, scenario cannot connect")
+ return
+ }
+ val info = Bundle()
+ info.putString(VpnProfileDataSource.KEY_UUID, uuid)
+ service.connect(info, true)
+ }
+
+ private fun defaultProfile(context: Context): String? =
+ PreferenceManager.getDefaultSharedPreferences(context).getString(Constants.PREF_MRU_VPN_PROFILE, null)
+
+ private fun inputs(context: Context): ScenarioInputs
+ {
+ val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
+ var wifi = false
+ var mobile = false
+ var ssid: String? = null
+ @Suppress("DEPRECATION")
+ cm?.allNetworks?.forEach { network ->
+ val caps = runCatching { cm.getNetworkCapabilities(network) }.getOrNull() ?: return@forEach
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return@forEach
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI))
+ {
+ wifi = true
+ ssid = SsidReader.from(context, caps)
+ }
+ else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
+ {
+ mobile = true
+ }
+ }
+ val roaming = mobile && runCatching { (context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager)?.isNetworkRoaming == true }.getOrDefault(false)
+ val c = Calendar.getInstance()
+ val whitelist = runCatching { WhiteListManager.getInstance(context).state.value.status == WhiteListStatus.ACTIVE }.getOrDefault(false)
+ val fg = if (_list.value.any { it.enabled && it.trigger == ScenarioTrigger.APP }) lastForeground ?: foregroundApp(context) else null
+ return ScenarioInputs(ssid, wifi, mobile, roaming, fg, c.get(Calendar.HOUR_OF_DAY) * 60 + c.get(Calendar.MINUTE), whitelist)
+ }
+
+ /** The app last moved to the foreground (usage events of the last minute); null without access. */
+ private fun foregroundApp(context: Context): String?
+ {
+ if (!AppTraffic.hasAccess(context))
+ {
+ return null
+ }
+ val usm = context.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager ?: return null
+ val now = System.currentTimeMillis()
+ val events = runCatching { usm.queryEvents(now - 60_000, now) }.getOrNull() ?: return lastForeground
+ var last: String? = null
+ val event = UsageEvents.Event()
+ while (events.hasNextEvent())
+ {
+ events.getNextEvent(event)
+ if (event.eventType == UsageEvents.Event.ACTIVITY_RESUMED)
+ {
+ last = event.packageName
+ }
+ }
+ return last ?: lastForeground
+ }
+
+ private fun windowEnd(s: Scenario): Long
+ {
+ val c = Calendar.getInstance()
+ val now = c.get(Calendar.HOUR_OF_DAY) * 60 + c.get(Calendar.MINUTE)
+ c.set(Calendar.HOUR_OF_DAY, s.endMinute / 60)
+ c.set(Calendar.MINUTE, s.endMinute % 60)
+ c.set(Calendar.SECOND, 0)
+ c.set(Calendar.MILLISECOND, 0)
+ if (s.endMinute <= now)
+ {
+ c.add(Calendar.DAY_OF_YEAR, 1)
+ }
+ return c.timeInMillis
+ }
+
+ /** An alarm at the next edge of any time window. */
+ private fun scheduleAlarm(context: Context)
+ {
+ val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
+ val pi = PendingIntent.getBroadcast(context, 46, Intent(context, ScenarioReceiver::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ val edges = _list.value.filter { it.enabled && it.trigger == ScenarioTrigger.TIME }.flatMap { listOf(it.startMinute, it.endMinute) }
+ if (edges.isEmpty())
+ {
+ am.cancel(pi)
+ return
+ }
+ val c = Calendar.getInstance()
+ val now = c.get(Calendar.HOUR_OF_DAY) * 60 + c.get(Calendar.MINUTE)
+ val next = edges.minByOrNull { (it - now + 1440 - 1) % 1440 + 1 } ?: return
+ c.set(Calendar.HOUR_OF_DAY, next / 60)
+ c.set(Calendar.MINUTE, next % 60)
+ c.set(Calendar.SECOND, 5)
+ c.set(Calendar.MILLISECOND, 0)
+ if (c.timeInMillis <= System.currentTimeMillis())
+ {
+ c.add(Calendar.DAY_OF_YEAR, 1)
+ }
+ try
+ {
+ am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
+ }
+ catch (_: SecurityException)
+ {
+ }
+ }
+
+ fun actionLabel(action: ScenarioAction): Int = when (action)
+ {
+ ScenarioAction.VPN_ON -> R.string.scenario_do_on
+ ScenarioAction.VPN_OFF -> R.string.scenario_do_off
+ ScenarioAction.PAUSE -> R.string.scenario_do_pause
+ ScenarioAction.SWITCH_SERVER -> R.string.scenario_do_switch
+ }
+
+ /** «Telegram or YouTube is open», «Every night 01:00–07:00» … */
+ fun describeWhen(context: Context, s: Scenario): String
+ {
+ fun hm(m: Int) = String.format("%02d:%02d", m / 60, m % 60)
+ return when (s.trigger)
+ {
+ ScenarioTrigger.WIFI -> if (s.ssids.isEmpty()) context.getString(R.string.scenario_when_any_wifi)
+ else context.getString(R.string.scenario_when_wifi, s.ssids.joinToString(", "))
+ ScenarioTrigger.MOBILE -> context.getString(R.string.scenario_when_mobile)
+ ScenarioTrigger.APP ->
+ {
+ val pm = context.packageManager
+ val names = s.packages.map { pkg -> runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrDefault(pkg) }
+ context.getString(R.string.scenario_when_app, names.joinToString(context.getString(R.string.scenario_or)))
+ }
+ ScenarioTrigger.TIME -> context.getString(R.string.scenario_when_time, hm(s.startMinute), hm(s.endMinute))
+ ScenarioTrigger.ROAMING -> context.getString(R.string.scenario_when_roaming)
+ ScenarioTrigger.WHITELIST -> context.getString(R.string.scenario_when_whitelist)
+ }
+ }
+
+ private fun load(context: Context)
+ {
+ if (loaded)
+ {
+ return
+ }
+ synchronized(this)
+ {
+ if (loaded)
+ {
+ return
+ }
+ try
+ {
+ val file = File(context.filesDir, FILE_NAME)
+ if (file.exists())
+ {
+ val array = JSONArray(file.readText())
+ _list.value = (0 until array.length()).mapNotNull { runCatching { fromJson(array.getJSONObject(it)) }.getOrNull() }
+ }
+ }
+ catch (e: Exception)
+ {
+ Log.w(TAG, "Loading failed", e)
+ }
+ loaded = true
+ }
+ }
+
+ private fun persist(context: Context)
+ {
+ try
+ {
+ val array = JSONArray()
+ _list.value.forEach { s ->
+ array.put(
+ JSONObject().put("id", s.id).put("on", s.enabled).put("when", s.trigger.name)
+ .put("ssids", JSONArray(s.ssids.toList())).put("apps", JSONArray(s.packages.toList()))
+ .put("start", s.startMinute).put("end", s.endMinute).put("do", s.action.name)
+ .put("profile", s.profileUuid ?: JSONObject.NULL)
+ )
+ }
+ File(context.filesDir, FILE_NAME).writeText(array.toString())
+ }
+ catch (e: Exception)
+ {
+ Log.w(TAG, "Saving failed", e)
+ }
+ }
+
+ private fun fromJson(o: JSONObject): Scenario
+ {
+ fun set(key: String): Set = o.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.getString(it) }.toSet() } ?: emptySet()
+ return Scenario(
+ id = o.getString("id"),
+ enabled = o.optBoolean("on", true),
+ trigger = ScenarioTrigger.valueOf(o.getString("when")),
+ ssids = set("ssids"),
+ packages = set("apps"),
+ startMinute = o.optInt("start", 60),
+ endMinute = o.optInt("end", 420),
+ action = ScenarioAction.valueOf(o.getString("do")),
+ profileUuid = o.optString("profile").takeIf { !o.isNull("profile") && it.isNotEmpty() },
+ )
+ }
+}
+
+/** Edge of a time window: decide again and arm the next edge. */
+class ScenarioReceiver : BroadcastReceiver()
+{
+ override fun onReceive(context: Context, intent: Intent?)
+ {
+ ScenarioEngine.start(context)
+ ScenarioEngine.evaluate(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
new file mode 100644
index 000000000..9091592d4
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ScenariosActivity.kt
@@ -0,0 +1,668 @@
+/*
+ * Copyright (C) 2026 SHX VPN
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the
+ * Free Software Foundation; either version 2 of the License, or (at your
+ * option) any later version.
+ */
+
+package org.strongswan.android.ui
+
+import android.Manifest
+import android.app.TimePickerDialog
+import android.app.usage.UsageStatsManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.drawable.Drawable
+import android.os.Bundle
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+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.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CheckboxDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.drawable.toBitmap
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.strongswan.android.R
+import org.strongswan.android.data.VpnProfile
+import org.strongswan.android.data.VpnProfileSource
+import org.strongswan.android.logic.autoconnect.AutoConnectManager
+import org.strongswan.android.logic.autoconnect.SsidReader
+import org.strongswan.android.logic.routing.AppTraffic
+import org.strongswan.android.logic.scenarios.Scenario
+import org.strongswan.android.logic.scenarios.ScenarioAction
+import org.strongswan.android.logic.scenarios.ScenarioEngine
+import org.strongswan.android.logic.scenarios.ScenarioTrigger
+import org.strongswan.android.ui.compose.design.ShxButton
+import org.strongswan.android.ui.compose.design.ShxButtonStyle
+import org.strongswan.android.ui.compose.design.ShxFormField
+import org.strongswan.android.ui.compose.design.ShxIcons
+import org.strongswan.android.ui.compose.design.ShxPageHeader
+import org.strongswan.android.ui.compose.design.ShxPanel
+import org.strongswan.android.ui.compose.design.ShxSectionHeader
+import org.strongswan.android.ui.compose.design.ShxSheet
+import org.strongswan.android.ui.compose.design.ShxSwitch
+import org.strongswan.android.ui.compose.theme.LocalShxColors
+import org.strongswan.android.ui.compose.theme.ShxTheme
+import org.strongswan.android.ui.compose.theme.ShxType
+import org.strongswan.android.utils.Constants
+
+/** «Scenarios» (F_Automation): the built-in network rules, the user scenarios, trusted networks. */
+class ScenariosActivity : AppCompatActivity()
+{
+ override fun onCreate(savedInstanceState: Bundle?)
+ {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ ShxTheme {
+ ScenariosScreen(onBack = { finish() })
+ }
+ }
+ }
+}
+
+private fun loadProfiles(context: Context): List
+{
+ val source = VpnProfileSource(context)
+ source.open()
+ try
+ {
+ return source.allVpnProfiles
+ }
+ finally
+ {
+ source.close()
+ }
+}
+
+@Composable
+private fun ScenariosScreen(onBack: () -> Unit)
+{
+ val c = LocalShxColors.current
+ val context = LocalContext.current
+ val auto = remember { AutoConnectManager.getInstance(context) }
+ var config by remember { mutableStateOf(auto.currentConfig) }
+ val scenarios by remember { ScenarioEngine.flow(context) }.collectAsStateWithLifecycle()
+ var editing by remember { mutableStateOf(null) }
+ var creating by remember { mutableStateOf(false) }
+ var resumes by remember { mutableIntStateOf(0) }
+ val owner = LocalLifecycleOwner.current
+ DisposableEffect(owner) {
+ val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { resumes++; config = auto.currentConfig } }
+ owner.lifecycle.addObserver(observer)
+ onDispose { owner.lifecycle.removeObserver(observer) }
+ }
+ val profiles by produceState(emptyList(), resumes) { value = withContext(Dispatchers.IO) { loadProfiles(context) } }
+
+ fun updateAuto(transform: (AutoConnectManager.Config) -> AutoConnectManager.Config)
+ {
+ auto.updateConfig { current ->
+ var next = transform(current)
+ next = next.copy(enabled = next.parkOnTrusted || next.connectOnUntrusted)
+ if (next.enabled && next.profileUuid == null)
+ { /* the rules need a server: the last used, else the first */
+ val mru = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).getString(Constants.PREF_MRU_VPN_PROFILE, null)
+ next = next.copy(profileUuid = mru ?: profiles.firstOrNull()?.getUUID()?.toString())
+ }
+ next
+ }
+ config = auto.currentConfig
+ }
+ /* «+ Add current network» needs the location permission to read the SSID */
+ var pendingAdd by remember { mutableStateOf(false) }
+ fun addCurrentNetwork()
+ {
+ val ssid = SsidReader.from(context)
+ if (ssid != null)
+ {
+ updateAuto { it.copy(trustedSsids = it.trustedSsids + ssid) }
+ }
+ else
+ {
+ android.widget.Toast.makeText(context, R.string.scenarios_no_wifi, android.widget.Toast.LENGTH_SHORT).show()
+ }
+ }
+ val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
+ if (pendingAdd)
+ {
+ pendingAdd = false
+ addCurrentNetwork()
+ }
+ }
+
+ Box(Modifier.fillMaxSize().background(c.background))
+ {
+ Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 104.dp))
+ {
+ ShxPageHeader(stringResource(R.string.scenarios_title), onBack = onBack)
+ Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp))
+ {
+ Text(
+ 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) } }
+ 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()
+ {
+ config.trustedSsids.sorted().forEach { ssid ->
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().height(56.dp).padding(start = 16.dp, end = 8.dp))
+ {
+ Icon(ShxIcons.Wifi, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(20.dp))
+ Text(ssid, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.size(40.dp).clip(CircleShape).clickable(role = Role.Button) { updateAuto { it.copy(trustedSsids = it.trustedSsids - ssid) } },
+ )
+ {
+ Icon(ShxIcons.Close, contentDescription = stringResource(R.string.scenarios_remove_net), tint = c.iconMuted, modifier = Modifier.size(18.dp))
+ }
+ }
+ Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
+ }
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxWidth().height(56.dp).clickable(role = Role.Button) {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED)
+ {
+ pendingAdd = true
+ permission.launch(Manifest.permission.ACCESS_FINE_LOCATION)
+ }
+ else addCurrentNetwork()
+ },
+ )
+ {
+ Text(stringResource(R.string.scenarios_add_current), style = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Bold), color = c.accentText)
+ }
+ }
+ }
+ }
+ /* «+ Scenario» */
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 16.dp, bottom = 24.dp).height(60.dp)
+ .shadow(10.dp, RoundedCornerShape(20.dp), ambientColor = c.accent, spotColor = c.accent)
+ .clip(RoundedCornerShape(20.dp)).background(c.accent).clickable(role = Role.Button) { creating = true }.padding(horizontal = 22.dp),
+ )
+ {
+ Text(stringResource(R.string.scenarios_add), style = ShxType.button.copy(fontSize = 16.sp), color = Color.White)
+ }
+ }
+
+ if (creating || editing != null)
+ {
+ ScenarioSheet(
+ initial = editing,
+ profiles = profiles,
+ onDismiss = { creating = false; editing = null },
+ onSave = { saved ->
+ val list = if (scenarios.any { it.id == saved.id }) scenarios.map { if (it.id == saved.id) saved else it } else scenarios + saved
+ ScenarioEngine.save(context, list)
+ creating = false
+ 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)
+ editing = null
+ }
+ },
+ )
+ }
+}
+
+@Composable
+private fun triggerLook(trigger: ScenarioTrigger): Triple
+{
+ val c = LocalShxColors.current
+ return when (trigger)
+ {
+ ScenarioTrigger.WIFI -> Triple(ShxIcons.Wifi, c.accentContainer, c.onAccentContainer)
+ ScenarioTrigger.MOBILE -> Triple(ShxIcons.Phone, c.accentContainer, c.onAccentContainer)
+ ScenarioTrigger.APP -> Triple(ShxIcons.Apps, c.warningStrong, c.onWarning)
+ ScenarioTrigger.TIME -> Triple(ShxIcons.Clock, c.tile, c.textSoft)
+ ScenarioTrigger.ROAMING -> Triple(ShxIcons.Globe, c.accentContainer, c.onAccentContainer)
+ ScenarioTrigger.WHITELIST -> Triple(ShxIcons.Alert, c.warningStrong, c.onWarning)
+ }
+}
+
+/** One rule of F_Automation: icon well, «when», «→ then», switch. */
+@Composable
+private fun RuleCard(icon: ImageVector, bg: Color, fg: Color, whenText: String, thenText: String, on: Boolean, onClick: (() -> Unit)?, onToggle: (Boolean) -> Unit)
+{
+ val c = LocalShxColors.current
+ val shape = RoundedCornerShape(24.dp)
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape)
+ .then(if (onClick != null) Modifier.clickable(role = Role.Button, onClick = onClick) else Modifier).padding(14.dp),
+ )
+ {
+ Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(bg))
+ {
+ Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(22.dp))
+ }
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
+ {
+ Text(whenText, style = ShxType.sub.copy(fontWeight = FontWeight.SemiBold), color = c.textSub, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
+ {
+ Icon(ShxIcons.Chevron, contentDescription = null, tint = c.accent, modifier = Modifier.size(16.dp))
+ Text(thenText, style = ShxType.cardTitle, color = c.text)
+ }
+ }
+ ShxSwitch(on, onToggle)
+ }
+}
+
+/** «New scenario» (F_ScenarioAdd): when, what, server, the summary. */
+@Composable
+private fun ScenarioSheet(
+ initial: Scenario?,
+ profiles: List,
+ onDismiss: () -> Unit,
+ onSave: (Scenario) -> Unit,
+ onDelete: (() -> Unit)?,
+ onRaise: (() -> Unit)?,
+)
+{
+ val c = LocalShxColors.current
+ val context = LocalContext.current
+ var trigger by remember { mutableStateOf(initial?.trigger ?: ScenarioTrigger.APP) }
+ var action by remember { mutableStateOf(initial?.action ?: ScenarioAction.VPN_ON) }
+ var packages by remember { mutableStateOf(initial?.packages ?: emptySet()) }
+ var ssids by remember { mutableStateOf(initial?.ssids?.joinToString(", ") ?: SsidReader.from(context).orEmpty()) }
+ var start by remember { mutableIntStateOf(initial?.startMinute ?: 60) }
+ var end by remember { mutableIntStateOf(initial?.endMinute ?: 7 * 60) }
+ var profile by remember { mutableStateOf(initial?.profileUuid) }
+ var picking by remember { mutableStateOf(false) }
+ var pickProfile by remember { mutableStateOf(false) }
+ val draft = Scenario(
+ id = initial?.id ?: java.util.UUID.randomUUID().toString(),
+ enabled = initial?.enabled ?: true,
+ trigger = trigger,
+ ssids = ssids.split(',').map { it.trim() }.filter { it.isNotEmpty() }.toSet(),
+ packages = packages,
+ startMinute = start,
+ endMinute = end,
+ action = action,
+ profileUuid = profile?.takeIf { action == ScenarioAction.VPN_ON || action == ScenarioAction.SWITCH_SERVER },
+ )
+ val valid = when (trigger)
+ {
+ ScenarioTrigger.APP -> packages.isNotEmpty()
+ ScenarioTrigger.TIME -> start != end
+ ScenarioTrigger.WIFI -> true
+ else -> true
+ } && !(action == ScenarioAction.SWITCH_SERVER && profile == null)
+
+ ShxSheet(onDismiss = onDismiss)
+ {
+ Column(verticalArrangement = Arrangement.spacedBy(14.dp))
+ {
+ Text(stringResource(if (initial == null) R.string.scenario_new else R.string.scenario_edit), style = ShxType.sheetTitle, color = c.text, modifier = Modifier.padding(start = 4.dp, top = 4.dp))
+ Text(stringResource(R.string.scenario_step_when), style = ShxType.section, color = c.accentText, modifier = Modifier.padding(horizontal = 4.dp))
+ val whens = listOf(
+ ScenarioTrigger.WIFI to R.string.scenario_t_wifi, ScenarioTrigger.MOBILE to R.string.scenario_t_mobile, ScenarioTrigger.APP to R.string.scenario_t_app,
+ ScenarioTrigger.TIME to R.string.scenario_t_time, ScenarioTrigger.ROAMING to R.string.scenario_t_roaming, ScenarioTrigger.WHITELIST to R.string.scenario_t_whitelist,
+ )
+ whens.chunked(3).forEach { row ->
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
+ {
+ row.forEach { (t, label) ->
+ val on = trigger == t
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically),
+ modifier = Modifier.weight(1f).height(76.dp).clip(RoundedCornerShape(18.dp)).background(if (on) c.accentSoft else c.card)
+ .border(if (on) 2.dp else 1.dp, if (on) c.accent else c.cardBorder, RoundedCornerShape(18.dp))
+ .clickable(role = Role.RadioButton) { trigger = t }.padding(horizontal = 4.dp),
+ )
+ {
+ Icon(triggerLook(t).first, contentDescription = null, tint = if (on) c.onAccentContainer else c.text, modifier = Modifier.size(20.dp))
+ Text(stringResource(label), style = ShxType.meta.copy(fontWeight = FontWeight.Bold, lineHeight = 14.sp), color = if (on) c.onAccentContainer else c.text, textAlign = TextAlign.Center, maxLines = 2)
+ }
+ }
+ }
+ }
+ when (trigger)
+ {
+ ScenarioTrigger.APP -> AppChips(packages, onPick = { picking = true }, onRemove = { packages = packages - it })
+ ScenarioTrigger.WIFI -> ShxFormField(label = stringResource(R.string.scenario_ssids), value = ssids, onValueChange = { ssids = it }, placeholder = stringResource(R.string.scenario_ssids_hint))
+ ScenarioTrigger.TIME -> Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
+ {
+ TimeBox(stringResource(R.string.scenario_from), start, Modifier.weight(1f)) { start = it }
+ TimeBox(stringResource(R.string.scenario_to), end, Modifier.weight(1f)) { end = it }
+ }
+ else -> Unit
+ }
+ Text(stringResource(R.string.scenario_step_what), style = ShxType.section, color = c.accentText, modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 6.dp))
+ listOf(ScenarioAction.VPN_ON, ScenarioAction.VPN_OFF, ScenarioAction.PAUSE, ScenarioAction.SWITCH_SERVER).chunked(2).forEach { row ->
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
+ {
+ row.forEach { a ->
+ val on = action == a
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.weight(1f).height(52.dp).clip(RoundedCornerShape(16.dp)).background(if (on) c.accentSoft else c.card)
+ .border(if (on) 2.dp else 1.dp, if (on) c.accent else c.cardBorder, RoundedCornerShape(16.dp))
+ .clickable(role = Role.RadioButton) { action = a },
+ )
+ {
+ Text(stringResource(ScenarioEngine.actionLabel(a)), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = if (on) c.onAccentContainer else c.text)
+ }
+ }
+ }
+ }
+ if (action == ScenarioAction.VPN_ON || action == ScenarioAction.SWITCH_SERVER)
+ {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.card).border(1.dp, c.cardBorder, RoundedCornerShape(18.dp))
+ .clickable(role = Role.Button) { pickProfile = true }.padding(horizontal = 14.dp, vertical = 12.dp),
+ )
+ {
+ Text(stringResource(R.string.scenario_server), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
+ Text(
+ profiles.firstOrNull { it.getUUID().toString() == profile }?.let { it.name ?: it.gateway } ?: stringResource(R.string.scenario_server_auto),
+ style = ShxType.body, color = c.textSub,
+ )
+ Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
+ }
+ }
+ Text(
+ stringResource(R.string.scenario_summary, ScenarioEngine.describeWhen(context, draft),
+ stringResource(ScenarioEngine.actionLabel(action)).replaceFirstChar { it.lowercase() }),
+ style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal), color = c.onAccentContainer,
+ modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.accentSoft).padding(horizontal = 14.dp, vertical = 12.dp),
+ )
+ if (trigger == ScenarioTrigger.APP && !AppTraffic.hasAccess(context))
+ {
+ 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)
+ {
+ 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)) }
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
+ {
+ ShxButton(stringResource(android.R.string.cancel), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
+ ShxButton(stringResource(R.string.server_save), { if (valid) onSave(draft) }, modifier = Modifier.weight(2f), enabled = valid)
+ }
+ }
+ }
+ if (picking)
+ {
+ AppPicker(packages, onDone = { packages = it; picking = false }, onDismiss = { picking = false })
+ }
+ if (pickProfile)
+ {
+ ShxSheet(onDismiss = { pickProfile = false })
+ {
+ ShxPanel()
+ {
+ (listOf(null) + profiles).forEachIndexed { i, p ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable { profile = p?.getUUID()?.toString(); pickProfile = false }.padding(horizontal = 16.dp),
+ )
+ {
+ Text(p?.let { it.name ?: it.gateway } ?: stringResource(R.string.scenario_server_auto), style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
+ if (profile == p?.getUUID()?.toString())
+ {
+ Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
+ }
+ }
+ if (i < profiles.size)
+ {
+ Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TimeBox(label: String, minute: Int, modifier: Modifier, onPick: (Int) -> Unit)
+{
+ val c = LocalShxColors.current
+ val context = LocalContext.current
+ Column(
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ modifier = modifier.clip(RoundedCornerShape(16.dp)).background(c.card).border(1.dp, c.cardBorder, RoundedCornerShape(16.dp))
+ .clickable(role = Role.Button) {
+ TimePickerDialog(context, { _, h, m -> onPick(h * 60 + m) }, minute / 60, minute % 60, true).show()
+ }.padding(horizontal = 14.dp, vertical = 10.dp),
+ )
+ {
+ Text(label, style = ShxType.meta, color = c.textSub)
+ Text(String.format("%02d:%02d", minute / 60, minute % 60), style = ShxType.cardHeading, color = c.text)
+ }
+}
+
+/** Chosen apps as chips with icons, plus «+ Choose». */
+@Composable
+private fun AppChips(packages: Set, onPick: () -> Unit, onRemove: (String) -> Unit)
+{
+ val c = LocalShxColors.current
+ val context = LocalContext.current
+ val pm = context.packageManager
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(horizontal = 2.dp))
+ {
+ packages.forEach { pkg ->
+ val info = remember(pkg) { runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull() }
+ val label = remember(pkg) { info?.let { pm.getApplicationLabel(it).toString() } ?: pkg }
+ val icon = remember(pkg) { info?.let { runCatching { pm.getApplicationIcon(it).toBitmap(72, 72).asImageBitmap() }.getOrNull() } }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.height(36.dp).clip(RoundedCornerShape(18.dp)).background(c.accentContainer).clickable { onRemove(pkg) }.padding(start = 6.dp, end = 12.dp),
+ )
+ {
+ if (icon != null) Image(icon, contentDescription = null, modifier = Modifier.size(24.dp).clip(RoundedCornerShape(8.dp)))
+ Text(label, style = ShxType.chip, color = c.onAccentContainer, maxLines = 1)
+ }
+ }
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.height(36.dp).clip(RoundedCornerShape(18.dp)).border(1.dp, c.iconMuted, RoundedCornerShape(18.dp)).clickable(role = Role.Button, onClick = onPick).padding(horizontal = 12.dp),
+ )
+ {
+ Text(stringResource(R.string.scenario_pick_apps), style = ShxType.chip, color = c.accentText)
+ }
+ }
+}
+
+private class LaunchableApp(val pkg: String, val label: String, val icon: Drawable?)
+
+/** Launchable apps, the most used of the last week first (with usage access). */
+private fun loadApps(context: Context): Pair, Set>
+{
+ val pm = context.packageManager
+ val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
+ val apps = pm.queryIntentActivities(intent, 0).map { it.activityInfo.packageName }.distinct()
+ .filter { it != context.packageName }
+ .map { pkg ->
+ val info = runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull()
+ LaunchableApp(pkg, info?.let { pm.getApplicationLabel(it).toString() } ?: pkg, info?.let { runCatching { pm.getApplicationIcon(it) }.getOrNull() })
+ }
+ val frequent = if (AppTraffic.hasAccess(context))
+ {
+ val usm = context.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager
+ val now = System.currentTimeMillis()
+ usm?.queryUsageStats(UsageStatsManager.INTERVAL_BEST, now - 7L * 24 * 60 * 60 * 1000, now)
+ ?.filter { it.totalTimeInForeground > 0 }?.sortedByDescending { it.totalTimeInForeground }
+ ?.map { it.packageName }?.filter { p -> apps.any { it.pkg == p } }?.distinct()?.take(7)?.toSet() ?: emptySet()
+ }
+ else emptySet()
+ return apps.sortedBy { it.label.lowercase() } to frequent
+}
+
+/** «Apps» (F_AppPicker): search, often opened first, checkboxes, Done. */
+@Composable
+private fun AppPicker(selected: Set, onDone: (Set) -> Unit, onDismiss: () -> Unit)
+{
+ val c = LocalShxColors.current
+ val context = LocalContext.current
+ var chosen by remember { mutableStateOf(selected) }
+ var query by remember { mutableStateOf("") }
+ val data by produceState, Set>?>(null) { value = withContext(Dispatchers.IO) { loadApps(context) } }
+ Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false))
+ {
+ Column(Modifier.fillMaxSize().background(c.background).statusBarsPadding().navigationBarsPadding())
+ {
+ ShxPageHeader(stringResource(R.string.stats_apps), onBack = onDismiss)
+ {
+ Text(stringResource(R.string.scenario_chosen, chosen.size), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.accentText, modifier = Modifier.padding(end = 8.dp))
+ }
+ Column(Modifier.weight(1f).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
+ {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(26.dp)).background(c.tile).padding(horizontal = 16.dp),
+ )
+ {
+ Icon(ShxIcons.Search, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
+ Box(Modifier.weight(1f))
+ {
+ if (query.isEmpty())
+ {
+ Text(stringResource(R.string.scenario_search_apps), style = ShxType.body.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium), color = c.textSub)
+ }
+ BasicTextField(query, { query = it }, singleLine = true, textStyle = ShxType.body.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium, color = c.text), cursorBrush = SolidColor(c.accent), modifier = Modifier.fillMaxWidth())
+ }
+ }
+ val (apps, frequent) = data ?: (emptyList() to emptySet())
+ val shown = apps.filter { query.isBlank() || it.label.contains(query.trim(), ignoreCase = true) }
+ val top = if (query.isBlank()) shown.filter { it.pkg in frequent }.sortedBy { frequent.indexOf(it.pkg) } else emptyList()
+ val rest = shown - top.toSet()
+ LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp))
+ {
+ if (top.isNotEmpty())
+ {
+ item { ShxSectionHeader(stringResource(R.string.scenario_often).uppercase(), Modifier.padding(horizontal = 6.dp)) }
+ item { AppList(top, chosen) { pkg -> chosen = if (pkg in chosen) chosen - pkg else chosen + pkg } }
+ item { ShxSectionHeader(stringResource(R.string.scenario_all_apps).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp)) }
+ }
+ item { AppList(rest, chosen) { pkg -> chosen = if (pkg in chosen) chosen - pkg else chosen + pkg } }
+ }
+ ShxButton(stringResource(R.string.scenario_done), { onDone(chosen) }, modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun AppList(apps: List, chosen: Set, onToggle: (String) -> Unit)
+{
+ val c = LocalShxColors.current
+ if (apps.isEmpty()) return
+ ShxPanel()
+ {
+ apps.forEachIndexed { i, app ->
+ val icon = remember(app.pkg) { app.icon?.let { runCatching { it.toBitmap(96, 96).asImageBitmap() }.getOrNull() } }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxWidth().height(60.dp).clickable { onToggle(app.pkg) }.padding(start = 16.dp, end = 8.dp),
+ )
+ {
+ Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(c.tile), contentAlignment = Alignment.Center)
+ {
+ if (icon != null) Image(icon, contentDescription = null, modifier = Modifier.size(36.dp))
+ else Text(app.label.take(1).uppercase(), style = ShxType.chip, color = c.textSoft)
+ }
+ Text(app.label, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
+ Checkbox(app.pkg in chosen, { onToggle(app.pkg) }, colors = CheckboxDefaults.colors(checkedColor = c.accent))
+ }
+ if (i < apps.lastIndex)
+ {
+ Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
+ }
+ }
+ }
+}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt
index 6b5f42204..0c6bbd291 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt
@@ -306,28 +306,10 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true)
}
}
- if (page == SettingsPage.AUTOMATION)
+ if (page == SettingsPage.STABILITY)
{
ShxSection(title = stringResource(R.string.settings_section_automation))
{
- ShxSettingRow(
- icon = Icons.Rounded.Wifi,
- title = stringResource(R.string.auto_connect_title),
- subtitle = stringResource(R.string.auto_connect_summary),
- onClick = {
- context.startActivity(Intent(context, TrustedNetworksActivity::class.java))
- },
- )
- ShxSectionDivider()
- ShxSettingRow(
- icon = Icons.Rounded.NetworkCheck,
- title = stringResource(R.string.health_check_title),
- subtitle = stringResource(R.string.health_check_summary),
- onClick = {
- context.startActivity(Intent(context, ConnectivityCheckActivity::class.java))
- },
- )
- ShxSectionDivider()
var whiteListEnabled by remember {
mutableStateOf(WhiteListManager.getInstance(context).currentConfig.enabled)
}
@@ -774,6 +756,8 @@ private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateStat
val themeMode by ShxThemeSettings.themeMode.collectAsStateWithLifecycle()
var showKillSwitchDialog by remember { mutableStateOf(false) }
val certs = remember { runCatching { TrustedCertificateManager.getInstance().load().getAllCACertificates().size }.getOrDefault(0) }
+ val scenarioList by remember { org.strongswan.android.logic.scenarios.ScenarioEngine.flow(context) }.collectAsStateWithLifecycle()
+ val scenarioCount = scenarioList.count { it.enabled }
/* «2 from profiles · 1 expires soon» */
val certSummary by produceState(0 to 0) {
value = withContext(Dispatchers.IO) { runCatching { org.strongswan.android.ui.certificateSummary(context) }.getOrDefault(0 to 0) }
@@ -781,18 +765,22 @@ private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateStat
val groups = listOf(
stringResource(R.string.settings_section_automation) to listOf(
+ SettingsEntry(
+ stringResource(R.string.scenarios_title),
+ stringResource(R.string.scenarios_sub, scenarioCount + (if (trusted.enabled && trusted.parkOnTrusted) 1 else 0) + (if (trusted.enabled && trusted.connectOnUntrusted) 1 else 0)),
+ ShxIcons.Bolt,
+ ) { context.startActivity(Intent(context, org.strongswan.android.ui.ScenariosActivity::class.java)) },
SettingsEntry(
stringResource(R.string.settings_trusted_networks),
if (!trusted.enabled) stringResource(R.string.settings_off)
else trusted.trustedSsids.sorted().joinToString(", ").ifEmpty { stringResource(R.string.settings_trusted_none) },
ShxIcons.Wifi,
- ) { context.startActivity(Intent(context, TrustedNetworksActivity::class.java)) },
+ ) { context.startActivity(Intent(context, org.strongswan.android.ui.ScenariosActivity::class.java)) },
SettingsEntry(
stringResource(R.string.health_check_title),
if (health.enabled) stringResource(R.string.settings_health_sub, health.intervalSec) else stringResource(R.string.settings_off),
ShxIcons.Pulse,
) { context.startActivity(Intent(context, ConnectivityCheckActivity::class.java)) },
- SettingsEntry(stringResource(R.string.settings_section_automation), stringResource(R.string.settings_automation_sub), ShxIcons.Bolt) { onOpen(SettingsPage.AUTOMATION) },
),
stringResource(R.string.settings_protection) to listOf(
SettingsEntry(
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxIcons.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxIcons.kt
index e438e22e6..e64678460 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxIcons.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxIcons.kt
@@ -72,4 +72,5 @@ object ShxIcons
val Scan = line("scan", "M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3M4 12h16", 2f)
val Pin = line("pin", "M12 21s-7-6.1-7-11a7 7 0 0 1 14 0c0 4.9-7 11-7 11zM12 12.5a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5", 2f)
val Stats = line("stats", "M5 20V10M12 20V4M19 20v-7", 2.4f)
+ val Clock = line("clock", "M12 7v5l3 2M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18", 2.2f)
}
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 579dba3a7..564e6dc29 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
@@ -1231,4 +1231,53 @@
Оператор включил белые списки: российские сайты работают, зарубежные серверы — нет.
Вернулись на «%1$s»
Белые списки сняли.
+ Включить VPN
+ Выключить VPN
+ Пауза
+ Сменить сервер
+ Любая Wi-Fi сеть
+ Wi-Fi %1$s
+ Мобильная сеть
+ Открыт %1$s
+ или
+ Каждый день %1$s–%2$s
+ Роуминг
+ Оператор включил белые списки
+ Сценарий: %1$s
+ %1$s.
+ Сценарии
+ VPN сам включается, выключается или встаёт на паузу. Верхние сценарии важнее нижних.
+ В доверенной Wi-Fi сети
+ Мобильная сеть или чужой Wi-Fi
+ Доверенные сети
+ Удалить сеть
+ + Добавить текущую сеть
+ Нет подключения к Wi-Fi
+ + Сценарий
+ Активных: %1$d · сеть, приложения, время
+ Новый сценарий
+ Сценарий
+ 1 · КОГДА
+ 2 · ЧТО СДЕЛАТЬ
+ Wi-Fi сеть
+ Мобильная сеть
+ Открыто приложение
+ Время
+ Роуминг
+ Белые списки
+ Сети
+ Пусто — любая Wi-Fi
+ с
+ до
+ Сервер
+ Автоматически
+ Итог: %1$s → %2$s. После окончания — вернуть как было.
+ Сценариям с приложениями нужен доступ к статистике: Настройки → Стабильная работа → Статистика использования.
+ Поднять выше
+ + Выбрать
+ Выбрано %1$d
+ Поиск приложений
+ Часто открываете
+ Все приложения
+ Готово
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 78cda60ab..baf999a90 100644
--- a/src/frontends/android/app/src/main/res/values/strings.xml
+++ b/src/frontends/android/app/src/main/res/values/strings.xml
@@ -1217,4 +1217,53 @@
The operator turned on whitelists: Russian sites work, foreign servers do not.
Back to «%1$s»
The whitelists are off.
+ Turn VPN on
+ Turn VPN off
+ Pause
+ Change server
+ Any Wi-Fi network
+ Wi-Fi %1$s
+ Mobile network
+ %1$s is open
+ or
+ Every day %1$s–%2$s
+ Roaming
+ The operator turned on whitelists
+ Scenario: %1$s
+ %1$s.
+ Scenarios
+ The VPN turns itself on, off or pauses. Upper scenarios win over lower ones.
+ In a trusted Wi-Fi network
+ Mobile network or a foreign Wi-Fi
+ Trusted networks
+ Remove network
+ + Add the current network
+ Not connected to Wi-Fi
+ + Scenario
+ %1$d active · network, apps, time
+ New scenario
+ Scenario
+ 1 · WHEN
+ 2 · WHAT TO DO
+ Wi-Fi network
+ Mobile network
+ App is open
+ Time
+ Roaming
+ Whitelists
+ Networks
+ Empty — any Wi-Fi
+ from
+ to
+ Server
+ Automatically
+ Summary: %1$s → %2$s. When it ends — back to how it was.
+ App scenarios need usage access: Settings → Stable work → Usage access.
+ Move up
+ + Choose
+ Chosen %1$d
+ Search apps
+ Often opened
+ All apps
+ Done
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
new file mode 100644
index 000000000..560fdd765
--- /dev/null
+++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/scenarios/ScenariosTest.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2026 SHX VPN
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the
+ * Free Software Foundation; either version 2 of the License, or (at your
+ * option) any later version.
+ */
+
+package org.strongswan.android.logic.scenarios
+
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Test
+
+class ScenariosTest
+{
+ private val idle = ScenarioInputs(ssid = null, wifi = false, mobile = true, roaming = false, foreground = null, minute = 12 * 60, whitelist = false)
+
+ @Test
+ fun windowWrapsMidnight()
+ {
+ assertThat(inWindow(30, 60, 420)).isFalse()
+ assertThat(inWindow(60, 60, 420)).isTrue()
+ assertThat(inWindow(419, 60, 420)).isTrue()
+ assertThat(inWindow(420, 60, 420)).isFalse()
+ assertThat(inWindow(23 * 60, 22 * 60, 6 * 60)).isTrue()
+ assertThat(inWindow(5 * 60, 22 * 60, 6 * 60)).isTrue()
+ assertThat(inWindow(12 * 60, 22 * 60, 6 * 60)).isFalse()
+ }
+
+ @Test
+ fun topScenarioWins()
+ {
+ val app = Scenario(id = "a", trigger = ScenarioTrigger.APP, packages = setOf("org.telegram.messenger"), action = ScenarioAction.VPN_ON)
+ val mobile = Scenario(id = "m", trigger = ScenarioTrigger.MOBILE, action = ScenarioAction.VPN_OFF)
+ val now = idle.copy(foreground = "org.telegram.messenger")
+ assertThat(activeScenario(listOf(app, mobile), now)?.id).isEqualTo("a")
+ assertThat(activeScenario(listOf(mobile, app), now)?.id).isEqualTo("m")
+ }
+
+ @Test
+ fun disabledAndNonMatchingAreSkipped()
+ {
+ val wifi = Scenario(id = "w", trigger = ScenarioTrigger.WIFI, ssids = setOf("Home_5G"), action = ScenarioAction.VPN_OFF)
+ val off = Scenario(id = "r", enabled = false, trigger = ScenarioTrigger.MOBILE, action = ScenarioAction.VPN_ON)
+ assertThat(activeScenario(listOf(wifi, off), idle)).isNull()
+ assertThat(activeScenario(listOf(wifi), idle.copy(wifi = true, ssid = "home_5g"))?.id).isEqualTo("w")
+ assertThat(activeScenario(listOf(wifi), idle.copy(wifi = true, ssid = "Office"))).isNull()
+ }
+
+ @Test
+ fun mobileOnlyWithoutWifi()
+ {
+ val mobile = Scenario(id = "m", trigger = ScenarioTrigger.MOBILE, action = ScenarioAction.VPN_ON)
+ assertThat(activeScenario(listOf(mobile), idle.copy(wifi = true))).isNull()
+ assertThat(activeScenario(listOf(mobile), idle)?.id).isEqualTo("m")
+ }
+}