android: scenarios as in the Final 2026 mockup: network rules with own switches, user scenarios (Wi-Fi, mobile, app, time, roaming, whitelists) with an engine, app picker

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 15:47:53 +07:00
1 parent 5ca60d7066
commit 1a6fb85259
11 files changed
+1357 -26

No files matched your search

@@ -79,6 +79,9 @@
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity>
<activity
android:name=".ui.ScenariosActivity"
android:exported="false" />
<activity
android:name=".ui.ServerActivity"
android:exported="false" />
@@ -318,6 +321,10 @@
<receiver
android:name=".logic.WatchdogReceiver"
android:exported="false" />
<!-- edges of the time windows of the user scenarios -->
<receiver
android:name=".logic.scenarios.ScenarioReceiver"
android:exported="false" />
<!-- ends a "pause VPN for N minutes" and reconnects the paused profile -->
<receiver
android:name=".logic.PauseReceiver"
@@ -141,6 +141,8 @@ public class StrongSwanApplication extends Application
UpdateManager.getInstance(this).start();
/* daily re-read of the profiles that came from a link */
org.strongswan.android.logic.profiles.ProfileUpdater.schedule(this);
/* user scenarios of F_Automation */
org.strongswan.android.logic.scenarios.ScenarioEngine.start(this);
}
private void reloadManagedConfigurationAndNotifyListeners()
@@ -60,6 +60,10 @@ class AutoConnectManager private constructor(
val enabled: Boolean = false,
val profileUuid: String? = null,
val trustedSsids: Set<String> = 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
}
}
@@ -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 */
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
*
* 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<String> = emptySet(),
/** APP: the packages */
val packages: Set<String> = 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<Scenario>, 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<List<Scenario>>(emptyList())
val list: StateFlow<List<Scenario>> = _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<List<Scenario>>
{
load(context)
return list
}
fun save(context: Context, list: List<Scenario>)
{
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<String> = 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)
}
}
@@ -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<VpnProfile>
{
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<Scenario?>(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<VpnProfile>(), 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<ImageVector, Color, Color>
{
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<VpnProfile>,
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<VpnProfile?>(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<String>, 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<List<LaunchableApp>, Set<String>>
{
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<String>, onDone: (Set<String>) -> 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<Pair<List<LaunchableApp>, Set<String>>?>(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<LaunchableApp>() 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<LaunchableApp>, chosen: Set<String>, 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))
}
}
}
}
@@ -306,28 +306,10 @@ fun SettingsScreen(profiles: List<VpnProfile>, 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(
@@ -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)
}
@@ -1231,4 +1231,53 @@
<string name="event_bridge_on_text">Оператор включил белые списки: российские сайты работают, зарубежные серверы — нет.</string>
<string name="event_bridge_off_title">Вернулись на «%1$s»</string>
<string name="event_bridge_off_text">Белые списки сняли.</string>
<string name="scenario_do_on">Включить VPN</string>
<string name="scenario_do_off">Выключить VPN</string>
<string name="scenario_do_pause">Пауза</string>
<string name="scenario_do_switch">Сменить сервер</string>
<string name="scenario_when_any_wifi">Любая Wi-Fi сеть</string>
<string name="scenario_when_wifi">Wi-Fi %1$s</string>
<string name="scenario_when_mobile">Мобильная сеть</string>
<string name="scenario_when_app">Открыт %1$s</string>
<string name="scenario_or"> или </string>
<string name="scenario_when_time">Каждый день %1$s–%2$s</string>
<string name="scenario_when_roaming">Роуминг</string>
<string name="scenario_when_whitelist">Оператор включил белые списки</string>
<string name="event_scenario_title">Сценарий: %1$s</string>
<string name="event_scenario_text">%1$s.</string>
<string name="scenarios_title">Сценарии</string>
<string name="scenarios_intro">VPN сам включается, выключается или встаёт на паузу. Верхние сценарии важнее нижних.</string>
<string name="scenarios_rule_trusted">В доверенной Wi-Fi сети</string>
<string name="scenarios_rule_untrusted">Мобильная сеть или чужой Wi-Fi</string>
<string name="scenarios_trusted">Доверенные сети</string>
<string name="scenarios_remove_net">Удалить сеть</string>
<string name="scenarios_add_current">+ Добавить текущую сеть</string>
<string name="scenarios_no_wifi">Нет подключения к Wi-Fi</string>
<string name="scenarios_add">+ Сценарий</string>
<string name="scenarios_sub">Активных: %1$d · сеть, приложения, время</string>
<string name="scenario_new">Новый сценарий</string>
<string name="scenario_edit">Сценарий</string>
<string name="scenario_step_when">1 · КОГДА</string>
<string name="scenario_step_what">2 · ЧТО СДЕЛАТЬ</string>
<string name="scenario_t_wifi">Wi-Fi сеть</string>
<string name="scenario_t_mobile">Мобильная сеть</string>
<string name="scenario_t_app">Открыто приложение</string>
<string name="scenario_t_time">Время</string>
<string name="scenario_t_roaming">Роуминг</string>
<string name="scenario_t_whitelist">Белые списки</string>
<string name="scenario_ssids">Сети</string>
<string name="scenario_ssids_hint">Пусто — любая Wi-Fi</string>
<string name="scenario_from">с</string>
<string name="scenario_to">до</string>
<string name="scenario_server">Сервер</string>
<string name="scenario_server_auto">Автоматически</string>
<string name="scenario_summary">Итог: %1$s → %2$s. После окончания — вернуть как было.</string>
<string name="scenario_app_access">Сценариям с приложениями нужен доступ к статистике: Настройки → Стабильная работа → Статистика использования.</string>
<string name="scenario_raise">Поднять выше</string>
<string name="scenario_pick_apps">+ Выбрать</string>
<string name="scenario_chosen">Выбрано %1$d</string>
<string name="scenario_search_apps">Поиск приложений</string>
<string name="scenario_often">Часто открываете</string>
<string name="scenario_all_apps">Все приложения</string>
<string name="scenario_done">Готово</string>
</resources>
@@ -1217,4 +1217,53 @@
<string name="event_bridge_on_text">The operator turned on whitelists: Russian sites work, foreign servers do not.</string>
<string name="event_bridge_off_title">Back to «%1$s»</string>
<string name="event_bridge_off_text">The whitelists are off.</string>
<string name="scenario_do_on">Turn VPN on</string>
<string name="scenario_do_off">Turn VPN off</string>
<string name="scenario_do_pause">Pause</string>
<string name="scenario_do_switch">Change server</string>
<string name="scenario_when_any_wifi">Any Wi-Fi network</string>
<string name="scenario_when_wifi">Wi-Fi %1$s</string>
<string name="scenario_when_mobile">Mobile network</string>
<string name="scenario_when_app">%1$s is open</string>
<string name="scenario_or"> or </string>
<string name="scenario_when_time">Every day %1$s–%2$s</string>
<string name="scenario_when_roaming">Roaming</string>
<string name="scenario_when_whitelist">The operator turned on whitelists</string>
<string name="event_scenario_title">Scenario: %1$s</string>
<string name="event_scenario_text">%1$s.</string>
<string name="scenarios_title">Scenarios</string>
<string name="scenarios_intro">The VPN turns itself on, off or pauses. Upper scenarios win over lower ones.</string>
<string name="scenarios_rule_trusted">In a trusted Wi-Fi network</string>
<string name="scenarios_rule_untrusted">Mobile network or a foreign Wi-Fi</string>
<string name="scenarios_trusted">Trusted networks</string>
<string name="scenarios_remove_net">Remove network</string>
<string name="scenarios_add_current">+ Add the current network</string>
<string name="scenarios_no_wifi">Not connected to Wi-Fi</string>
<string name="scenarios_add">+ Scenario</string>
<string name="scenarios_sub">%1$d active · network, apps, time</string>
<string name="scenario_new">New scenario</string>
<string name="scenario_edit">Scenario</string>
<string name="scenario_step_when">1 · WHEN</string>
<string name="scenario_step_what">2 · WHAT TO DO</string>
<string name="scenario_t_wifi">Wi-Fi network</string>
<string name="scenario_t_mobile">Mobile network</string>
<string name="scenario_t_app">App is open</string>
<string name="scenario_t_time">Time</string>
<string name="scenario_t_roaming">Roaming</string>
<string name="scenario_t_whitelist">Whitelists</string>
<string name="scenario_ssids">Networks</string>
<string name="scenario_ssids_hint">Empty — any Wi-Fi</string>
<string name="scenario_from">from</string>
<string name="scenario_to">to</string>
<string name="scenario_server">Server</string>
<string name="scenario_server_auto">Automatically</string>
<string name="scenario_summary">Summary: %1$s → %2$s. When it ends — back to how it was.</string>
<string name="scenario_app_access">App scenarios need usage access: Settings → Stable work → Usage access.</string>
<string name="scenario_raise">Move up</string>
<string name="scenario_pick_apps">+ Choose</string>
<string name="scenario_chosen">Chosen %1$d</string>
<string name="scenario_search_apps">Search apps</string>
<string name="scenario_often">Often opened</string>
<string name="scenario_all_apps">All apps</string>
<string name="scenario_done">Done</string>
</resources>
@@ -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")
}
}