diff --git a/src/frontends/android/app/src/main/AndroidManifest.xml b/src/frontends/android/app/src/main/AndroidManifest.xml
index 367097696..2d1804056 100644
--- a/src/frontends/android/app/src/main/AndroidManifest.xml
+++ b/src/frontends/android/app/src/main/AndroidManifest.xml
@@ -30,6 +30,10 @@
+
+
@@ -133,6 +137,10 @@
android:name=".ui.InboxActivity"
android:label="@string/events_title">
+
+
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java
index e199e2e83..251c0aceb 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java
@@ -1170,6 +1170,29 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
RuIpListManager list = RuIpListManager.getInstance(context);
List v4 = enabled ? list.getExcludedV4() : Collections.emptyList();
List v6 = enabled ? list.getExcludedV6() : Collections.emptyList();
+ if (enabled)
+ { /* user rules: addresses and resolved sites that always go direct */
+ List direct = org.strongswan.android.logic.routing.RoutingManager.getInstance(context).directCidrs();
+ if (!direct.isEmpty())
+ {
+ ArrayList d4 = new ArrayList<>(v4);
+ ArrayList d6 = new ArrayList<>(v6);
+ for (String cidr : direct)
+ {
+ try
+ {
+ IPRange range = new IPRange(cidr);
+ (range.getFrom() instanceof Inet4Address ? d4 : d6).add(range);
+ }
+ catch (Exception ignored)
+ { /* stored prefixes are validated; skip anything stale */
+ }
+ }
+ /* the engine expects sorted, non-overlapping ranges */
+ v4 = RuIpListManager.Companion.mergeRanges(d4);
+ v6 = RuIpListManager.Companion.mergeRanges(d6);
+ }
+ }
List force = new ArrayList<>();
if (enabled)
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt
index 471b06c7f..905ba4463 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt
@@ -40,6 +40,8 @@ object PauseManager
private const val PREF_UNTIL = "pref_pause_until"
private const val PREF_PROFILE = "pref_pause_profile"
private const val REQUEST_CODE = 45
+ /* safety net of "until the network changes" */
+ private const val NETWORK_PAUSE_MAX_MS = 12L * 60 * 60 * 1000
/* wall-clock end of the running pause, 0 if none; read by the Home screen */
private val _until = MutableStateFlow(0L)
@@ -74,11 +76,108 @@ object PauseManager
/** Disconnects [service] and schedules the reconnect of [profileUuid] in [minutes]. */
@JvmStatic
fun pause(context: Context, service: VpnStateService, profileUuid: String, minutes: Int)
+ {
+ pauseUntil(context, service, profileUuid, System.currentTimeMillis() + minutes * 60_000L)
+ }
+
+ /** Disconnects [service] until the wall-clock time [until]. */
+ @JvmStatic
+ fun pauseUntil(context: Context, service: VpnStateService, profileUuid: String, until: Long)
{
val ctx = context.applicationContext
+ unwatchNetwork(ctx)
service.disconnect()
- store(ctx, System.currentTimeMillis() + minutes * 60_000L, profileUuid)
- DiagLog.i(TAG, "VPN paused for $minutes min")
+ store(ctx, until, profileUuid)
+ DiagLog.i(TAG, "VPN paused until $until")
+ }
+
+ /**
+ * Disconnects until the device moves to another network (e.g. leaves this
+ * Wi-Fi). A [NETWORK_PAUSE_MAX_MS] alarm is the safety net in case the
+ * process dies meanwhile and the change is never seen.
+ */
+ @JvmStatic
+ fun pauseUntilNetworkChange(context: Context, service: VpnStateService, profileUuid: String)
+ {
+ val ctx = context.applicationContext
+ pauseUntil(ctx, service, profileUuid, System.currentTimeMillis() + NETWORK_PAUSE_MAX_MS)
+ watchNetwork(ctx)
+ }
+
+ /** Next 07:00 after [now], local time. */
+ @JvmStatic
+ fun nextMorning(now: Long): Long
+ {
+ val c = java.util.Calendar.getInstance()
+ c.timeInMillis = now
+ c.set(java.util.Calendar.HOUR_OF_DAY, 7)
+ c.set(java.util.Calendar.MINUTE, 0)
+ c.set(java.util.Calendar.SECOND, 0)
+ c.set(java.util.Calendar.MILLISECOND, 0)
+ if (c.timeInMillis <= now)
+ {
+ c.add(java.util.Calendar.DAY_OF_YEAR, 1)
+ }
+ return c.timeInMillis
+ }
+
+ private var networkCallback: android.net.ConnectivityManager.NetworkCallback? = null
+
+ private fun watchNetwork(ctx: Context)
+ {
+ val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager ?: return
+ var first: android.net.Network? = null
+ val callback = object : android.net.ConnectivityManager.NetworkCallback()
+ {
+ override fun onAvailable(network: android.net.Network)
+ {
+ /* right after the disconnect the default network may still be the
+ * tunnel itself: only real uplinks count */
+ val caps = try
+ {
+ cm.getNetworkCapabilities(network)
+ }
+ catch (_: Exception)
+ {
+ null
+ }
+ if (caps == null || caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN))
+ {
+ return
+ }
+ /* the first uplink reported is the current one; any other one is a change */
+ if (first == null)
+ {
+ first = network
+ }
+ else if (network != first)
+ {
+ DiagLog.i(TAG, "network changed, ending the pause")
+ android.os.Handler(android.os.Looper.getMainLooper()).post { resumeNow(ctx) }
+ }
+ }
+ }
+ try
+ {
+ cm.registerDefaultNetworkCallback(callback)
+ networkCallback = callback
+ }
+ catch (_: RuntimeException)
+ { /* too many callbacks: the safety alarm still ends the pause */
+ }
+ }
+
+ private fun unwatchNetwork(ctx: Context)
+ {
+ val callback = networkCallback ?: return
+ networkCallback = null
+ try
+ {
+ (ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager)?.unregisterNetworkCallback(callback)
+ }
+ catch (_: IllegalArgumentException)
+ {
+ }
}
/** Moves the end of the running pause by [minutes]. */
@@ -122,6 +221,7 @@ object PauseManager
return
}
_until.value = 0L
+ unwatchNetwork(ctx)
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
.remove(PREF_UNTIL).remove(PREF_PROFILE).apply()
alarmManager(ctx).cancel(alarmIntent(ctx))
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt
index 6587511a6..905136d85 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt
@@ -64,6 +64,8 @@ enum class EventAction
OPEN_ROUTE_CHECK,
OPEN_LOG,
INSTALL_UPDATE,
+ /** let the app in [ShxEvent.target] bypass the VPN */
+ SET_APP_DIRECT,
}
data class ShxEvent(
@@ -81,6 +83,8 @@ data class ShxEvent(
val read: Boolean = false,
/** the problem went away by itself; shown dimmed, no longer "waiting" */
val resolved: Boolean = false,
+ /** what the action applies to, e.g. a package name */
+ val target: String? = null,
)
{
/** still needs the user's attention */
@@ -112,6 +116,7 @@ class EventLog private constructor(private val context: Context)
text: String,
action: EventAction = EventAction.NONE,
key: String? = null,
+ target: String? = null,
)
{
val now = System.currentTimeMillis()
@@ -123,7 +128,7 @@ class EventLog private constructor(private val context: Context)
val next = if (merged >= 0)
{
val old = list[merged]
- val bumped = old.copy(at = now, title = title, text = text, action = action, count = old.count + 1, read = false)
+ val bumped = old.copy(at = now, title = title, text = text, action = action, target = target, count = old.count + 1, read = false)
posted = bumped
listOf(bumped) + list.filterIndexed { i, _ -> i != merged }
}
@@ -131,7 +136,7 @@ class EventLog private constructor(private val context: Context)
{
val event = ShxEvent(
id = now * 1000 + (list.size % 1000), at = now, kind = kind, category = category,
- title = title, text = text, action = action, key = key,
+ title = title, text = text, action = action, key = key, target = target,
)
posted = event
listOf(event) + list
@@ -143,14 +148,21 @@ class EventLog private constructor(private val context: Context)
}
/** Records once per [key] for the whole retention time (e.g. one update notice per version). */
- fun recordOnce(kind: EventKind, category: EventCategory, title: String, text: String, action: EventAction, key: String)
+ fun recordOnce(kind: EventKind, category: EventCategory, title: String, text: String, action: EventAction, key: String, target: String? = null)
{
if (_events.value.none { it.key == key })
{
- record(kind, category, title, text, action, key)
+ record(kind, category, title, text, action, key, target)
}
}
+ /** The user answered an event (did the action or said no): it no longer waits. */
+ fun answer(id: Long)
+ {
+ _events.update { list -> list.map { if (it.id == id) it.copy(resolved = true, read = true) else it } }
+ persist()
+ }
+
/** Marks the unresolved events whose key starts with [keyPrefix] as resolved. */
fun resolve(keyPrefix: String)
{
@@ -319,6 +331,7 @@ class EventLog private constructor(private val context: Context)
.put("id", e.id).put("at", e.at).put("kind", e.kind.name).put("category", e.category.name)
.put("title", e.title).put("text", e.text).put("action", e.action.name)
.put("key", e.key ?: JSONObject.NULL).put("count", e.count).put("read", e.read).put("resolved", e.resolved)
+ .put("target", e.target ?: JSONObject.NULL)
internal fun fromJson(o: JSONObject?): ShxEvent?
{
@@ -337,6 +350,7 @@ class EventLog private constructor(private val context: Context)
count = o.optInt("count", 1),
read = o.optBoolean("read"),
resolved = o.optBoolean("resolved"),
+ target = if (o.isNull("target")) null else o.optString("target"),
)
}
catch (_: Exception)
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt
index c691c4447..b323fc17e 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt
@@ -115,6 +115,16 @@ object Events
)
}
+ /** A newly installed app looks like a Russian service that is not direct yet. */
+ @JvmStatic
+ fun newRuApp(context: Context, packageName: String, label: String)
+ {
+ EventLog.getInstance(context).recordOnce(
+ EventKind.ASK, EventCategory.ROUTING, context.getString(R.string.event_new_app_title, label),
+ context.getString(R.string.event_new_app_text), EventAction.SET_APP_DIRECT, "new-app:$packageName", packageName,
+ )
+ }
+
@JvmStatic
fun pauseOver(context: Context)
{
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/AppTraffic.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/AppTraffic.kt
new file mode 100644
index 000000000..a29b6e170
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/AppTraffic.kt
@@ -0,0 +1,114 @@
+/*
+ * 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.routing
+
+import android.app.AppOpsManager
+import android.app.usage.NetworkStats
+import android.app.usage.NetworkStatsManager
+import android.content.Context
+import android.net.ConnectivityManager
+import android.os.Build
+import android.os.Process
+import android.util.Log
+
+/**
+ * Per-app network traffic from the system statistics ("Usage access"), for
+ * the «Recent apps» list. Android attributes tunneled traffic back to the
+ * app that produced it, so the totals cover both the VPN and direct paths.
+ * Only byte counts per app are read - never destinations or content.
+ */
+object AppTraffic
+{
+ private const val TAG = "AppTraffic"
+ /* the first uid of regular apps; lower ones are system services */
+ private const val FIRST_APP_UID = 10000
+
+ fun hasAccess(context: Context): Boolean
+ {
+ val ops = context.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager ?: return false
+ val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
+ {
+ ops.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
+ }
+ else
+ {
+ @Suppress("DEPRECATION")
+ ops.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
+ }
+ return mode == AppOpsManager.MODE_ALLOWED
+ }
+
+ /**
+ * Bytes per package since [sinceMs] (wall clock), Wi-Fi and mobile
+ * together; empty without usage access. The system keeps the counters
+ * in buckets of up to two hours, so the window is approximate.
+ */
+ fun since(context: Context, sinceMs: Long): Map
+ {
+ if (!hasAccess(context))
+ {
+ return emptyMap()
+ }
+ val manager = context.getSystemService(Context.NETWORK_STATS_SERVICE) as? NetworkStatsManager ?: return emptyMap()
+ val now = System.currentTimeMillis()
+ val perUid = HashMap()
+ @Suppress("DEPRECATION")
+ for (type in intArrayOf(ConnectivityManager.TYPE_WIFI, ConnectivityManager.TYPE_MOBILE))
+ {
+ try
+ {
+ manager.querySummary(type, null, sinceMs, now).use { stats -> sum(stats, perUid) }
+ }
+ catch (e: Exception)
+ { /* no such network history or no access */
+ Log.d(TAG, "querySummary($type) failed: $e")
+ }
+ }
+ val pm = context.packageManager
+ val result = HashMap()
+ for ((uid, bytes) in perUid)
+ {
+ if (uid < FIRST_APP_UID || bytes <= 0)
+ {
+ continue
+ }
+ val pkg = pm.getPackagesForUid(uid)?.firstOrNull() ?: continue
+ if (pkg != context.packageName)
+ {
+ result[pkg] = (result[pkg] ?: 0L) + bytes
+ }
+ }
+ return result
+ }
+
+ private fun sum(stats: NetworkStats, into: HashMap)
+ {
+ val bucket = NetworkStats.Bucket()
+ while (stats.hasNextBucket())
+ {
+ stats.getNextBucket(bucket)
+ into[bucket.uid] = (into[bucket.uid] ?: 0L) + bucket.rxBytes + bucket.txBytes
+ }
+ }
+
+ private inline fun NetworkStats.use(block: (NetworkStats) -> R): R = try
+ {
+ block(this)
+ }
+ finally
+ {
+ close()
+ }
+}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RouteCheck.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RouteCheck.kt
index 9aa1aa42d..c728d83f3 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RouteCheck.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RouteCheck.kt
@@ -102,6 +102,63 @@ object RouteCheck
)
}
+ /** Why an address takes its route, in the order the engine decides. */
+ enum class Reason
+ {
+ /** a resolver of the tunnel or strict private DNS: always through the tunnel */
+ DNS,
+ /** a user rule (address or site) sends it through the VPN */
+ RULE_TUNNEL,
+ /** a user rule (address or site) sends it direct */
+ RULE_DIRECT,
+ /** it is in the RU address base */
+ RU_LIST,
+ /** nothing matched: the default route is the tunnel */
+ DEFAULT,
+ }
+
+ data class Verdict(val direct: Boolean, val reason: Reason)
+
+ /**
+ * Full verdict for an address: like [classify], plus the user rules,
+ * which the engine applies when [rulesActive] (userspace routing):
+ * "through the VPN" wins over "direct", both win over the RU base.
+ */
+ @JvmStatic
+ fun verdict(
+ address: InetAddress,
+ exclusionsV4: List,
+ exclusionsV6: List,
+ dnsPunchBlocks: Set,
+ forceRanges: List,
+ directRanges: List,
+ rulesActive: Boolean,
+ ): Verdict
+ {
+ if (insidePunchBlock(dnsPunchBlocks, address))
+ {
+ return Verdict(direct = false, reason = Reason.DNS)
+ }
+ if (rulesActive && containsAddress(forceRanges, address))
+ {
+ return Verdict(direct = false, reason = Reason.RULE_TUNNEL)
+ }
+ if (rulesActive && containsAddress(directRanges, address))
+ {
+ return Verdict(direct = true, reason = Reason.RULE_DIRECT)
+ }
+ val exclusions = if (address is java.net.Inet4Address) exclusionsV4 else exclusionsV6
+ if (containsAddress(exclusions, address))
+ {
+ return Verdict(direct = true, reason = Reason.RU_LIST)
+ }
+ return Verdict(direct = false, reason = Reason.DEFAULT)
+ }
+
+ /** Parses CIDR strings, skipping invalid ones. */
+ @JvmStatic
+ fun ranges(cidrs: List): List = cidrs.mapNotNull { runCatching { IPRange(it) }.getOrNull() }
+
/** Route verdict for one address under the applied exclusion set. */
@JvmStatic
fun classify(
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt
index 7b8945711..897d1c7a4 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt
@@ -29,6 +29,9 @@ import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -80,10 +83,29 @@ data class RoutingConfig(
val enabledCategories: Set = emptySet(),
/** User package patterns, see [PackagePatterns]. */
val packagePatterns: Set = emptySet(),
+ /** Patterns the user switched off without deleting them. */
+ val disabledPatterns: Set = emptySet(),
val forceTunnelPrefixes: List = emptyList(),
+ /** User addresses that always go direct (same shape as the force-tunnel entries). */
+ val directPrefixes: List = emptyList(),
+ /** Per-site rules; the engine routes by address, so sites are resolved, see [SiteRule]. */
+ val siteRules: List = emptyList(),
val version: Int = RoutingManager.CONFIG_VERSION,
)
+/**
+ * "This site always goes direct / through the VPN". The userspace router
+ * decides per destination address, so the rule applies to the addresses the
+ * domain (and its www. name) resolves to; they are refreshed when the
+ * tunnel comes up and every [RoutingManager.SITE_REFRESH_MS] while it is up.
+ */
+data class SiteRule(
+ val id: String,
+ val domain: String,
+ val direct: Boolean,
+ val enabled: Boolean = true,
+)
+
/** Why a package is (not) direct, for the per-app reason line. */
data class TemplateMatch(
/** Built-in category id (may be [RoutingPresets.NAMESPACE]) or null. */
@@ -132,11 +154,32 @@ class RoutingManager private constructor(private val context: Context)
private val _state = MutableStateFlow(RoutingState())
val state: StateFlow = _state
+ /* resolved addresses of the site rules, domain -> CIDRs; read by the push on any thread */
+ @Volatile
+ private var siteAddresses: Map> = emptyMap()
+ private val _siteAddresses = MutableStateFlow