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:
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"
|
||||
|
||||
+2
@@ -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()
|
||||
|
||||
+12
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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 */
|
||||
|
||||
+500
@@ -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)
|
||||
}
|
||||
}
|
||||
+668
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-21
@@ -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(
|
||||
|
||||
+1
@@ -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>
|
||||
+58
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user