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