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" />
|
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".ui.ScenariosActivity"
|
||||||
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.ServerActivity"
|
android:name=".ui.ServerActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
@@ -318,6 +321,10 @@
|
|||||||
<receiver
|
<receiver
|
||||||
android:name=".logic.WatchdogReceiver"
|
android:name=".logic.WatchdogReceiver"
|
||||||
android:exported="false" />
|
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 -->
|
<!-- ends a "pause VPN for N minutes" and reconnects the paused profile -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".logic.PauseReceiver"
|
android:name=".logic.PauseReceiver"
|
||||||
|
|||||||
+2
@@ -141,6 +141,8 @@ public class StrongSwanApplication extends Application
|
|||||||
UpdateManager.getInstance(this).start();
|
UpdateManager.getInstance(this).start();
|
||||||
/* daily re-read of the profiles that came from a link */
|
/* daily re-read of the profiles that came from a link */
|
||||||
org.strongswan.android.logic.profiles.ProfileUpdater.schedule(this);
|
org.strongswan.android.logic.profiles.ProfileUpdater.schedule(this);
|
||||||
|
/* user scenarios of F_Automation */
|
||||||
|
org.strongswan.android.logic.scenarios.ScenarioEngine.start(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void reloadManagedConfigurationAndNotifyListeners()
|
private void reloadManagedConfigurationAndNotifyListeners()
|
||||||
|
|||||||
+12
-3
@@ -60,6 +60,10 @@ class AutoConnectManager private constructor(
|
|||||||
val enabled: Boolean = false,
|
val enabled: Boolean = false,
|
||||||
val profileUuid: String? = null,
|
val profileUuid: String? = null,
|
||||||
val trustedSsids: Set<String> = emptySet(),
|
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
|
enum class NetworkKind
|
||||||
@@ -226,6 +230,7 @@ class AutoConnectManager private constructor(
|
|||||||
}
|
}
|
||||||
currentNetwork = kind
|
currentNetwork = kind
|
||||||
evaluate()
|
evaluate()
|
||||||
|
org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun stateChanged()
|
override fun stateChanged()
|
||||||
@@ -383,6 +388,8 @@ class AutoConnectManager private constructor(
|
|||||||
arr.optString(i)?.takeIf { it.isNotBlank() }
|
arr.optString(i)?.takeIf { it.isNotBlank() }
|
||||||
}.toSet()
|
}.toSet()
|
||||||
} ?: emptySet(),
|
} ?: emptySet(),
|
||||||
|
parkOnTrusted = obj.optBoolean("parkOnTrusted", true),
|
||||||
|
connectOnUntrusted = obj.optBoolean("connectOnUntrusted", true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
catch (_: JSONException)
|
catch (_: JSONException)
|
||||||
@@ -397,6 +404,8 @@ class AutoConnectManager private constructor(
|
|||||||
obj.put("enabled", config.enabled)
|
obj.put("enabled", config.enabled)
|
||||||
obj.put("profileUuid", config.profileUuid ?: JSONObject.NULL)
|
obj.put("profileUuid", config.profileUuid ?: JSONObject.NULL)
|
||||||
obj.put("trustedSsids", JSONArray(config.trustedSsids.sorted()))
|
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()
|
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,13 +460,13 @@ class AutoConnectManager private constructor(
|
|||||||
{
|
{
|
||||||
null -> Action.IGNORE
|
null -> Action.IGNORE
|
||||||
NetworkKind.TRUSTED_WIFI ->
|
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
|
/* SSID is redacted in background until location is readable. Keep an
|
||||||
* existing tunnel (do not treat unknown as untrusted) and still
|
* existing tunnel (do not treat unknown as untrusted) and still
|
||||||
* auto-start if the VPN is down. */
|
* 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 ->
|
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)
|
if (before.status != WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.ACTIVE)
|
||||||
{
|
{
|
||||||
Events.whitelist(context, true)
|
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)
|
else if (before.status == WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.INACTIVE)
|
||||||
{
|
{
|
||||||
Events.whitelist(context, false)
|
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)
|
if (machine.status == WhiteListStatus.ACTIVE)
|
||||||
{ /* keep watching for the whitelist to clear, at a low rate */
|
{ /* 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))
|
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 {
|
var whiteListEnabled by remember {
|
||||||
mutableStateOf(WhiteListManager.getInstance(context).currentConfig.enabled)
|
mutableStateOf(WhiteListManager.getInstance(context).currentConfig.enabled)
|
||||||
}
|
}
|
||||||
@@ -774,6 +756,8 @@ private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateStat
|
|||||||
val themeMode by ShxThemeSettings.themeMode.collectAsStateWithLifecycle()
|
val themeMode by ShxThemeSettings.themeMode.collectAsStateWithLifecycle()
|
||||||
var showKillSwitchDialog by remember { mutableStateOf(false) }
|
var showKillSwitchDialog by remember { mutableStateOf(false) }
|
||||||
val certs = remember { runCatching { TrustedCertificateManager.getInstance().load().getAllCACertificates().size }.getOrDefault(0) }
|
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» */
|
/* «2 from profiles · 1 expires soon» */
|
||||||
val certSummary by produceState(0 to 0) {
|
val certSummary by produceState(0 to 0) {
|
||||||
value = withContext(Dispatchers.IO) { runCatching { org.strongswan.android.ui.certificateSummary(context) }.getOrDefault(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(
|
val groups = listOf(
|
||||||
stringResource(R.string.settings_section_automation) to 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(
|
SettingsEntry(
|
||||||
stringResource(R.string.settings_trusted_networks),
|
stringResource(R.string.settings_trusted_networks),
|
||||||
if (!trusted.enabled) stringResource(R.string.settings_off)
|
if (!trusted.enabled) stringResource(R.string.settings_off)
|
||||||
else trusted.trustedSsids.sorted().joinToString(", ").ifEmpty { stringResource(R.string.settings_trusted_none) },
|
else trusted.trustedSsids.sorted().joinToString(", ").ifEmpty { stringResource(R.string.settings_trusted_none) },
|
||||||
ShxIcons.Wifi,
|
ShxIcons.Wifi,
|
||||||
) { context.startActivity(Intent(context, TrustedNetworksActivity::class.java)) },
|
) { context.startActivity(Intent(context, org.strongswan.android.ui.ScenariosActivity::class.java)) },
|
||||||
SettingsEntry(
|
SettingsEntry(
|
||||||
stringResource(R.string.health_check_title),
|
stringResource(R.string.health_check_title),
|
||||||
if (health.enabled) stringResource(R.string.settings_health_sub, health.intervalSec) else stringResource(R.string.settings_off),
|
if (health.enabled) stringResource(R.string.settings_health_sub, health.intervalSec) else stringResource(R.string.settings_off),
|
||||||
ShxIcons.Pulse,
|
ShxIcons.Pulse,
|
||||||
) { context.startActivity(Intent(context, ConnectivityCheckActivity::class.java)) },
|
) { 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(
|
stringResource(R.string.settings_protection) to listOf(
|
||||||
SettingsEntry(
|
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 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 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 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_on_text">Оператор включил белые списки: российские сайты работают, зарубежные серверы — нет.</string>
|
||||||
<string name="event_bridge_off_title">Вернулись на «%1$s»</string>
|
<string name="event_bridge_off_title">Вернулись на «%1$s»</string>
|
||||||
<string name="event_bridge_off_text">Белые списки сняли.</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>
|
</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_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_title">Back to «%1$s»</string>
|
||||||
<string name="event_bridge_off_text">The whitelists are off.</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>
|
</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