diff --git a/src/frontends/android/app/build.gradle b/src/frontends/android/app/build.gradle
index 4e2d58ecd..8300dbd21 100644
--- a/src/frontends/android/app/build.gradle
+++ b/src/frontends/android/app/build.gradle
@@ -37,8 +37,8 @@ android {
minSdkVersion 21
targetSdkVersion 36
- versionCode 147
- versionName "2.13.10"
+ versionCode 150
+ versionName "2.13.13"
/* Base URL of the update storage, trailing slash included */
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
diff --git a/src/frontends/android/app/src/main/AndroidManifest.xml b/src/frontends/android/app/src/main/AndroidManifest.xml
index e2647dd60..4c4f1405f 100644
--- a/src/frontends/android/app/src/main/AndroidManifest.xml
+++ b/src/frontends/android/app/src/main/AndroidManifest.xml
@@ -127,6 +127,10 @@
android:name=".ui.ConnectivityCheckActivity"
android:label="@string/health_check_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 4569c2f59..75b0ec597 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
@@ -77,6 +77,7 @@ import java.io.IOException;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
+import java.net.InetSocketAddress;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedByInterruptException;
@@ -127,12 +128,22 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
private static volatile List sPrivateDnsServers = Collections.emptyList();
private static volatile String sPrivateDnsHost;
private static volatile long sPrivateDnsResolvedAt;
+ /* last-known-good resolve of the private DNS host: a transient resolve
+ * failure must not silently drop the DoT addresses from the force-tunnel
+ * set (they are stable for a given hostname) */
+ private static volatile List sPrivateDnsLastGood;
+ private static volatile String sPrivateDnsLastGoodHost;
+ private static volatile long sPrivateDnsLastGoodAt;
+ private static final long PRIVATE_DNS_LAST_GOOD_TTL_MS = 24 * 60 * 60 * 1000;
private static volatile List sTunnelDnsServers = Collections.emptyList();
/* whether the native engine currently holds an enabled classification
* set (skips pointless disable-pushes) */
private static volatile boolean sUserspacePushActive;
+ /** Number of user-defined force-tunnel prefixes in the last push (diagnostics). */
+ private static volatile int sUserForcePrefixCount;
+
/** Snapshot of the geo exclusion set currently applied to the TUN (route check UI). */
public static volatile List sAppliedGeoExclusionsV4 = Collections.emptyList();
public static volatile List sAppliedGeoExclusionsV6 = Collections.emptyList();
@@ -315,8 +326,23 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
if (result.isEmpty())
{
- DiagLog.i(TAG, "private DNS resolve timed out for " + host);
+ DiagLog.i(TAG, "private DNS resolve failed for " + host);
+ /* a transient failure must not empty the force-tunnel
+ * set: fall back to the last known good addresses */
+ long now = SystemClock.elapsedRealtime();
+ List lastGood = sPrivateDnsLastGood;
+ if (lastGood != null && host.equals(sPrivateDnsLastGoodHost)
+ && now - sPrivateDnsLastGoodAt < PRIVATE_DNS_LAST_GOOD_TTL_MS)
+ {
+ DiagLog.i(TAG, "private DNS: using cached addresses for " + host
+ + " (age " + ((now - sPrivateDnsLastGoodAt) / 1000) + "s)");
+ return new ArrayList<>(lastGood);
+ }
+ return result;
}
+ sPrivateDnsLastGood = new ArrayList<>(result);
+ sPrivateDnsLastGoodHost = host;
+ sPrivateDnsLastGoodAt = SystemClock.elapsedRealtime();
DiagLog.i(TAG, "private DNS " + host + " -> " + result);
return result;
}
@@ -1034,15 +1060,35 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
List v4 = enabled ? list.getExcludedV4() : Collections.emptyList();
List v6 = enabled ? list.getExcludedV6() : Collections.emptyList();
- List force = new ArrayList<>();
+ List force = new ArrayList<>();
if (enabled)
{
/* DoT servers of strict private DNS and the tunnel's own
* resolvers must never go direct, even when they are in RU
* space (One UI/strict private DNS, see 2.12.12) */
- force.addAll(sPrivateDnsServers);
- force.addAll(sTunnelDnsServers);
+ for (InetAddress addr : sPrivateDnsServers)
+ {
+ force.add(new IPRange(addr, addr));
+ }
+ for (InetAddress addr : sTunnelDnsServers)
+ {
+ force.add(new IPRange(addr, addr));
+ }
+ /* user-defined prefixes that must always go through the
+ * tunnel (e.g. a self-hosted proxy on a RU address) */
+ for (String cidr : org.strongswan.android.logic.routing.RoutingManager.getInstance(context).forceTunnelCidrs())
+ {
+ try
+ {
+ force.add(new IPRange(cidr));
+ }
+ catch (Exception ignored)
+ { /* stored prefixes are validated; skip anything stale */
+ }
+ }
}
+ sUserForcePrefixCount = enabled
+ ? force.size() - sPrivateDnsServers.size() - sTunnelDnsServers.size() : 0;
long[] v4From = new long[v4.size()];
long[] v4To = new long[v4.size()];
@@ -1058,26 +1104,32 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
System.arraycopy(v6.get(i).getFrom().getAddress(), 0, v6From, i * 16, 16);
System.arraycopy(v6.get(i).getTo().getAddress(), 0, v6To, i * 16, 16);
}
- ArrayList ftV4 = new ArrayList<>();
- ByteArrayOutputStream ftV6 = new ByteArrayOutputStream();
- for (InetAddress addr : force)
+ ArrayList forceV4FromList = new ArrayList<>();
+ ArrayList forceV4ToList = new ArrayList<>();
+ ByteArrayOutputStream forceV6From = new ByteArrayOutputStream();
+ ByteArrayOutputStream forceV6To = new ByteArrayOutputStream();
+ for (IPRange range : force)
{
- if (addr instanceof Inet4Address)
+ if (range.getFrom() instanceof Inet4Address)
{
- ftV4.add(addressToLong(addr));
+ forceV4FromList.add(addressToLong(range.getFrom()));
+ forceV4ToList.add(addressToLong(range.getTo()));
}
- else if (addr instanceof Inet6Address)
+ else if (range.getFrom() instanceof Inet6Address)
{
- ftV6.write(addr.getAddress(), 0, 16);
+ forceV6From.write(range.getFrom().getAddress(), 0, 16);
+ forceV6To.write(range.getTo().getAddress(), 0, 16);
}
}
- long[] forceV4 = new long[ftV4.size()];
- for (int i = 0; i < ftV4.size(); i++)
+ long[] forceV4From = new long[forceV4FromList.size()];
+ long[] forceV4To = new long[forceV4ToList.size()];
+ for (int i = 0; i < forceV4FromList.size(); i++)
{
- forceV4[i] = ftV4.get(i);
+ forceV4From[i] = forceV4FromList.get(i);
+ forceV4To[i] = forceV4ToList.get(i);
}
setUserspaceRoutingData(enabled, v4From, v4To, v6From, v6To,
- forceV4, ftV6.toByteArray());
+ forceV4From, forceV4To, forceV6From.toByteArray(), forceV6To.toByteArray());
sUserspacePushActive = enabled;
}
catch (Throwable e)
@@ -1119,9 +1171,15 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
DiagLog.i(TAG, "route diag failed: " + e);
}
});
- /* periodic while the connection is up */
- mHandler.removeCallbacks(this);
- mHandler.postDelayed(this, ROUTE_DIAG_INTERVAL_MS);
+ /* periodic re-runs only in extended diagnostics mode: the snapshot
+ * right after connecting covers field debugging, while a 5-minute
+ * cadence burns file I/O and battery for nothing in steady state */
+ SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(CharonVpnService.this);
+ if (pref.getBoolean(Constants.PREF_EXTENDED_DIAG, false))
+ {
+ mHandler.removeCallbacks(this);
+ mHandler.postDelayed(this, ROUTE_DIAG_INTERVAL_MS);
+ }
}
};
@@ -1137,7 +1195,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
DiagLog.i(TAG, "route diag: rusplit=" + rusplit + ", mode=" + mode
+ ", applied=" + (rusplit ? mode + (isUserspaceRoutingEnabled(this) ? " (active)" : "")
+ ", ranges=" + sAppliedGeoExclusionsV4.size() + "v4/" + sAppliedGeoExclusionsV6.size() + "v6"
- + ", forceTunnel=" + (sPrivateDnsServers.size() + sTunnelDnsServers.size())
+ + ", forceTunnel=" + (sPrivateDnsServers.size() + sTunnelDnsServers.size() + sUserForcePrefixCount)
: "none"));
long[] s = null;
@@ -1149,7 +1207,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
DiagLog.i(TAG, "route diag: native engine unavailable (" + e.getClass().getSimpleName() + ")");
}
- if (s == null || s.length < 19)
+ if (s == null || s.length < 20)
{
return;
}
@@ -1160,7 +1218,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
DiagLog.i(TAG, "route diag: udp=" + s[4] + " pkts (" + s[12] + " flows), tcp=" + s[6] + " pkts ("
+ s[13] + " flows, " + s[14] + " accepts)");
DiagLog.i(TAG, "route diag: drops icmp/frag/malformed=" + s[8] + "/" + s[9] + "/" + s[10]
- + ", relayErrors=" + s[11]);
+ + ", relayErrors=" + s[11] + ", cooldownHits=" + s[19]);
}
private void scheduleRouteDiagnostics()
@@ -1344,6 +1402,53 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
return true;
}
+ /**
+ * Called via JNI when a direct-relay destination enters its failure
+ * cooldown: names the app that owns the connection being refused, so a
+ * retry storm against a dead server is attributable in diag.log. Best
+ * effort - on any failure nothing is logged.
+ *
+ * @param localAddr app-side source address of its socket
+ * @param localPort app-side source port
+ * @param remoteAddr destination address
+ * @param remotePort destination port
+ * @param dst preformatted "ip:port" for the log line
+ */
+ public void logConnectionOwner(byte[] localAddr, int localPort,
+ byte[] remoteAddr, int remotePort, String dst)
+ {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
+ {
+ return;
+ }
+ try
+ {
+ ConnectivityManager manager = (ConnectivityManager)getSystemService(Context.CONNECTIVITY_SERVICE);
+ if (manager == null)
+ {
+ return;
+ }
+ InetAddress localInet = InetAddress.getByAddress(localAddr);
+ InetAddress remoteInet = InetAddress.getByAddress(remoteAddr);
+ InetSocketAddress local = new InetSocketAddress(localInet, localPort);
+ InetSocketAddress remote = new InetSocketAddress(remoteInet, remotePort);
+ int protocol = localInet instanceof Inet6Address
+ ? android.system.OsConstants.AF_INET6 : android.system.OsConstants.AF_INET;
+ int uid = manager.getConnectionOwnerUid(protocol, local, remote);
+ if (uid < 0)
+ { /* the socket is already gone - nothing to attribute */
+ return;
+ }
+ String[] packages = getPackageManager().getPackagesForUid(uid);
+ String owner = packages != null && packages.length > 0 ? packages[0] : "uid " + uid;
+ DiagLog.i(TAG, "direct tcp: dst=" + dst + " owner=" + owner);
+ }
+ catch (Exception e)
+ {
+ DiagLog.i(TAG, "direct tcp: owner lookup failed for dst=" + dst + ": " + e);
+ }
+ }
+
/**
* Initialization of charon, provided by libandroidbridge.so
*
@@ -1384,12 +1489,15 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
* @param v4To IPv4 range ends
* @param v6From IPv6 range starts (16 bytes per entry, concatenated)
* @param v6To IPv6 range ends
- * @param forceV4 force-tunnel IPv4 addresses (DoT / tunnel DNS)
- * @param forceV6 force-tunnel IPv6 addresses
+ * @param forceV4From force-tunnel IPv4 range starts (DoT / tunnel DNS / user prefixes)
+ * @param forceV4To force-tunnel IPv4 range ends
+ * @param forceV6From force-tunnel IPv6 range starts
+ * @param forceV6To force-tunnel IPv6 range ends
*/
public static native void setUserspaceRoutingData(boolean enabled, long[] v4From, long[] v4To,
byte[] v6From, byte[] v6To,
- long[] forceV4, byte[] forceV6);
+ long[] forceV4From, long[] forceV4To,
+ byte[] forceV6From, byte[] forceV6To);
/**
* Snapshot of the userspace routing engine counters, provided by
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/DiagLog.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/DiagLog.kt
index a34f3112c..badf750de 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/DiagLog.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/DiagLog.kt
@@ -28,9 +28,11 @@ import java.util.Locale
* watchdog decisions, retry scheduling, network changes, auto-connect and
* foreground-service start failures.
*
- * Every line is flushed and closed on write so events survive a process kill
- * at any point. The file rotates at [MAX_SIZE] (one previous generation is
- * kept). Never throws - logging must not be able to break the caller.
+ * The writer is kept open across writes but flushed on every line, so
+ * events survive a process kill at any point while avoiding a per-line
+ * open/stat/close cycle. The file rotates at [MAX_SIZE] (one previous
+ * generation is kept), tracked by a written-byte counter instead of a stat
+ * per line. Never throws - logging must not be able to break the caller.
*/
object DiagLog
{
@@ -41,6 +43,9 @@ object DiagLog
@Volatile
private var dir: File? = null
+ private var writer: FileWriter? = null
+ private var written = 0L
+
private val format = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)
/** Must be called once at process start (from the Application class). */
@@ -57,17 +62,24 @@ object DiagLog
val base = dir ?: return
try
{
+ val line = "${format.format(Date())} $tag: $message\n"
synchronized(this)
{
- val file = File(base, FILE_NAME)
- if (file.length() > MAX_SIZE)
+ if (written > MAX_SIZE)
{
- File(base, OLD_FILE_NAME).delete()
- file.renameTo(File(base, OLD_FILE_NAME))
+ rotate(base)
}
- FileWriter(file, true).use { writer ->
- writer.write("${format.format(Date())} $tag: $message\n")
+ var w = writer
+ if (w == null)
+ {
+ val file = File(base, FILE_NAME)
+ written = file.length()
+ w = FileWriter(file, true)
+ writer = w
}
+ w.write(line)
+ w.flush()
+ written += line.length + 1
}
}
catch (_: Exception)
@@ -75,6 +87,30 @@ object DiagLog
}
}
+ /** Close the writer and move diag.log to diag.log.1; reopened lazily. */
+ private fun rotate(base: File)
+ {
+ writer?.let {
+ try
+ {
+ it.close()
+ }
+ catch (_: Exception)
+ {
+ }
+ }
+ writer = null
+ try
+ {
+ File(base, OLD_FILE_NAME).delete()
+ File(base, FILE_NAME).renameTo(File(base, OLD_FILE_NAME))
+ }
+ catch (_: Exception)
+ {
+ }
+ written = 0
+ }
+
/** The log file (may not exist yet). */
@JvmStatic
fun file(context: Context): File = File(context.getFilesDir(), FILE_NAME)
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/WatchdogReceiver.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/WatchdogReceiver.java
index fc9a7a97c..fe118f4e3 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/WatchdogReceiver.java
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/WatchdogReceiver.java
@@ -44,11 +44,14 @@ import org.strongswan.android.utils.Constants;
* never wakes the device; it fires only while the device is awake anyway
* (Doze defers it until then). A second WAKEUP safety-net alarm bounds the
* recovery time while the device sleeps (e.g. the process was evicted on a
- * trusted Wi-Fi overnight). The checks themselves are a couple of memory
- * and preference reads - no network probes, no wake locks, no periodic work
- * while the connection is healthy. A crash is therefore repaired within
- * seconds of the next time the device is used, and within minutes even if
- * it never is.
+ * trusted Wi-Fi overnight). Once a state proves steady - the tunnel parked
+ * on a trusted network, or a healthy established connection - both alarms
+ * back off (5 min maintenance, 30 min/1 h safety net), because in those
+ * states a live process recovers via its own callbacks within seconds and
+ * the alarms only guard a dead one. The checks themselves are a couple of
+ * memory and preference reads - no network probes, no wake locks, no
+ * periodic work while the connection is healthy. A crash is therefore
+ * repaired within seconds of the next time the device is used.
*
* Trusted-networks auto sessions: while parked on a trusted Wi-Fi the VPN is
* expected to be DOWN (the auto session is "expected" in the sense that the
@@ -67,6 +70,31 @@ public class WatchdogReceiver extends BroadcastReceiver
/* wakeup safety net; allow-while-idle alarms are throttled to a 9-minute
* minimum interval in Doze, so 10 minutes is effectively exact enough */
private static final long WATCHDOG_WAKEUP_INTERVAL_MS = 600000;
+ /* after this many consecutive checks that find the tunnel parked on a
+ * trusted network, stretch the maintenance interval: while parked there
+ * is nothing to do (auto-connect owns the return via its callbacks) and
+ * the 60 s cadence only burns battery and floods diag.log overnight.
+ * The wakeup safety net is stretched to 30 minutes as well: a live
+ * process reacts to network changes within seconds, so it only guards
+ * the rare "process died, then the user left the trusted Wi-Fi" corner.
+ * Static on purpose: a parked tunnel implies a live process, so the
+ * counter survives; a dead process starts fresh with the short interval. */
+ private static final int PARKED_BACKOFF_AFTER = 5;
+ private static final long WATCHDOG_PARKED_INTERVAL_MS = 5 * 60000;
+ private static final long WATCHDOG_PARKED_WAKEUP_INTERVAL_MS = 30 * 60000;
+ /* steady state of an established connection: charon's own DPD/rekey and
+ * the in-process retry machinery own recovery, so the alarms only guard
+ * a dead process - repaired within seconds of the device being used.
+ * The wakeup alarm in Doze is stretched to an hour (from 10 minutes),
+ * cutting the overnight CPU wakeups while the tunnel is healthy. */
+ private static final int CONNECTED_BACKOFF_AFTER = 5;
+ private static final long WATCHDOG_CONNECTED_INTERVAL_MS = 5 * 60000;
+ private static final long WATCHDOG_CONNECTED_WAKEUP_INTERVAL_MS = 60 * 60000;
+ private static volatile int sParkedChecks;
+ private static volatile int sConnectedChecks;
+ /** cadence announced in diag.log last ("parked"/"connected"/null), so a
+ * steady state is logged once on entry instead of on every fire */
+ private static volatile String sLastCadence;
/** What onReceive decided to do. */
public enum Action
@@ -135,7 +163,7 @@ public class WatchdogReceiver extends BroadcastReceiver
* schedule() re-arms the alarms when that changes */
return;
}
- schedule(context);
+ reschedule(context);
if (WhiteListManager.getInstance(context).shouldBlockReconnect())
{ /* the ISP whitelist blocks the server; the in-process retry
* machinery parks itself in that case, so should we */
@@ -149,9 +177,42 @@ public class WatchdogReceiver extends BroadcastReceiver
boolean autoProfileConfigured = AutoConnectManager.getInstance(context).getCurrentConfig().getProfileUuid() != null;
Action action = decideAction(enabled, uuid != null, autoExpected, false, serviceTracking,
processAlive, wifiPresent, autoProfileConfigured);
- DiagLog.i(TAG, "check: uuid=" + (uuid != null) + " autoExpected=" + autoExpected +
- " processAlive=" + processAlive + " tracking=" + serviceTracking +
- " wifi=" + wifiPresent + " -> " + action);
+ boolean connected = serviceTracking && service.getState() == VpnStateService.State.CONNECTED;
+ boolean parked = uuid == null && autoExpected && processAlive && !serviceTracking && action == Action.NONE;
+ if (parked)
+ {
+ sParkedChecks++;
+ sConnectedChecks = 0;
+ }
+ else if (connected && action == Action.NONE)
+ {
+ sConnectedChecks++;
+ sParkedChecks = 0;
+ }
+ else
+ { /* any other outcome is interesting - go back to the fast cadence */
+ sParkedChecks = 0;
+ sConnectedChecks = 0;
+ }
+ boolean parkedSteady = parked && sParkedChecks >= PARKED_BACKOFF_AFTER;
+ boolean connectedSteady = connected && sConnectedChecks >= CONNECTED_BACKOFF_AFTER;
+ String cadence = parkedSteady ? "parked" : connectedSteady ? "connected" : null;
+ if (cadence != null)
+ { /* steady state: log the transition once, then stay silent - a
+ * per-fire line only floods diag.log and burns flash I/O */
+ if (!cadence.equals(sLastCadence))
+ {
+ DiagLog.i(TAG, "steady state (" + cadence + "), backing off checks to " +
+ (parkedSteady ? "5m/30m" : "5m/60m"));
+ }
+ }
+ else
+ {
+ DiagLog.i(TAG, "check: uuid=" + (uuid != null) + " autoExpected=" + autoExpected +
+ " processAlive=" + processAlive + " tracking=" + serviceTracking +
+ " wifi=" + wifiPresent + " -> " + action);
+ }
+ sLastCadence = cadence;
switch (action)
{
case NONE:
@@ -243,12 +304,27 @@ public class WatchdogReceiver extends BroadcastReceiver
}
/**
- * Arm the next maintenance check. A one-shot non-wakeup alarm (fires only
+ * Arm the next maintenance check after a state transition (connect,
+ * park, settings toggle). Resets the steady-state counters so the
+ * checks return to the fast cadence until the new state proves itself
+ * stable again, then delegates to reschedule().
+ */
+ public static void schedule(Context context)
+ {
+ sParkedChecks = 0;
+ sConnectedChecks = 0;
+ sLastCadence = null;
+ reschedule(context);
+ }
+
+ /**
+ * Arm the next maintenance check with the cadence the steady-state
+ * counters currently ask for. A one-shot non-wakeup alarm (fires only
* while the device is awake) plus a one-shot wakeup safety net that
* bounds the recovery time while the device sleeps. No-op when the
* feature is disabled or no connection is expected.
*/
- public static void schedule(Context context)
+ private static void reschedule(Context context)
{
context = context.getApplicationContext();
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
@@ -263,8 +339,14 @@ public class WatchdogReceiver extends BroadcastReceiver
{
return;
}
- arm(am, pendingIntent(context, REQUEST_CODE), SystemClock.elapsedRealtime() + WATCHDOG_INTERVAL_MS, false);
- arm(am, pendingIntent(context, WAKEUP_REQUEST_CODE), SystemClock.elapsedRealtime() + WATCHDOG_WAKEUP_INTERVAL_MS, true);
+ boolean parkedBackoff = sParkedChecks >= PARKED_BACKOFF_AFTER;
+ boolean connectedBackoff = sConnectedChecks >= CONNECTED_BACKOFF_AFTER;
+ long interval = parkedBackoff ? WATCHDOG_PARKED_INTERVAL_MS
+ : connectedBackoff ? WATCHDOG_CONNECTED_INTERVAL_MS : WATCHDOG_INTERVAL_MS;
+ long wakeupInterval = parkedBackoff ? WATCHDOG_PARKED_WAKEUP_INTERVAL_MS
+ : connectedBackoff ? WATCHDOG_CONNECTED_WAKEUP_INTERVAL_MS : WATCHDOG_WAKEUP_INTERVAL_MS;
+ arm(am, pendingIntent(context, REQUEST_CODE), SystemClock.elapsedRealtime() + interval, false);
+ arm(am, pendingIntent(context, WAKEUP_REQUEST_CODE), SystemClock.elapsedRealtime() + wakeupInterval, true);
}
/**
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt
index 12c8458be..0da4091c0 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt
@@ -24,6 +24,7 @@ import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
+import android.os.SystemClock
import android.util.Log
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
@@ -84,6 +85,8 @@ class AutoConnectManager private constructor(
private var currentNetwork: NetworkKind? = null
/* marks the session this manager started itself */
private val autoSession = AtomicBoolean(false)
+ /* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
+ private var lastParkAt = 0L
private val monitor = NetworkMonitor(context, { config.trustedSsids }, ::onNetworkChanged)
private val lifecycleBound = AtomicBoolean(false)
@@ -261,6 +264,7 @@ class AutoConnectManager private constructor(
Log.i(TAG, "Trusted network detected, disconnecting")
DiagLog.i(TAG, "trusted network detected, parking the tunnel")
autoSession.set(false)
+ lastParkAt = SystemClock.elapsedRealtime()
/* mark the auto session as expected so the watchdog can restore
* it if the process dies while parked and the device later
* moves to cellular (auto-connect dies with the process) */
@@ -281,6 +285,19 @@ class AutoConnectManager private constructor(
{
return
}
+ /* Wi-Fi at the edge of range flaps with a ~10-15 s period: a park
+ * followed by a quick "untrusted" flip must not bounce the tunnel
+ * through full reconnects. Right after a park require a longer
+ * stable-cellular window before starting (the runnable re-evaluates
+ * and quietly gives up if the trusted Wi-Fi returned in between). */
+ val delay = if (SystemClock.elapsedRealtime() - lastParkAt < RECENT_PARK_WINDOW_MS)
+ {
+ FLAP_CONNECT_DELAY_MS
+ }
+ else
+ {
+ CONNECT_DELAY_MS
+ }
handler.postDelayed({
pendingEvaluate.set(false)
val svc = service
@@ -296,7 +313,7 @@ class AutoConnectManager private constructor(
startVpn(svc, uuid)
}
}
- }, CONNECT_DELAY_MS)
+ }, delay)
}
private fun startVpn(svc: VpnStateService, uuid: String)
@@ -394,6 +411,10 @@ class AutoConnectManager private constructor(
private const val PREFS_NAME = "one.shx.strongswan.ext.AUTO_CONNECT"
private const val KEY_CONFIG = "config"
private const val CONNECT_DELAY_MS = 3000L
+ /* anti-flap: a start right after a trusted-Wi-Fi park waits longer
+ * (the network must stay cellular through the whole window) */
+ private const val FLAP_CONNECT_DELAY_MS = 10_000L
+ private const val RECENT_PARK_WINDOW_MS = 120_000L
/**
* Pure decision function, unit-testable. Trusted Wi-Fi parks only
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/NetworkMonitor.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/NetworkMonitor.kt
index 61d34e699..f3608a112 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/NetworkMonitor.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/NetworkMonitor.kt
@@ -74,12 +74,12 @@ class NetworkMonitor(
{
networks.remove(network)
ssids.remove(network)
+ scheduleEvaluate()
}
- else
+ else if (remember(network, capabilities))
{
- remember(network, capabilities)
+ scheduleEvaluate()
}
- scheduleEvaluate()
}
}
@@ -190,12 +190,39 @@ class NetworkMonitor(
return null
}
- private fun remember(network: Network, caps: NetworkCapabilities)
+ /**
+ * Store the latest state of a network. Returns whether anything the
+ * trust decision depends on changed: capability callbacks fire on every
+ * signal-strength/validation tick, and re-evaluating on those only burns
+ * battery - the debounced runnable wakes the main thread and rescans all
+ * networks for no result. Only a transport change or a new SSID counts.
+ */
+ private fun remember(network: Network, caps: NetworkCapabilities): Boolean
{
+ val previous = networks[network]
+ val oldSsid = ssids[network]
+ val ssid = SsidReader.fromWifiInfo(caps.transportInfo as? WifiInfo)
networks[network] = caps
- SsidReader.fromWifiInfo(caps.transportInfo as? WifiInfo)?.let { ssids[network] = it }
+ if (ssid != null)
+ {
+ ssids[network] = ssid
+ }
+ if (previous == null)
+ {
+ return true
+ }
+ if (!sameTransports(previous, caps))
+ {
+ return true
+ }
+ return ssid != null && ssid != oldSsid
}
+ private fun sameTransports(a: NetworkCapabilities, b: NetworkCapabilities): Boolean =
+ a.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == b.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
+ a.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == b.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
+ a.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == b.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
+
private fun ssidFor(network: Network, caps: NetworkCapabilities): String?
{
ssids[network]?.let { return it }
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 28a13d4a3..800f45d4b 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
@@ -50,9 +50,18 @@ data class RuleSet(
val keywords: Set = emptySet(),
)
+/** A network prefix that must always go through the tunnel (userspace mode). */
+data class ForceTunnelPrefix(
+ val id: String,
+ /** Validated CIDR or single address, e.g. "130.49.213.176/32". */
+ val prefix: String,
+ val enabled: Boolean = true,
+)
+
data class RoutingConfig(
val enabled: Boolean = true,
val ruleSets: List = emptyList(),
+ val forceTunnelPrefixes: List = emptyList(),
)
data class RoutingState(
@@ -161,9 +170,12 @@ class RoutingManager private constructor(private val context: Context)
updateConfig { config -> config.copy(ruleSets = config.ruleSets + ruleSet) }
}
+ /** A rename never affects routing, so it must not schedule a reconnect. */
fun updateRuleSetName(id: String, name: String)
{
- updateConfig { config -> config.copy(ruleSets = config.ruleSets.map { if (it.id == id) it.copy(name = name) else it }) }
+ config = config.copy(ruleSets = config.ruleSets.map { if (it.id == id) it.copy(name = name) else it })
+ saveConfig(config)
+ publish()
}
fun setRuleSetEnabled(id: String, enabled: Boolean)
@@ -224,6 +236,79 @@ class RoutingManager private constructor(private val context: Context)
}
}
+ /* --------------------- force-tunnel prefixes --------------------- */
+
+ /**
+ * Adds a force-tunnel prefix (CIDR or bare address). Returns null on an
+ * invalid prefix, otherwise the new entry's id.
+ */
+ fun addForcePrefix(cidr: String): String?
+ {
+ val normalized = normalizePrefix(cidr) ?: return null
+ updateConfigNoReconnect { config ->
+ config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes + ForceTunnelPrefix(
+ id = UUID.randomUUID().toString(), prefix = normalized))
+ }
+ return config.forceTunnelPrefixes.lastOrNull { it.prefix == normalized }?.id
+ }
+
+ /** Replaces the prefix of an existing entry; false if the new one is invalid. */
+ fun updateForcePrefix(id: String, cidr: String): Boolean
+ {
+ val normalized = normalizePrefix(cidr) ?: return false
+ updateConfigNoReconnect { config ->
+ config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes.map {
+ if (it.id == id) it.copy(prefix = normalized) else it })
+ }
+ return true
+ }
+
+ fun deleteForcePrefix(id: String)
+ {
+ updateConfigNoReconnect { config ->
+ config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes.filter { it.id != id })
+ }
+ }
+
+ fun setForcePrefixEnabled(id: String, enabled: Boolean)
+ {
+ updateConfigNoReconnect { config ->
+ config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes.map {
+ if (it.id == id) it.copy(enabled = enabled) else it })
+ }
+ }
+
+ /**
+ * Enabled force-tunnel prefixes as validated CIDR strings (userspace
+ * routing feeds them into the native force set).
+ */
+ fun forceTunnelCidrs(): List =
+ config.forceTunnelPrefixes.filter { it.enabled }.map { it.prefix }
+
+ /**
+ * Like [updateConfig], but force prefixes apply on the fly through the
+ * atomic set swap of the userspace router - no reconnect needed.
+ */
+ private fun updateConfigNoReconnect(transform: (RoutingConfig) -> RoutingConfig)
+ {
+ config = transform(config)
+ saveConfig(config)
+ publish()
+ pushUserspaceRouting()
+ }
+
+ private fun pushUserspaceRouting()
+ {
+ try
+ {
+ org.strongswan.android.logic.CharonVpnService.pushUserspaceRoutingData(context)
+ }
+ catch (e: Throwable)
+ {
+ Log.w(TAG, "Failed to push userspace routing data", e)
+ }
+ }
+
/* ------------------------- connect-time merge ------------------------- */
/**
@@ -315,6 +400,17 @@ class RoutingManager private constructor(private val context: Context)
}
}
} ?: emptyList(),
+ forceTunnelPrefixes = obj.optJSONArray("forceTunnelPrefixes")?.let { arr ->
+ (0 until arr.length()).mapNotNull { i ->
+ arr.optJSONObject(i)?.let { o ->
+ ForceTunnelPrefix(
+ id = o.optString("id"),
+ prefix = o.optString("prefix"),
+ enabled = o.optBoolean("enabled", true),
+ )
+ }
+ }
+ } ?: emptyList(),
)
}
catch (_: JSONException)
@@ -340,6 +436,16 @@ class RoutingManager private constructor(private val context: Context)
sets.put(o)
}
obj.put("ruleSets", sets)
+ val forcePrefixes = JSONArray()
+ for (entry in config.forceTunnelPrefixes)
+ {
+ val o = JSONObject()
+ o.put("id", entry.id)
+ o.put("prefix", entry.prefix)
+ o.put("enabled", entry.enabled)
+ forcePrefixes.put(o)
+ }
+ obj.put("forceTunnelPrefixes", forcePrefixes)
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
}
@@ -353,6 +459,16 @@ class RoutingManager private constructor(private val context: Context)
private const val KEY_CONFIG = "config"
private const val RECONNECT_DEBOUNCE_MS = 2000L
+ /** Validates and canonicalizes a CIDR/address; null if invalid. */
+ fun normalizePrefix(cidr: String): String? = try
+ {
+ org.strongswan.android.utils.IPRange(cidr.trim()).toString()
+ }
+ catch (_: Exception)
+ {
+ null
+ }
+
/**
* All packages matched by the enabled rule sets: explicit packages,
* prefixes and keywords matched against the installed packages.
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateManager.kt
index 49132d5a2..d82518e57 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateManager.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateManager.kt
@@ -174,11 +174,15 @@ class UpdateManager private constructor(private val context: Context)
{
cleanup()
context.bindService(Intent(context, VpnStateService::class.java), mConnection, Context.BIND_AUTO_CREATE)
- val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES)
- .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
+ val request = PeriodicWorkRequestBuilder(12, TimeUnit.HOURS)
+ .setConstraints(Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ /* background checks only run while charging - zero battery impact */
+ .setRequiresCharging(true)
+ .build())
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
- UpdateCheckWorker.WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
+ UpdateCheckWorker.WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
mScope.launch {
while (true)
{
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/RouteCheckActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/RouteCheckActivity.kt
new file mode 100644
index 000000000..c53af2863
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/RouteCheckActivity.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.ui
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import org.strongswan.android.R
+import org.strongswan.android.ui.compose.RouteCheckScreen
+import org.strongswan.android.ui.compose.SecondaryScaffold
+import org.strongswan.android.ui.compose.theme.ShxTheme
+
+/** Standalone route diagnostics screen, opened from the Routing tab. */
+class RouteCheckActivity : AppCompatActivity()
+{
+ override fun onCreate(savedInstanceState: Bundle?)
+ {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ ShxTheme {
+ SecondaryScaffold(title = stringResource(R.string.route_check_title), onBack = { finish() }) { padding ->
+ Box(Modifier.padding(padding)) {
+ RouteCheckScreen()
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/PresetPickerSheet.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/PresetPickerSheet.kt
new file mode 100644
index 000000000..b5b01cf4c
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/PresetPickerSheet.kt
@@ -0,0 +1,178 @@
+/*
+ * 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.compose
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.rounded.Search
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import org.strongswan.android.R
+import org.strongswan.android.logic.routing.RuleSet
+import org.strongswan.android.logic.routing.RoutingPresets
+
+/**
+ * Searchable list of the built-in presets shown inside the rule set editor
+ * sheet. Selection toggles immediately; "Done"/back returns to the editor.
+ */
+@Composable
+fun PresetPickerContent(
+ ruleSet: RuleSet,
+ onToggle: (RoutingPresets.Preset) -> Unit,
+ onBack: () -> Unit,
+)
+{
+ var query by remember { mutableStateOf("") }
+ val filtered = remember(query)
+ {
+ val q = query.trim()
+ if (q.isEmpty())
+ {
+ RoutingPresets.RF_BUNDLE
+ }
+ else
+ {
+ RoutingPresets.RF_BUNDLE.filter { it.name.contains(q, ignoreCase = true) }
+ }
+ }
+
+ Column(Modifier.fillMaxWidth())
+ {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 4.dp),
+ )
+ {
+ IconButton(onClick = onBack)
+ {
+ Icon(
+ Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = stringResource(R.string.action_back),
+ )
+ }
+ Text(
+ text = stringResource(R.string.routing_presets_label),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(onClick = onBack)
+ {
+ Text(stringResource(R.string.action_done))
+ }
+ }
+
+ OutlinedTextField(
+ value = query,
+ onValueChange = { query = it },
+ label = { Text(stringResource(R.string.routing_presets_search)) },
+ singleLine = true,
+ leadingIcon = {
+ Icon(Icons.Rounded.Search, contentDescription = null)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp),
+ shape = MaterialTheme.shapes.medium,
+ )
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 460.dp)
+ .padding(top = 4.dp),
+ )
+ {
+ items(filtered, key = { it.name })
+ { preset ->
+ val selected = ruleSet.prefixes.containsAll(preset.prefixes) &&
+ ruleSet.keywords.containsAll(preset.keywords)
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onToggle(preset) }
+ .padding(horizontal = 24.dp, vertical = 6.dp),
+ )
+ {
+ Column(
+ Modifier
+ .weight(1f)
+ .padding(vertical = 6.dp),
+ )
+ {
+ Text(preset.name, style = MaterialTheme.typography.bodyLarge)
+ Text(
+ preset.patternSummary(),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Checkbox(checked = selected, onCheckedChange = { onToggle(preset) })
+ }
+ }
+ if (filtered.isEmpty())
+ {
+ item {
+ Text(
+ text = stringResource(R.string.routing_presets_none),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 16.dp),
+ )
+ }
+ }
+ }
+ }
+}
+
+/** "3 prefixes · 2 keywords" summary, skipping empty groups. */
+@Composable
+private fun RoutingPresets.Preset.patternSummary(): String
+{
+ val parts = listOf(
+ if (prefixes.isEmpty()) null else stringResource(R.string.routing_preset_prefixes, prefixes.size),
+ if (keywords.isEmpty()) null else stringResource(R.string.routing_preset_keywords, keywords.size),
+ ).filterNotNull()
+ return if (parts.isEmpty())
+ {
+ stringResource(R.string.routing_preset_no_patterns)
+ }
+ else
+ {
+ parts.joinToString(separator = " · ")
+ }
+}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RouteCheckScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RouteCheckScreen.kt
new file mode 100644
index 000000000..965b45ed5
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RouteCheckScreen.kt
@@ -0,0 +1,160 @@
+/*
+ * 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.compose
+
+import androidx.compose.foundation.layout.Column
+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.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import org.strongswan.android.R
+import org.strongswan.android.logic.CharonVpnService
+import org.strongswan.android.logic.routing.RouteCheck
+import org.strongswan.android.ui.compose.theme.ScreenPadding
+
+/** Diagnostic: how a domain resolves and which route its addresses take. */
+@Composable
+fun RouteCheckScreen()
+{
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ var domain by remember { mutableStateOf("") }
+ var checking by remember { mutableStateOf(false) }
+ var report by remember { mutableStateOf(null) }
+
+ Column(
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = ScreenPadding, vertical = 12.dp),
+ )
+ {
+ Text(
+ text = stringResource(R.string.route_check_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedTextField(
+ value = domain,
+ onValueChange = { domain = it },
+ label = { Text(stringResource(R.string.route_check_field)) },
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 10.dp),
+ shape = MaterialTheme.shapes.medium,
+ )
+ Button(
+ onClick = {
+ checking = true
+ scope.launch {
+ report = RouteCheck.check(
+ context,
+ domain.trim(),
+ CharonVpnService.sAppliedGeoExclusionsV4,
+ CharonVpnService.sAppliedGeoExclusionsV6,
+ CharonVpnService.sDnsPunchIps,
+ )
+ checking = false
+ }
+ },
+ enabled = domain.isNotBlank() && !checking,
+ shape = MaterialTheme.shapes.medium,
+ modifier = Modifier.padding(top = 10.dp),
+ )
+ {
+ Text(stringResource(if (checking) R.string.route_check_checking else R.string.route_check_button))
+ }
+ report?.let { rep -> RouteCheckResult(rep) }
+ }
+}
+
+@Composable
+private fun RouteCheckResult(report: RouteCheck.Report)
+{
+ Column(modifier = Modifier.padding(top = 12.dp))
+ {
+ if (!report.vpnNetworkFound)
+ {
+ Text(
+ text = stringResource(R.string.route_check_no_vpn),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ RouteCheckRow(stringResource(R.string.route_check_via_vpn_dns), report.vpnDnsIps)
+ RouteCheckRow(stringResource(R.string.route_check_via_direct_dns), report.directDnsIps)
+ Text(
+ text = stringResource(R.string.route_check_routes),
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.padding(top = 10.dp),
+ )
+ for (route in report.routes)
+ {
+ val label = stringResource(
+ when (route.kind)
+ {
+ RouteCheck.RouteKind.TUNNEL -> R.string.route_verdict_tunnel
+ RouteCheck.RouteKind.DIRECT_RU -> R.string.route_verdict_direct
+ RouteCheck.RouteKind.TUNNEL_DNS -> R.string.route_verdict_dns
+ },
+ )
+ Text(
+ text = "${route.ip} — $label",
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ modifier = Modifier.padding(top = 2.dp),
+ )
+ }
+ if (report.routes.isEmpty())
+ {
+ Text(
+ text = stringResource(R.string.route_check_resolve_failed),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+}
+
+@Composable
+private fun RouteCheckRow(label: String, ips: List)
+{
+ Text(
+ text = label,
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ Text(
+ text = ips.joinToString(", ").ifEmpty { stringResource(R.string.route_check_none) },
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ )
+}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt
index ef2ef4278..6e785ecfc 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt
@@ -23,44 +23,43 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.RowScope
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.Block
-import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
+import androidx.compose.material.icons.rounded.Lan
import androidx.compose.material.icons.rounded.Refresh
-import androidx.compose.material3.AssistChip
-import androidx.compose.material3.Button
+import androidx.compose.material.icons.rounded.Route
+import androidx.compose.material.icons.rounded.Rule
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@@ -70,24 +69,22 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -96,18 +93,19 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
+import org.strongswan.android.logic.routing.ForceTunnelPrefix
import org.strongswan.android.logic.routing.RuleSet
-import org.strongswan.android.logic.routing.RouteCheck
import org.strongswan.android.logic.routing.RoutingManager
-import org.strongswan.android.logic.routing.RoutingPresets
import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.logic.routing.RuIpListState
import org.strongswan.android.logic.routing.RuIpListStatus
-import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.utils.Constants
+import org.strongswan.android.ui.RouteCheckActivity
import org.strongswan.android.ui.SelectedApplicationsActivity
-import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxEmptyState
+import org.strongswan.android.ui.compose.components.ShxIconBadge
+import org.strongswan.android.ui.compose.components.ShxSection
+import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ScreenPadding
@@ -134,9 +132,11 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
val state by manager.state.collectAsStateWithLifecycle()
val ruState by ruList.state.collectAsStateWithLifecycle()
val config = state.config
- var expandedId by remember { mutableStateOf(null) }
- var focusNameId by remember { mutableStateOf(null) }
+ var editingId by rememberSaveable { mutableStateOf(null) }
+ var focusName by rememberSaveable { mutableStateOf(false) }
var pickTarget by remember { mutableStateOf(null) }
+ /* force-prefix dialog: null = hidden, "" = add, otherwise the entry id */
+ var forceDialogId by remember { mutableStateOf(null) }
val scope = rememberCoroutineScope()
val pickApps = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
@@ -165,14 +165,16 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
}
}
+ fun addNewSet()
+ {
+ editingId = manager.addRuleSet()
+ focusName = true
+ }
+
fun deleteSetWithUndo(ruleSet: RuleSet)
{
scope.launch {
manager.deleteRuleSet(ruleSet.id)
- if (expandedId == ruleSet.id)
- {
- expandedId = null
- }
val result = snackbarHostState.showSnackbar(
message = context.getString(R.string.routing_delete_snackbar),
actionLabel = context.getString(R.string.action_undo),
@@ -185,6 +187,22 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
}
}
+ val refreshGeoip = {
+ scope.launch {
+ val ok = ruList.updateNow()
+ val message = if (ok)
+ {
+ val total = ruList.state.value.let { it.ipv4Ranges + it.ipv6Ranges }
+ context.getString(R.string.rusplit_updated, total)
+ }
+ else
+ {
+ context.getString(R.string.rusplit_update_failed)
+ }
+ snackbarHostState.showSnackbar(message)
+ }
+ }
+
Column(
Modifier
.fillMaxSize()
@@ -194,7 +212,16 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
{
TopAppBar(
title = { Text(stringResource(R.string.routing_title), fontWeight = FontWeight.SemiBold) },
- colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
+ actions = {
+ IconButton(onClick = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) })
+ {
+ Icon(
+ Icons.Rounded.Route,
+ contentDescription = stringResource(R.string.route_check_title),
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
@@ -208,49 +235,20 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
modifier = Modifier.padding(horizontal = ScreenPadding),
)
- /* ---- geoip RU-direct card with info chips ---- */
- val refreshGeoip = {
- scope.launch {
- val ok = ruList.updateNow()
- val message = if (ok)
- {
- val total = ruList.state.value.let { it.ipv4Ranges + it.ipv6Ranges }
- context.getString(R.string.rusplit_updated, total)
- }
- else
- {
- context.getString(R.string.rusplit_update_failed)
- }
- snackbarHostState.showSnackbar(message)
- }
- }
- RoutingCard(modifier = Modifier.padding(top = 12.dp, start = ScreenPadding, end = ScreenPadding))
+ /* ---- RU networks direct: geoip list + routing mode ---- */
+ ShxSection(title = stringResource(R.string.routing_section_ru))
{
- RoutingToggleRow(
- icon = Icons.Rounded.Block,
- title = stringResource(R.string.rusplit_toggle_title), subtitle = stringResource(R.string.rusplit_toggle_summary),
+ RuToggleRow(
+ title = stringResource(R.string.rusplit_toggle_title),
+ subtitle = stringResource(R.string.rusplit_toggle_summary),
checked = ruState.enabled,
+ updating = ruState.status == RuIpListStatus.UPDATING,
onCheckedChange = { ruList.setEnabled(it) },
- actions = {
- if (ruState.status == RuIpListStatus.UPDATING)
- {
- CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp)
- Spacer(Modifier.width(12.dp))
- }
- else
- {
- IconButton(onClick = { refreshGeoip() })
- {
- Icon(
- Icons.Rounded.Refresh,
- contentDescription = stringResource(R.string.rusplit_update),
- )
- }
- }
- },
+ onUpdate = { refreshGeoip() },
)
if (ruState.enabled)
{
+ ShxSectionDivider()
RuModeSelector(
mode = ruState.mode,
onModeChange = { ruList.setMode(it) },
@@ -264,150 +262,161 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
)
}
GeoipChipsRow(state = ruState)
- Text(
- text = stringResource(R.string.rusplit_source),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp),
- )
- Text(
- text = stringResource(R.string.rusplit_beta_note),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 12.dp),
- )
+ RuDetailsDisclosure(mode = ruState.mode)
}
}
- /* ---- route check: how does a domain go, tunnel or direct ---- */
- RouteCheckCard(modifier = Modifier.padding(top = 12.dp, start = ScreenPadding, end = ScreenPadding))
-
- /* ---- quick action: RF bundle ---- */
- if (config.enabled)
+ /* ---- force-tunnel prefixes: always through the tunnel ---- */
+ ShxSection(title = stringResource(R.string.routing_force_section))
{
- RoutingCard(
- modifier = Modifier
- .padding(top = 12.dp, start = ScreenPadding, end = ScreenPadding)
- .fillMaxWidth(),
- onClick = { manager.applyRfBundle() },
- )
+ if (config.forceTunnelPrefixes.isEmpty())
{
- RoutingActionRow(
- icon = Icons.Rounded.Block,
- title = stringResource(R.string.routing_rf_quick_title),
- subtitle = stringResource(R.string.routing_rf_quick_summary),
+ Text(
+ text = stringResource(R.string.routing_force_summary),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
)
}
+ else
+ {
+ config.forceTunnelPrefixes.forEachIndexed { index, entry ->
+ ForcePrefixRow(
+ entry = entry,
+ onToggle = { manager.setForcePrefixEnabled(entry.id, it) },
+ onEdit = { forceDialogId = entry.id },
+ onDelete = { manager.deleteForcePrefix(entry.id) },
+ )
+ if (index < config.forceTunnelPrefixes.lastIndex)
+ {
+ ShxSectionDivider()
+ }
+ }
+ }
+ ShxSectionDivider()
+ AddForcePrefixRow(onClick = { forceDialogId = "" })
}
/* ---- rule sets ---- */
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 24.dp, bottom = 4.dp, start = ScreenPadding, end = ScreenPadding),
- verticalAlignment = Alignment.CenterVertically,
- )
+ ShxSection(title = stringResource(R.string.routing_rules_section))
{
- Text(
- text = stringResource(R.string.routing_rules_section),
- style = MaterialTheme.typography.titleSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.weight(1f),
- )
- TextButton(
- onClick = {
- val newId = manager.addRuleSet()
- expandedId = newId
- focusNameId = newId
- },
- )
+ if (config.enabled)
{
- Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
- Spacer(Modifier.width(8.dp))
- Text(stringResource(R.string.routing_add_rule))
+ RfActionRow(onClick = { manager.applyRfBundle() })
+ if (config.ruleSets.isNotEmpty())
+ {
+ ShxSectionDivider()
+ }
}
- }
-
- if (config.ruleSets.isEmpty())
- {
- ShxCard(modifier = Modifier.padding(horizontal = ScreenPadding))
+ if (config.ruleSets.isEmpty())
{
ShxEmptyState(
icon = Icons.Rounded.AltRoute,
message = stringResource(R.string.routing_empty),
actionLabel = stringResource(R.string.routing_add_rule),
- onAction = {
- val newId = manager.addRuleSet()
- expandedId = newId
- focusNameId = newId
- },
+ onAction = { addNewSet() },
)
}
- }
- else
- {
- RoutingCard(modifier = Modifier.padding(horizontal = ScreenPadding))
+ else
{
config.ruleSets.forEachIndexed { index, ruleSet ->
RuleSetRow(
ruleSet = ruleSet,
- expanded = expandedId == ruleSet.id,
- requestNameFocus = focusNameId == ruleSet.id,
onToggle = { manager.setRuleSetEnabled(ruleSet.id, it) },
- onExpand = {
- expandedId = if (expandedId == ruleSet.id) null else ruleSet.id
- /* manual expansion cancels the auto-focus intent */
- focusNameId = null
- },
- onRename = { manager.updateRuleSetName(ruleSet.id, it) },
- onPickApps = {
- pickTarget = ruleSet.id
- val intent = Intent(context, SelectedApplicationsActivity::class.java)
- intent.putStringArrayListExtra(
- VpnProfileDataSource.KEY_SELECTED_APPS_LIST,
- ArrayList(ruleSet.packages),
- )
- pickApps.launch(intent)
- },
- onTogglePreset = { manager.togglePreset(ruleSet.id, it) },
- onAddPrefix = { prefix ->
- manager.updateConfig { cfg ->
- cfg.copy(ruleSets = cfg.ruleSets.map {
- if (it.id == ruleSet.id) it.copy(prefixes = it.prefixes + prefix) else it
- })
- }
- },
- onRemovePrefix = { prefix ->
- manager.updateConfig { cfg ->
- cfg.copy(ruleSets = cfg.ruleSets.map {
- if (it.id == ruleSet.id) it.copy(prefixes = it.prefixes - prefix) else it
- })
- }
- },
- onAddKeyword = { keyword ->
- manager.updateConfig { cfg ->
- cfg.copy(ruleSets = cfg.ruleSets.map {
- if (it.id == ruleSet.id) it.copy(keywords = it.keywords + keyword.lowercase()) else it
- })
- }
- },
- onRemoveKeyword = { keyword ->
- manager.updateConfig { cfg ->
- cfg.copy(ruleSets = cfg.ruleSets.map {
- if (it.id == ruleSet.id) it.copy(keywords = it.keywords - keyword) else it
- })
- }
- },
- onDelete = { deleteSetWithUndo(ruleSet) },
+ onOpen = { editingId = ruleSet.id; focusName = false },
)
if (index < config.ruleSets.lastIndex)
{
- ShxDivider()
+ ShxSectionDivider()
}
}
+ ShxSectionDivider()
+ AddRuleRow(onClick = { addNewSet() })
}
}
}
+
+ /* ---- rule set editor, hosted outside the scrolling column ---- */
+ val editing = editingId?.let { id -> config.ruleSets.firstOrNull { it.id == id } }
+ if (editing != null)
+ {
+ RuleSetEditorSheet(
+ ruleSet = editing,
+ requestNameFocus = focusName,
+ onDismiss = { editingId = null; focusName = false },
+ onRename = { manager.updateRuleSetName(editing.id, it) },
+ onPickApps = {
+ pickTarget = editing.id
+ val intent = Intent(context, SelectedApplicationsActivity::class.java)
+ intent.putStringArrayListExtra(
+ VpnProfileDataSource.KEY_SELECTED_APPS_LIST,
+ ArrayList(editing.packages),
+ )
+ pickApps.launch(intent)
+ },
+ onTogglePreset = { manager.togglePreset(editing.id, it) },
+ onAddPrefix = { prefix ->
+ manager.updateConfig { cfg ->
+ cfg.copy(ruleSets = cfg.ruleSets.map {
+ if (it.id == editing.id) it.copy(prefixes = it.prefixes + prefix) else it
+ })
+ }
+ },
+ onRemovePrefix = { prefix ->
+ manager.updateConfig { cfg ->
+ cfg.copy(ruleSets = cfg.ruleSets.map {
+ if (it.id == editing.id) it.copy(prefixes = it.prefixes - prefix) else it
+ })
+ }
+ },
+ onAddKeyword = { keyword ->
+ manager.updateConfig { cfg ->
+ cfg.copy(ruleSets = cfg.ruleSets.map {
+ if (it.id == editing.id) it.copy(keywords = it.keywords + keyword.lowercase()) else it
+ })
+ }
+ },
+ onRemoveKeyword = { keyword ->
+ manager.updateConfig { cfg ->
+ cfg.copy(ruleSets = cfg.ruleSets.map {
+ if (it.id == editing.id) it.copy(keywords = it.keywords - keyword) else it
+ })
+ }
+ },
+ onDelete = {
+ deleteSetWithUndo(editing)
+ editingId = null
+ focusName = false
+ },
+ )
+ }
+
+ /* ---- force prefix add/edit dialog, hosted outside the scrolling column ---- */
+ if (forceDialogId != null)
+ {
+ val initial = if (forceDialogId == "")
+ {
+ ""
+ }
+ else
+ {
+ config.forceTunnelPrefixes.firstOrNull { it.id == forceDialogId }?.prefix ?: ""
+ }
+ ForcePrefixDialog(
+ initial = initial,
+ onConfirm = { cidr ->
+ if (forceDialogId == "")
+ {
+ manager.addForcePrefix(cidr) != null
+ }
+ else
+ {
+ manager.updateForcePrefix(forceDialogId!!, cidr)
+ }
+ },
+ onDismiss = { forceDialogId = null },
+ )
+ }
}
/** Hero card: master switch plus a live summary of what the rules currently affect. */
@@ -472,6 +481,55 @@ private fun RoutingHeroCard(
}
}
+/** Geoip toggle row with a refresh/update slot in front of the switch. */
+@Composable
+private fun RuToggleRow(
+ title: String,
+ subtitle: String,
+ checked: Boolean,
+ updating: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ onUpdate: () -> Unit,
+)
+{
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onCheckedChange(!checked) }
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ )
+ {
+ ShxIconBadge(Icons.Rounded.Block)
+ Spacer(Modifier.width(16.dp))
+ Column(Modifier.weight(1f))
+ {
+ Text(title, style = MaterialTheme.typography.bodyLarge)
+ Text(
+ subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (updating)
+ {
+ CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp)
+ Spacer(Modifier.width(12.dp))
+ }
+ else
+ {
+ IconButton(onClick = onUpdate)
+ {
+ Icon(
+ Icons.Rounded.Refresh,
+ contentDescription = stringResource(R.string.rusplit_update),
+ )
+ }
+ }
+ Switch(checked = checked, onCheckedChange = onCheckedChange)
+ }
+}
+
/**
* Selector for how the RU split is routed: OS routes with the coarsened
* complement (as before) or per-packet classification in native code.
@@ -479,38 +537,95 @@ private fun RoutingHeroCard(
@Composable
private fun RuModeSelector(mode: String, onModeChange: (String) -> Unit)
{
- Row(
+ SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
- .padding(start = 16.dp, end = 16.dp, top = 4.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- FilterChip(
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ )
+ {
+ SegmentedButton(
selected = mode == Constants.RUSPLIT_MODE_SYSTEM,
onClick = { onModeChange(Constants.RUSPLIT_MODE_SYSTEM) },
- label = { Text(stringResource(R.string.rusplit_mode_system)) },
+ shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
)
- FilterChip(
+ {
+ Text(stringResource(R.string.rusplit_mode_system))
+ }
+ SegmentedButton(
selected = mode == Constants.RUSPLIT_MODE_USERSPACE,
onClick = { onModeChange(Constants.RUSPLIT_MODE_USERSPACE) },
- label = { Text(stringResource(R.string.rusplit_mode_userspace)) },
+ shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
+ )
+ {
+ Text(stringResource(R.string.rusplit_mode_userspace_short))
+ }
+ }
+}
+
+/** Mode description, list source and beta note behind a "Details" disclosure. */
+@Composable
+private fun RuDetailsDisclosure(mode: String)
+{
+ var expanded by rememberSaveable { mutableStateOf(false) }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { expanded = !expanded }
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ )
+ {
+ Text(
+ text = stringResource(if (expanded) R.string.routing_details_less else R.string.routing_details_more),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(Modifier.width(4.dp))
+ Icon(
+ if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(18.dp),
)
}
- Text(
- text = stringResource(
- if (mode == Constants.RUSPLIT_MODE_USERSPACE)
- {
- R.string.rusplit_mode_userspace_desc
- }
- else
- {
- R.string.rusplit_mode_system_desc
- }
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp),
+ AnimatedVisibility(
+ visible = expanded,
+ enter = expandVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow)) +
+ fadeIn(tween(120)),
+ exit = shrinkVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMedium)) +
+ fadeOut(tween(90)),
)
+ {
+ Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp))
+ {
+ Text(
+ text = stringResource(
+ if (mode == Constants.RUSPLIT_MODE_USERSPACE)
+ {
+ R.string.rusplit_mode_userspace_desc
+ }
+ else
+ {
+ R.string.rusplit_mode_system_desc
+ }
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = stringResource(R.string.rusplit_source),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 6.dp),
+ )
+ Text(
+ text = stringResource(R.string.rusplit_beta_note),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ }
+ }
}
/** Freshness/count chips of the geoip list. */
@@ -523,7 +638,7 @@ private fun GeoipChipsRow(state: RuIpListState)
FlowRow(
modifier = Modifier
.fillMaxWidth()
- .padding(start = 16.dp, end = 16.dp, top = 8.dp),
+ .padding(start = 16.dp, end = 16.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
)
@@ -566,80 +681,29 @@ private fun InfoChip(text: String, highlight: Color? = null)
}
}
+/** Quick action: create/enable rule sets for all RF bundle presets. */
@Composable
-private fun RoutingCard(
- modifier: Modifier = Modifier,
- onClick: (() -> Unit)? = null,
- content: @Composable () -> Unit,
-)
-{
- Surface(
- shape = MaterialTheme.shapes.large,
- color = MaterialTheme.colorScheme.surfaceContainerLow,
- border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
- onClick = onClick ?: {},
- enabled = onClick != null,
- modifier = modifier.fillMaxWidth(),
- )
- {
- Column { content() }
- }
-}
-
-@Composable
-private fun RoutingToggleRow(
- icon: ImageVector,
- title: String,
- subtitle: String,
- checked: Boolean,
- onCheckedChange: (Boolean) -> Unit,
- actions: (@Composable RowScope.() -> Unit)? = null,
-)
+private fun RfActionRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 8.dp),
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 14.dp),
)
{
- Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ ShxIconBadge(Icons.Rounded.Block)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f))
{
- Text(title, style = MaterialTheme.typography.bodyLarge)
Text(
- subtitle,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ stringResource(R.string.routing_rf_quick_title),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.SemiBold,
)
- }
- if (actions != null)
- {
- actions()
- Spacer(Modifier.width(4.dp))
- }
- Spacer(Modifier.width(8.dp))
- Switch(checked = checked, onCheckedChange = onCheckedChange)
- }
-}
-
-@Composable
-private fun RoutingActionRow(icon: ImageVector, title: String, subtitle: String)
-{
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- )
- {
- Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
- Spacer(Modifier.width(16.dp))
- Column {
- Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
Text(
- subtitle,
+ stringResource(R.string.routing_rf_quick_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -647,81 +711,33 @@ private fun RoutingActionRow(icon: ImageVector, title: String, subtitle: String)
}
}
-/** Compact input with an add action; commits on the IME "done" or the trailing button. */
-@Composable
-private fun AddChipField(
- label: String,
- onAdd: (String) -> Unit,
-)
-{
- var value by remember { mutableStateOf("") }
- val commit = {
- if (value.isNotBlank())
- {
- onAdd(value.trim())
- value = ""
- }
- }
- OutlinedTextField(
- value = value,
- onValueChange = { value = it },
- label = { Text(label) },
- singleLine = true,
- keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
- keyboardActions = KeyboardActions(onDone = { commit() }),
- trailingIcon = {
- IconButton(onClick = { commit() }, enabled = value.isNotBlank())
- {
- Icon(Icons.Rounded.Add, contentDescription = null)
- }
- },
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 8.dp),
- shape = MaterialTheme.shapes.medium,
- )
-}
-
+/** Collapsed rule set row: tap opens the editor sheet, the switch toggles in place. */
@Composable
private fun RuleSetRow(
ruleSet: RuleSet,
- expanded: Boolean,
- requestNameFocus: Boolean = false,
onToggle: (Boolean) -> Unit,
- onExpand: () -> Unit,
- onRename: (String) -> Unit,
- onPickApps: () -> Unit,
- onTogglePreset: (RoutingPresets.Preset) -> Unit,
- onAddPrefix: (String) -> Unit,
- onRemovePrefix: (String) -> Unit,
- onAddKeyword: (String) -> Unit,
- onRemoveKeyword: (String) -> Unit,
- onDelete: () -> Unit,
+ onOpen: () -> Unit,
)
{
- val nameFocusRequester = remember { FocusRequester() }
- LaunchedEffect(expanded, requestNameFocus)
- {
- if (expanded && requestNameFocus)
- {
- nameFocusRequester.requestFocus()
- }
- }
-
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 4.dp),
+ .clickable(onClick = onOpen)
+ .padding(horizontal = 16.dp, vertical = 14.dp),
)
{
- Switch(checked = ruleSet.enabled, onCheckedChange = onToggle)
- Spacer(Modifier.width(12.dp))
+ ShxIconBadge(
+ Icons.Rounded.Rule,
+ tint = if (ruleSet.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f))
{
Text(
ruleSet.name.ifBlank { stringResource(R.string.routing_rule_name) },
style = MaterialTheme.typography.bodyLarge,
+ color = if (ruleSet.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stringResource(
@@ -734,227 +750,158 @@ private fun RuleSetRow(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
- IconButton(onClick = onExpand)
+ Switch(checked = ruleSet.enabled, onCheckedChange = onToggle)
+ }
+}
+
+/** Trailing "add rule set" row of the rule sets card. */
+@Composable
+private fun AddRuleRow(onClick: () -> Unit)
+{
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ )
+ {
+ Icon(
+ Icons.Rounded.Add,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(22.dp),
+ )
+ Spacer(Modifier.width(16.dp))
+ Text(
+ stringResource(R.string.routing_add_rule),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+}
+
+/** One force-tunnel prefix: tap edits, switch toggles, trash deletes. */
+@Composable
+private fun ForcePrefixRow(
+ entry: ForceTunnelPrefix,
+ onToggle: (Boolean) -> Unit,
+ onEdit: () -> Unit,
+ onDelete: () -> Unit,
+)
+{
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onEdit)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ {
+ ShxIconBadge(
+ Icons.Rounded.Lan,
+ tint = if (entry.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.width(16.dp))
+ Text(
+ entry.prefix,
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (entry.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ IconButton(onClick = onDelete)
{
Icon(
- if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
- contentDescription = null,
+ Icons.Rounded.Delete,
+ contentDescription = stringResource(R.string.routing_force_delete),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
+ Switch(checked = entry.enabled, onCheckedChange = onToggle)
}
+}
- AnimatedVisibility(
- visible = expanded,
- enter = expandVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow)) +
- fadeIn(tween(120)),
- exit = shrinkVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMedium)) +
- fadeOut(tween(90)),
+/** Trailing "add force-tunnel prefix" row. */
+@Composable
+private fun AddForcePrefixRow(onClick: () -> Unit)
+{
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 14.dp),
)
{
- Column(Modifier.padding(horizontal = 16.dp))
- {
- OutlinedTextField(
- value = ruleSet.name,
- onValueChange = onRename,
- label = { Text(stringResource(R.string.routing_rule_name)) },
- singleLine = true,
- modifier = Modifier
- .fillMaxWidth()
- .focusRequester(nameFocusRequester),
- shape = MaterialTheme.shapes.medium,
- )
- Spacer(Modifier.height(8.dp))
- TextButton(onClick = onPickApps)
- {
- Text(stringResource(R.string.routing_pick_apps) + " (${ruleSet.packages.size})")
- }
-
- Text(
- text = stringResource(R.string.routing_presets_label),
- style = MaterialTheme.typography.titleSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Row(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier
- .fillMaxWidth()
- .horizontalScroll(rememberScrollState()),
- )
- {
- RoutingPresets.RF_BUNDLE.forEach { preset ->
- val selected = ruleSet.prefixes.containsAll(preset.prefixes) &&
- ruleSet.keywords.containsAll(preset.keywords)
- AssistChip(
- onClick = { onTogglePreset(preset) },
- label = { Text(preset.name) },
- border = BorderStroke(
- 1.dp,
- if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
- ),
- )
- }
- }
-
- ruleSet.prefixes.sorted().forEach { prefix ->
- AssistChip(
- onClick = { onRemovePrefix(prefix) },
- label = { Text(prefix) },
- trailingIcon = {
- Icon(Icons.Rounded.Close, contentDescription = null, modifier = Modifier.size(16.dp))
- },
- modifier = Modifier.padding(top = 4.dp),
- )
- }
- AddChipField(label = stringResource(R.string.routing_add_prefix), onAdd = onAddPrefix)
-
- ruleSet.keywords.sorted().forEach { keyword ->
- AssistChip(
- onClick = { onRemoveKeyword(keyword) },
- label = { Text("*$keyword*") },
- trailingIcon = {
- Icon(Icons.Rounded.Close, contentDescription = null, modifier = Modifier.size(16.dp))
- },
- modifier = Modifier.padding(top = 4.dp),
- )
- }
- AddChipField(label = stringResource(R.string.routing_add_keyword), onAdd = onAddKeyword)
-
- TextButton(
- onClick = onDelete,
- modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
- )
- {
- Icon(
- Icons.Rounded.Delete,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.error,
- modifier = Modifier.size(18.dp),
- )
- Spacer(Modifier.width(8.dp))
- Text(stringResource(R.string.routing_delete), color = MaterialTheme.colorScheme.error)
- }
- }
- }
-}
-
-/** Diagnostic card: shows how a domain resolves and which route its addresses take. */
-@Composable
-private fun RouteCheckCard(modifier: Modifier = Modifier)
-{
- val context = LocalContext.current
- val scope = rememberCoroutineScope()
- var domain by remember { mutableStateOf("") }
- var checking by remember { mutableStateOf(false) }
- var report by remember { mutableStateOf(null) }
-
- ShxCard(modifier = modifier.fillMaxWidth())
- {
- Column(modifier = Modifier.padding(16.dp))
- {
- Text(
- text = stringResource(R.string.route_check_title),
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- )
- Text(
- text = stringResource(R.string.route_check_hint),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(top = 4.dp),
- )
- OutlinedTextField(
- value = domain,
- onValueChange = { domain = it },
- label = { Text(stringResource(R.string.route_check_field)) },
- singleLine = true,
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 10.dp),
- )
- Button(
- onClick = {
- checking = true
- scope.launch {
- report = RouteCheck.check(
- context,
- domain.trim(),
- CharonVpnService.sAppliedGeoExclusionsV4,
- CharonVpnService.sAppliedGeoExclusionsV6,
- CharonVpnService.sDnsPunchIps,
- )
- checking = false
- }
- },
- enabled = domain.isNotBlank() && !checking,
- modifier = Modifier.padding(top = 10.dp),
- )
- {
- Text(stringResource(if (checking) R.string.route_check_checking else R.string.route_check_button))
- }
- report?.let { rep -> RouteCheckResult(rep) }
- }
- }
-}
-
-@Composable
-private fun RouteCheckResult(report: RouteCheck.Report)
-{
- Column(modifier = Modifier.padding(top = 12.dp))
- {
- if (!report.vpnNetworkFound)
- {
- Text(
- text = stringResource(R.string.route_check_no_vpn),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error,
- )
- }
- RouteCheckRow(stringResource(R.string.route_check_via_vpn_dns), report.vpnDnsIps)
- RouteCheckRow(stringResource(R.string.route_check_via_direct_dns), report.directDnsIps)
+ Icon(
+ Icons.Rounded.Add,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(22.dp),
+ )
+ Spacer(Modifier.width(16.dp))
Text(
- text = stringResource(R.string.route_check_routes),
- style = MaterialTheme.typography.titleSmall,
- modifier = Modifier.padding(top = 10.dp),
+ stringResource(R.string.routing_force_add),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Medium,
)
- for (route in report.routes)
- {
- val label = stringResource(
- when (route.kind)
- {
- RouteCheck.RouteKind.TUNNEL -> R.string.route_verdict_tunnel
- RouteCheck.RouteKind.DIRECT_RU -> R.string.route_verdict_direct
- RouteCheck.RouteKind.TUNNEL_DNS -> R.string.route_verdict_dns
- },
- )
- Text(
- text = "${route.ip} — $label",
- style = MaterialTheme.typography.bodySmall,
- fontFamily = FontFamily.Monospace,
- modifier = Modifier.padding(top = 2.dp),
- )
- }
- if (report.routes.isEmpty())
- {
- Text(
- text = stringResource(R.string.route_check_resolve_failed),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error,
- )
- }
}
}
+/** Add/edit dialog for a force-tunnel prefix; shows an error until valid. */
@Composable
-private fun RouteCheckRow(label: String, ips: List)
+private fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean, onDismiss: () -> Unit)
{
- Text(
- text = label,
- style = MaterialTheme.typography.titleSmall,
- modifier = Modifier.padding(top = 8.dp),
- )
- Text(
- text = ips.joinToString(", ").ifEmpty { stringResource(R.string.route_check_none) },
- style = MaterialTheme.typography.bodySmall,
- fontFamily = FontFamily.Monospace,
+ var text by remember { mutableStateOf(initial) }
+ var error by remember { mutableStateOf(false) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.routing_force_dialog_title)) },
+ text = {
+ OutlinedTextField(
+ value = text,
+ onValueChange = {
+ text = it
+ error = false
+ },
+ singleLine = true,
+ isError = error,
+ label = { Text(stringResource(R.string.routing_force_hint)) },
+ supportingText = if (error)
+ {
+ { Text(stringResource(R.string.routing_force_invalid)) }
+ }
+ else
+ {
+ null
+ },
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ if (onConfirm(text.trim()))
+ {
+ onDismiss()
+ }
+ else
+ {
+ error = true
+ }
+ })
+ {
+ Text(stringResource(R.string.routing_force_save))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss)
+ {
+ Text(stringResource(android.R.string.cancel))
+ }
+ },
)
}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RuleSetEditorSheet.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RuleSetEditorSheet.kt
new file mode 100644
index 000000000..0ff6744fd
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RuleSetEditorSheet.kt
@@ -0,0 +1,358 @@
+/*
+ * 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.compose
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
+import androidx.compose.material.icons.rounded.Add
+import androidx.compose.material.icons.rounded.Apps
+import androidx.compose.material.icons.rounded.Category
+import androidx.compose.material.icons.rounded.Close
+import androidx.compose.material.icons.rounded.Delete
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import org.strongswan.android.R
+import org.strongswan.android.logic.routing.RuleSet
+import org.strongswan.android.logic.routing.RoutingPresets
+import org.strongswan.android.ui.compose.components.ShxIconBadge
+
+/**
+ * Editor of one rule set as a modal bottom sheet. The preset picker is a
+ * second view inside the same sheet (back returns to the editor) so that two
+ * sheets never stack on top of each other.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RuleSetEditorSheet(
+ ruleSet: RuleSet,
+ requestNameFocus: Boolean,
+ onDismiss: () -> Unit,
+ onRename: (String) -> Unit,
+ onPickApps: () -> Unit,
+ onTogglePreset: (RoutingPresets.Preset) -> Unit,
+ onAddPrefix: (String) -> Unit,
+ onRemovePrefix: (String) -> Unit,
+ onAddKeyword: (String) -> Unit,
+ onRemoveKeyword: (String) -> Unit,
+ onDelete: () -> Unit,
+)
+{
+ var showPresets by remember { mutableStateOf(false) }
+ BackHandler(enabled = showPresets) { showPresets = false }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ )
+ {
+ AnimatedContent(
+ targetState = showPresets,
+ transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(120)) },
+ label = "rule-set-editor",
+ )
+ { presetsVisible ->
+ if (presetsVisible)
+ {
+ PresetPickerContent(
+ ruleSet = ruleSet,
+ onToggle = onTogglePreset,
+ onBack = { showPresets = false },
+ )
+ }
+ else
+ {
+ EditorContent(
+ ruleSet = ruleSet,
+ requestNameFocus = requestNameFocus,
+ onDismiss = onDismiss,
+ onRename = onRename,
+ onPickApps = onPickApps,
+ onOpenPresets = { showPresets = true },
+ onAddPrefix = onAddPrefix,
+ onRemovePrefix = onRemovePrefix,
+ onAddKeyword = onAddKeyword,
+ onRemoveKeyword = onRemoveKeyword,
+ onDelete = onDelete,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun EditorContent(
+ ruleSet: RuleSet,
+ requestNameFocus: Boolean,
+ onDismiss: () -> Unit,
+ onRename: (String) -> Unit,
+ onPickApps: () -> Unit,
+ onOpenPresets: () -> Unit,
+ onAddPrefix: (String) -> Unit,
+ onRemovePrefix: (String) -> Unit,
+ onAddKeyword: (String) -> Unit,
+ onRemoveKeyword: (String) -> Unit,
+ onDelete: () -> Unit,
+)
+{
+ val nameFocusRequester = remember { FocusRequester() }
+ LaunchedEffect(ruleSet.id, requestNameFocus)
+ {
+ if (requestNameFocus)
+ {
+ nameFocusRequester.requestFocus()
+ }
+ }
+
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(bottom = 24.dp),
+ )
+ {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(start = 24.dp, end = 12.dp, bottom = 8.dp),
+ )
+ {
+ Text(
+ text = stringResource(R.string.routing_editor_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(onClick = onDismiss)
+ {
+ Text(stringResource(R.string.action_done))
+ }
+ }
+
+ OutlinedTextField(
+ value = ruleSet.name,
+ onValueChange = onRename,
+ label = { Text(stringResource(R.string.routing_rule_name)) },
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .focusRequester(nameFocusRequester),
+ shape = MaterialTheme.shapes.medium,
+ )
+
+ EditorRow(
+ icon = Icons.Rounded.Apps,
+ title = stringResource(R.string.routing_pick_apps),
+ subtitle = stringResource(R.string.routing_apps_count, ruleSet.packages.size),
+ onClick = onPickApps,
+ )
+ EditorRow(
+ icon = Icons.Rounded.Category,
+ title = stringResource(R.string.routing_presets_selected, countSelectedPresets(ruleSet)),
+ subtitle = stringResource(R.string.routing_presets_hint),
+ onClick = onOpenPresets,
+ )
+
+ ChipGroup(
+ label = stringResource(R.string.routing_prefixes_label),
+ values = ruleSet.prefixes.sorted(),
+ text = { it },
+ onRemove = onRemovePrefix,
+ addLabel = stringResource(R.string.routing_add_prefix),
+ onAdd = onAddPrefix,
+ )
+ ChipGroup(
+ label = stringResource(R.string.routing_keywords_label),
+ values = ruleSet.keywords.sorted(),
+ text = { "*$it*" },
+ onRemove = onRemoveKeyword,
+ addLabel = stringResource(R.string.routing_add_keyword),
+ onAdd = onAddKeyword,
+ )
+
+ Button(
+ onClick = onDelete,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
+ ),
+ shape = MaterialTheme.shapes.medium,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 12.dp),
+ )
+ {
+ Icon(
+ Icons.Rounded.Delete,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(R.string.routing_delete))
+ }
+ }
+}
+
+/** Labeled chip group with trailing add field, shared by prefixes and keywords. */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun ChipGroup(
+ label: String,
+ values: List,
+ text: (String) -> String,
+ onRemove: (String) -> Unit,
+ addLabel: String,
+ onAdd: (String) -> Unit,
+)
+{
+ Text(
+ text = label,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 24.dp, top = 16.dp),
+ )
+ FlowRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ )
+ {
+ values.forEach { value ->
+ AssistChip(
+ onClick = { onRemove(value) },
+ label = { Text(text(value), maxLines = 1) },
+ trailingIcon = {
+ Icon(Icons.Rounded.Close, contentDescription = null, modifier = Modifier.size(16.dp))
+ },
+ )
+ }
+ }
+ AddChipField(label = addLabel, onAdd = onAdd)
+}
+
+/** Editor row: badge, title/subtitle, chevron. */
+@Composable
+private fun EditorRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> Unit)
+{
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 24.dp, vertical = 14.dp),
+ )
+ {
+ ShxIconBadge(icon)
+ Spacer(Modifier.width(16.dp))
+ Column(Modifier.weight(1f))
+ {
+ Text(title, style = MaterialTheme.typography.bodyLarge)
+ Text(
+ subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Icon(
+ Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+/** Compact input with an add action; commits on the IME "done" or the trailing button. */
+@Composable
+private fun AddChipField(
+ label: String,
+ onAdd: (String) -> Unit,
+)
+{
+ var value by remember { mutableStateOf("") }
+ val commit = {
+ if (value.isNotBlank())
+ {
+ onAdd(value.trim())
+ value = ""
+ }
+ }
+ OutlinedTextField(
+ value = value,
+ onValueChange = { value = it },
+ label = { Text(label) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = { commit() }),
+ trailingIcon = {
+ IconButton(onClick = { commit() }, enabled = value.isNotBlank())
+ {
+ Icon(Icons.Rounded.Add, contentDescription = null)
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 4.dp),
+ shape = MaterialTheme.shapes.medium,
+ )
+}
+
+/** A preset counts as selected when all of its patterns are in the set. */
+private fun countSelectedPresets(ruleSet: RuleSet): Int =
+ RoutingPresets.RF_BUNDLE.count { preset ->
+ ruleSet.prefixes.containsAll(preset.prefixes) && ruleSet.keywords.containsAll(preset.keywords)
+ }
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 66e8bb974..b7bff8d2b 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
@@ -29,6 +29,7 @@ import androidx.compose.material.icons.rounded.Autorenew
import androidx.compose.material.icons.rounded.BatteryChargingFull
import androidx.compose.material.icons.rounded.Bedtime
import androidx.compose.material.icons.rounded.Bolt
+import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.Cached
import androidx.compose.material.icons.rounded.CloudDownload
import androidx.compose.material.icons.rounded.CloudSync
@@ -389,6 +390,20 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true)
context.startActivity(Intent(context, LogActivity::class.java))
},
)
+ ShxSectionDivider()
+ var extendedDiag by remember {
+ mutableStateOf(prefs.getBoolean(Constants.PREF_EXTENDED_DIAG, false))
+ }
+ ShxSettingToggleRow(
+ icon = Icons.Rounded.BugReport,
+ title = stringResource(R.string.extended_diag_title),
+ subtitle = stringResource(R.string.extended_diag_summary),
+ checked = extendedDiag,
+ onCheckedChange = {
+ extendedDiag = it
+ prefs.edit().putBoolean(Constants.PREF_EXTENDED_DIAG, it).apply()
+ },
+ )
}
ShxSection(title = stringResource(R.string.settings_section_updates))
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/utils/Constants.java b/src/frontends/android/app/src/main/java/org/strongswan/android/utils/Constants.java
index 8048a3c5c..6a4815c9d 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/utils/Constants.java
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/utils/Constants.java
@@ -91,10 +91,8 @@ public final class Constants
/**
* Minimum interval between automatic update checks.
- * TODO: revert to 12 * 60 * 60 * 1000L (12h) once the silent update flow
- * has been verified on a real device
*/
- public static final long UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1000L;
+ public static final long UPDATE_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L;
/**
* Preference key to store the timestamp of the last update check
@@ -135,6 +133,14 @@ public final class Constants
*/
public static final String PREF_WATCHDOG_AUTO_EXPECTED = "pref_watchdog_auto_expected";
+ /**
+ * Preference key to store whether extended diagnostics are enabled:
+ * periodic route-diag snapshots while connected and other verbose
+ * logging that costs battery and flash I/O. Off by default - the
+ * one-shot snapshot right after connecting is always written.
+ */
+ public static final String PREF_EXTENDED_DIAG = "pref_extended_diag";
+
/**
* Preference key to store whether IPv6 should be blackholed when the
* tunnel provides no IPv6 configuration, instead of letting it bypass
diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c b/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c
index 4311a0b87..b7a7afbc5 100644
--- a/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c
+++ b/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c
@@ -914,10 +914,12 @@ static job_requeue_t initiate(private_android_service_t *this)
}
else
{ /* create ESP proposals with and without DH groups, let responder decide
- * if PFS is used */
+ * if PFS is used. modp2048 is kept in the AEAD list so server-initiated
+ * rekeys offering it match instead of failing with NO_PROP, which
+ * triggers a full IKE re-establishment every rekey */
child_cfg->add_proposal(child_cfg, proposal_create_from_string(PROTO_ESP,
"aes256gcm16-aes128gcm16-chacha20poly1305-"
- "curve25519-ecp384-ecp521-modp3072-modp4096-ecp256-modp8192"));
+ "curve25519-ecp384-ecp521-modp3072-modp4096-ecp256-modp8192-modp2048"));
child_cfg->add_proposal(child_cfg, proposal_create_from_string(PROTO_ESP,
"aes256-aes192-aes128-sha384-sha256-sha512-sha1-"
"curve25519-ecp384-ecp521-modp3072-modp4096-ecp256-modp2048-"
diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.c b/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.c
index 60d991ade..c6cd37e08 100644
--- a/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.c
+++ b/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.c
@@ -25,8 +25,10 @@
#include
#include
#include
+#include
#include
#include
+#include
#include
#include "charonservice.h"
@@ -254,7 +256,7 @@ static bool bypass_single_socket(private_charonservice_t *this, int fd,
}
if (!(*env)->CallBooleanMethod(env, this->vpn_service, method_id, fd))
{
- DBG2(DBG_KNL, "VpnService.protect() failed");
+ DBG1(DBG_KNL, "VpnService.protect() failed");
goto failed;
}
androidjni_detach_thread();
@@ -326,6 +328,71 @@ bool charonservice_protect_direct_socket(int fd)
return bypass_single_socket(this, fd, TRUE);
}
+/**
+ * Ask Java which app owns the connection the direct relay just refused
+ * (diagnostics: names the app hammering a dead destination). local is the
+ * app-side endpoint of its socket, remote the destination. Best effort;
+ * the engine calls this at most once per destination cooldown.
+ */
+void charonservice_log_connection_owner(const struct sockaddr *local,
+ const struct sockaddr *remote,
+ const char *dst)
+{
+ private_charonservice_t *this = (private_charonservice_t*)charonservice;
+ JNIEnv *env;
+ jmethodID method_id;
+ jbyteArray jlocal, jremote;
+ jstring jdst;
+ const void *laddr, *raddr;
+ size_t alen;
+ int lport, rport;
+
+ if (!this || !local || !remote || !dst)
+ {
+ return;
+ }
+ if (local->sa_family == AF_INET6)
+ {
+ const struct sockaddr_in6 *l6 = (const struct sockaddr_in6*)local;
+ const struct sockaddr_in6 *r6 = (const struct sockaddr_in6*)remote;
+
+ alen = 16;
+ laddr = &l6->sin6_addr;
+ raddr = &r6->sin6_addr;
+ lport = ntohs(l6->sin6_port);
+ rport = ntohs(r6->sin6_port);
+ }
+ else
+ {
+ const struct sockaddr_in *l4 = (const struct sockaddr_in*)local;
+ const struct sockaddr_in *r4 = (const struct sockaddr_in*)remote;
+
+ alen = 4;
+ laddr = &l4->sin_addr;
+ raddr = &r4->sin_addr;
+ lport = ntohs(l4->sin_port);
+ rport = ntohs(r4->sin_port);
+ }
+
+ androidjni_attach_thread(&env);
+ jlocal = (*env)->NewByteArray(env, alen);
+ jremote = (*env)->NewByteArray(env, alen);
+ jdst = (*env)->NewStringUTF(env, dst);
+ method_id = (*env)->GetMethodID(env, android_charonvpnservice_class,
+ "logConnectionOwner", "([BI[BILjava/lang/String;)V");
+ if (!jlocal || !jremote || !jdst || !method_id)
+ {
+ goto failed;
+ }
+ (*env)->SetByteArrayRegion(env, jlocal, 0, alen, (const jbyte*)laddr);
+ (*env)->SetByteArrayRegion(env, jremote, 0, alen, (const jbyte*)raddr);
+ (*env)->CallVoidMethod(env, this->vpn_service, method_id,
+ jlocal, (jint)lport, jremote, (jint)rport, jdst);
+failed:
+ androidjni_exception_occurred(env);
+ androidjni_detach_thread();
+}
+
/**
* Converts the given Java array of byte arrays (byte[][]) to a linked list
* of chunk_t objects.
@@ -1098,19 +1165,21 @@ static uint8_t *bytes_to_v6(JNIEnv *env, jbyteArray jarr, size_t *count)
/**
* Push the userspace routing classification data (mode B of the RU split)
- * to the engine: the merged sorted RU ranges and the force-tunnel list.
- * The set is swapped atomically, no reconnect is required.
+ * to the engine: the merged sorted RU ranges and the force-tunnel ranges
+ * (DoT/tunnel DNS servers and user-defined prefixes). The set is swapped
+ * atomically, no reconnect is required.
*/
JNI_METHOD(CharonVpnService, setUserspaceRoutingData, void,
jboolean enabled, jlongArray jv4from, jlongArray jv4to,
- jbyteArray jv6from, jbyteArray jv6to, jlongArray jft4, jbyteArray jft6)
+ jbyteArray jv6from, jbyteArray jv6to,
+ jlongArray jft4from, jlongArray jft4to, jbyteArray jft6from, jbyteArray jft6to)
{
/* the engine exists only while the userspace routing mode is enabled;
* in mode A none of its code runs at all */
direct_engine_t *engine = enabled ? direct_engine_ensure() : direct_engine_get();
ru_set_t *set;
- uint32_t *v4f = NULL, *v4t = NULL, *ft4 = NULL;
- uint8_t *v6f = NULL, *v6t = NULL, *ft6 = NULL;
+ uint32_t *v4f = NULL, *v4t = NULL, *ft4f = NULL, *ft4t = NULL;
+ uint8_t *v6f = NULL, *v6t = NULL, *ft6f = NULL, *ft6t = NULL;
size_t n4 = 0, n6 = 0, nf4 = 0, nf6 = 0, n4b = 0;
if (!engine)
@@ -1137,10 +1206,35 @@ JNI_METHOD(CharonVpnService, setUserspaceRoutingData, void,
free(v6t);
return;
}
- ft4 = longs_to_v4(env, jft4, &nf4);
- ft6 = bytes_to_v6(env, jft6, &nf6);
+ ft4f = longs_to_v4(env, jft4from, &nf4);
+ ft4t = longs_to_v4(env, jft4to, &n4b);
+ if (n4b != nf4)
+ {
+ free(v4f);
+ free(v4t);
+ free(v6f);
+ free(v6t);
+ free(ft4f);
+ free(ft4t);
+ return;
+ }
+ ft6f = bytes_to_v6(env, jft6from, &nf6);
+ n4b = 0;
+ ft6t = bytes_to_v6(env, jft6to, &n4b);
+ if (n4b != nf6)
+ {
+ free(v4f);
+ free(v4t);
+ free(v6f);
+ free(v6t);
+ free(ft4f);
+ free(ft4t);
+ free(ft6f);
+ free(ft6t);
+ return;
+ }
- set = ru_set_create(v4f, v4t, n4, v6f, v6t, n6, ft4, nf4, ft6, nf6);
+ set = ru_set_create(v4f, v4t, n4, v6f, v6t, n6, ft4f, ft4t, nf4, ft6f, ft6t, nf6);
engine->update_set(engine, set);
engine->set_enabled(engine, enabled);
}
@@ -1152,7 +1246,7 @@ JNI_METHOD(CharonVpnService, getRoutingStats, jlongArray)
{
direct_engine_t *engine = direct_engine_get();
direct_stats_t s = {};
- jlong v[19];
+ jlong v[20];
jlongArray result;
memset(&s, 0, sizeof(s));
@@ -1179,9 +1273,10 @@ JNI_METHOD(CharonVpnService, getRoutingStats, jlongArray)
v[16] = (jlong)s.set_v6;
v[17] = (jlong)s.set_ft_v4;
v[18] = (jlong)(s.enabled ? 1 : 0);
+ v[19] = (jlong)s.cooldown_hits;
- result = (*env)->NewLongArray(env, 19);
- (*env)->SetLongArrayRegion(env, result, 0, 19, v);
+ result = (*env)->NewLongArray(env, 20);
+ (*env)->SetLongArrayRegion(env, result, 0, 20, v);
return result;
}
diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.h b/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.h
index 7ffb08ae4..efbd746b9 100644
--- a/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.h
+++ b/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.h
@@ -40,6 +40,7 @@
#include
#include
+#include
typedef enum android_vpn_state_t android_vpn_state_t;
typedef enum android_imc_state_t android_imc_state_t;
@@ -180,4 +181,13 @@ bool charonservice_protect_socket(int fd);
*/
bool charonservice_protect_direct_socket(int fd);
+/**
+ * Log (via Java) which app owns the connection identified by the two
+ * endpoints; used by the direct relay when a destination enters its failure
+ * cooldown. Best effort.
+ */
+void charonservice_log_connection_owner(const struct sockaddr *local,
+ const struct sockaddr *remote,
+ const char *dst);
+
#endif /** CHARONSERVICE_H_ @}*/
diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_engine.c b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_engine.c
index cf478b15c..0415c8b42 100644
--- a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_engine.c
+++ b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_engine.c
@@ -35,14 +35,28 @@ extern void lwip_assert_diag(const char *msg);
/**
* Poll set capacity; beyond that flows are simply not polled for one round
- * (the wakeup pipe keeps the loop spinning at 250ms worst case)
+ * (the wakeup pipe keeps the loop spinning at the poll timeout worst case)
*/
#define MAX_POLL_FDS 1024
/**
- * Poll timeout: upper bound for lwIP timer latency
+ * Poll timeout: upper bound for lwIP timer latency while traffic flows.
+ * Empty rounds double it up to the idle cap - ENGINE_IDLE_TCP_MS while TCP
+ * relays exist (their connect timeout and failure-cooldown bookkeeping are
+ * evaluated per round), ENGINE_IDLE_POLL_MS otherwise - so an idle engine
+ * stops waking the CPU four times a second. Any event (queued packet,
+ * socket, wakeup pipe) restores the active timeout instantly, so latency
+ * under load is unchanged.
*/
#define ENGINE_POLL_MS 250
+#define ENGINE_IDLE_POLL_MS 4000
+#define ENGINE_IDLE_TCP_MS 1000
+
+/**
+ * Idle-flow sweep interval (UDP flow expiry), measured on the monotonic
+ * clock so stretching the poll timeout does not stretch the sweep
+ */
+#define ENGINE_SWEEP_MS 4000
/**
* Idle-fragment of the IPv4 fragment flags word (MF flag or offset)
@@ -375,6 +389,7 @@ METHOD(direct_engine_t, get_stats, void,
.drop_frag = __atomic_load_n(&this->stats.drop_frag, __ATOMIC_RELAXED),
.drop_malformed = __atomic_load_n(&this->stats.drop_malformed, __ATOMIC_RELAXED),
.relay_errors = __atomic_load_n(&this->stats.relay_errors, __ATOMIC_RELAXED),
+ .cooldown_hits = __atomic_load_n(&this->stats.cooldown_hits, __ATOMIC_RELAXED),
.udp_flows = __atomic_load_n(&this->stats.udp_flows, __ATOMIC_RELAXED),
.tcp_flows = __atomic_load_n(&this->stats.tcp_flows, __ATOMIC_RELAXED),
.tcp_accepts = __atomic_load_n(&this->stats.tcp_accepts, __ATOMIC_RELAXED),
@@ -520,19 +535,30 @@ static void drain_queue_nobind(private_direct_engine_t *this)
}
}
+/**
+ * Monotonic clock in milliseconds (matches sys_now() in direct_tcp.c)
+ */
+static uint64_t monotonic_ms(void)
+{
+ return (uint64_t)(time_monotonic(NULL) * 1000);
+}
+
/**
* Engine thread: drives all direct flows. The queue is drained straight
* into dispatch - no intermediate list, no allocations in this loop.
+ * The poll timeout is adaptive: idle rounds stretch it (battery), any
+ * event or queued packet restores the active cadence (latency).
*/
static void *engine_thread(private_direct_engine_t *this)
{
struct pollfd fds[MAX_POLL_FDS];
- uint32_t sweeps = 0;
+ uint32_t poll_ms = ENGINE_POLL_MS;
+ uint64_t last_sweep = monotonic_ms();
while (this->running)
{
size_t n = 1, udp_n, tcp_n;
- bool bound;
+ bool bound, active = FALSE;
fds[0].fd = this->wake[0];
fds[0].events = POLLIN;
@@ -541,16 +567,20 @@ static void *engine_thread(private_direct_engine_t *this)
n += udp_n;
tcp_n = 0;
if (this->tcp_active)
- { /* dormant until a TCP flow exists (currently never: handle()
- * tunnels TCP; see the lwIP note in dispatch_packet) */
+ { /* dormant until a TCP flow exists (see the lwIP note in
+ * dispatch_packet for why TCP is usually tunneled instead) */
tcp_n = this->tcp->fill_poll(this->tcp, fds + n, MAX_POLL_FDS - n);
n += tcp_n;
}
- poll(fds, n, ENGINE_POLL_MS);
+ if (poll(fds, n, poll_ms) != 0)
+ { /* an event arrived (or poll failed) - active round */
+ active = TRUE;
+ }
if (fds[0].revents & POLLIN)
{
+ active = TRUE;
drain_wake(this);
}
@@ -559,6 +589,7 @@ static void *engine_thread(private_direct_engine_t *this)
* roam event; the flow lists belong to this thread) */
if (__atomic_exchange_n(&reprotect_pending, FALSE, __ATOMIC_ACQ_REL))
{
+ active = TRUE;
this->udp->reprotect(this->udp);
if (this->tcp_active)
{
@@ -592,6 +623,7 @@ static void *engine_thread(private_direct_engine_t *this)
this->queue_full = FALSE;
this->q_lock->unlock(this->q_lock);
+ active = TRUE;
if (bound)
{
dispatch_packet(this, packet);
@@ -621,9 +653,29 @@ static void *engine_thread(private_direct_engine_t *this)
this->tcp->check_timers(this->tcp);
}
- if (++sweeps % 16 == 0)
- { /* roughly every four seconds */
- this->udp->sweep(this->udp);
+ { /* adapt the cadence after the work of this round is known */
+ uint64_t now = monotonic_ms();
+
+ if (now - last_sweep >= ENGINE_SWEEP_MS)
+ { /* wall-clock based, so the adaptive timeout does not
+ * change the flow-expiry cadence */
+ this->udp->sweep(this->udp);
+ last_sweep = now;
+ }
+ if (active)
+ {
+ poll_ms = ENGINE_POLL_MS;
+ }
+ else
+ {
+ uint32_t cap = this->tcp_active ? ENGINE_IDLE_TCP_MS : ENGINE_IDLE_POLL_MS;
+
+ poll_ms *= 2;
+ if (poll_ms > cap)
+ {
+ poll_ms = cap;
+ }
+ }
}
}
return NULL;
diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_engine.h b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_engine.h
index 68ca55c54..62f440ca9 100644
--- a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_engine.h
+++ b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_engine.h
@@ -50,6 +50,7 @@ typedef struct direct_stats_t {
uint64_t drop_frag;
uint64_t drop_malformed;
uint64_t relay_errors;
+ uint64_t cooldown_hits;
uint64_t udp_flows, tcp_flows;
uint64_t tcp_accepts;
size_t set_v4, set_v6, set_ft_v4, set_ft_v6;
diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_tcp.c b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_tcp.c
index 3ca213e8a..fa8c7fa01 100644
--- a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_tcp.c
+++ b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_tcp.c
@@ -58,8 +58,43 @@
*/
#define MAX_BUFFERED (256 * 1024)
+/**
+ * Give up on a direct connect after this many milliseconds (a destination
+ * that silently drops SYNs would otherwise hang the app's connection
+ * forever: lwIP already completed the handshake towards the kernel)
+ */
+#define CONNECT_TIMEOUT_MS 10000
+
+/**
+ * Negative cache: after NEG_FAILS consecutive failed connect cycles a
+ * destination is refused outright (RST without a socket) for
+ * NEG_COOLDOWN_MS, so an app hammering a dead host cannot burn sockets,
+ * JNI protect() roundtrips and log lines. Cleared on success, on a network
+ * change (reprotect) and after NEG_EXPIRY_MS without failures.
+ */
+#define NEG_FAILS 3
+#define NEG_COOLDOWN_MS 45000
+#define NEG_EXPIRY_MS (10 * 60 * 1000)
+
+/**
+ * Minimum spacing between failure log lines for the same destination
+ */
+#define NEG_LOG_MS 5000
+
+#define NEG_SLOTS 32
+
typedef struct private_direct_tcp_t private_direct_tcp_t;
+/**
+ * One entry of the per-destination failure table (negative cache)
+ */
+typedef struct {
+ bool used;
+ struct sockaddr_storage dest;
+ uint32_t fails;
+ uint64_t last_fail, cooldown_until, last_log;
+} neg_entry_t;
+
static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err);
static lwip_err_t on_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, lwip_err_t err);
static void on_err(void *arg, lwip_err_t err);
@@ -76,8 +111,10 @@ typedef struct {
bool dead;
bool remote_eof;
bool retried;
+ uint64_t connect_started;
struct sockaddr_storage dest;
socklen_t dest_len;
+ struct sockaddr_storage origin;
uint8_t *to_sock;
size_t to_sock_len, to_sock_cap;
uint8_t *to_pcb;
@@ -112,6 +149,11 @@ struct private_direct_tcp_t {
* Live relays; only touched on the engine thread
*/
linked_list_t *relays;
+
+ /**
+ * Per-destination failure table (negative cache); engine thread only
+ */
+ neg_entry_t neg[NEG_SLOTS];
};
/**
@@ -290,6 +332,161 @@ static void format_dest(const struct sockaddr_storage *ss, char *buf, size_t len
snprintf(buf, len, "%s:%u", host, ntohs(port));
}
+/**
+ * Monotonic milliseconds
+ */
+static uint64_t now_ms(void)
+{
+ timeval_t tv;
+
+ time_monotonic(&tv);
+ return (uint64_t)tv.tv_sec * 1000 + tv.tv_usec / 1000;
+}
+
+/**
+ * Same destination (address and port)?
+ */
+static bool neg_match(const neg_entry_t *e, const struct sockaddr_storage *ss)
+{
+ if (!e->used || e->dest.ss_family != ss->ss_family)
+ {
+ return FALSE;
+ }
+ if (ss->ss_family == AF_INET6)
+ {
+ return memcmp(&((struct sockaddr_in6*)&e->dest)->sin6_addr,
+ &((struct sockaddr_in6*)ss)->sin6_addr, 16) == 0 &&
+ ((struct sockaddr_in6*)&e->dest)->sin6_port ==
+ ((struct sockaddr_in6*)ss)->sin6_port;
+ }
+ return memcmp(&((struct sockaddr_in*)&e->dest)->sin_addr,
+ &((struct sockaddr_in*)ss)->sin_addr, 4) == 0 &&
+ ((struct sockaddr_in*)&e->dest)->sin_port ==
+ ((struct sockaddr_in*)ss)->sin_port;
+}
+
+/**
+ * Whether the destination is currently refused outright (negative cache hit)
+ */
+static bool neg_active(private_direct_tcp_t *this, const struct sockaddr_storage *ss,
+ uint64_t now)
+{
+ size_t i;
+
+ for (i = 0; i < NEG_SLOTS; i++)
+ {
+ if (neg_match(&this->neg[i], ss))
+ {
+ if (now < this->neg[i].cooldown_until)
+ {
+ return TRUE;
+ }
+ return FALSE;
+ }
+ }
+ return FALSE;
+}
+
+/**
+ * Log gating: at most one line per destination per NEG_LOG_MS
+ */
+static bool neg_should_log(private_direct_tcp_t *this, const struct sockaddr_storage *ss,
+ uint64_t now)
+{
+ size_t i;
+
+ for (i = 0; i < NEG_SLOTS; i++)
+ {
+ if (neg_match(&this->neg[i], ss))
+ {
+ if (now - this->neg[i].last_log >= NEG_LOG_MS)
+ {
+ this->neg[i].last_log = now;
+ return TRUE;
+ }
+ return FALSE;
+ }
+ }
+ return TRUE;
+}
+
+/**
+ * Record a failed connect cycle. Returns TRUE when the destination just
+ * entered its cooldown (the caller may log/attribute the owner once here).
+ */
+static bool neg_record_fail(private_direct_tcp_t *this, const struct sockaddr_storage *ss,
+ uint64_t now)
+{
+ neg_entry_t *entry = NULL, *oldest = &this->neg[0];
+ size_t i;
+
+ for (i = 0; i < NEG_SLOTS; i++)
+ {
+ if (this->neg[i].used)
+ {
+ if (neg_match(&this->neg[i], ss))
+ {
+ entry = &this->neg[i];
+ break;
+ }
+ if (this->neg[i].last_fail < oldest->last_fail)
+ {
+ oldest = &this->neg[i];
+ }
+ }
+ else if (!entry)
+ {
+ entry = &this->neg[i];
+ }
+ }
+ if (!entry)
+ { /* full: recycle the least recently failed slot */
+ entry = oldest;
+ }
+ if (!entry->used || !neg_match(entry, ss))
+ {
+ memset(entry, 0, sizeof(*entry));
+ entry->used = TRUE;
+ memcpy(&entry->dest, ss, sizeof(*ss));
+ }
+ else if (now - entry->last_fail > NEG_EXPIRY_MS)
+ { /* silence long enough: start over */
+ entry->fails = 0;
+ }
+ entry->fails++;
+ entry->last_fail = now;
+ if (entry->fails >= NEG_FAILS && now >= entry->cooldown_until)
+ {
+ entry->cooldown_until = now + NEG_COOLDOWN_MS;
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Forget failures for a destination after a successful connect
+ */
+static void neg_clear(private_direct_tcp_t *this, const struct sockaddr_storage *ss)
+{
+ size_t i;
+
+ for (i = 0; i < NEG_SLOTS; i++)
+ {
+ if (neg_match(&this->neg[i], ss))
+ {
+ this->neg[i].used = FALSE;
+ }
+ }
+}
+
+/**
+ * Drop the whole failure table (network change, engine reset)
+ */
+static void neg_clear_all(private_direct_tcp_t *this)
+{
+ memset(this->neg, 0, sizeof(this->neg));
+}
+
/**
* Open a protected, network-pinned non-blocking socket and start connecting
* it to the given destination. Returns -1 on failure.
@@ -304,7 +501,8 @@ static int relay_connect_socket(const struct sockaddr_storage *ss, socklen_t len
return -1;
}
if (!charonservice_protect_direct_socket(fd))
- {
+ { /* name the cause in the caller's failure log */
+ errno = EACCES;
close(fd);
return -1;
}
@@ -323,11 +521,12 @@ static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err)
{
private_direct_tcp_t *this = arg;
tcp_relay_t *relay;
- struct sockaddr_storage ss;
+ struct sockaddr_storage ss, org;
struct sockaddr_in *sin = (struct sockaddr_in*)&ss;
struct sockaddr_in6 *sin6 = (struct sockaddr_in6*)&ss;
socklen_t ss_len;
char dest[INET6_ADDRSTRLEN + 8];
+ uint64_t now = now_ms();
int fd;
DIRECT_STATS_ADD(this->stats->tcp_accepts, 1);
@@ -349,12 +548,49 @@ static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err)
}
format_dest(&ss, dest, sizeof(dest));
+ /* the kernel side peer: the app's socket endpoint (source address and
+ * port), needed to attribute the flow to an owning app */
+ memset(&org, 0, sizeof(org));
+ if (IP_IS_V6_VAL(newpcb->remote_ip))
+ {
+ struct sockaddr_in6 *o6 = (struct sockaddr_in6*)&org;
+
+ o6->sin6_family = AF_INET6;
+ memcpy(&o6->sin6_addr, &ip_2_ip6(&newpcb->remote_ip)->addr, 16);
+ o6->sin6_port = htons(newpcb->remote_port);
+ }
+ else
+ {
+ struct sockaddr_in *o4 = (struct sockaddr_in*)&org;
+
+ o4->sin_family = AF_INET;
+ memcpy(&o4->sin_addr, &ip_2_ip4(&newpcb->remote_ip)->addr, 4);
+ o4->sin_port = htons(newpcb->remote_port);
+ }
+
+ if (neg_active(this, &ss, now))
+ { /* this destination recently failed repeatedly - refuse without
+ * burning a socket and a protect() roundtrip; the app keeps
+ * getting its fast RST */
+ DIRECT_STATS_ADD(this->stats->cooldown_hits, 1);
+ tcp_abort(newpcb);
+ return ERR_ABRT;
+ }
+
fd = relay_connect_socket(&ss, ss_len);
if (fd < 0)
{
DIRECT_STATS_ADD(this->stats->relay_errors, 1);
- DBG1(DBG_NET, "direct tcp: accept socket setup failed dst=%s: %s",
- dest, strerror(errno));
+ if (neg_record_fail(this, &ss, now))
+ {
+ charonservice_log_connection_owner((struct sockaddr*)&org,
+ (struct sockaddr*)&ss, dest);
+ }
+ if (neg_should_log(this, &ss, now))
+ {
+ DBG1(DBG_NET, "direct tcp: accept socket setup failed dst=%s: %s",
+ dest, strerror(errno));
+ }
tcp_abort(newpcb);
return ERR_ABRT;
}
@@ -363,9 +599,11 @@ static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err)
.m = this,
.pcb = newpcb,
.fd = fd,
+ .connect_started = now,
.dest_len = ss_len,
);
memcpy(&relay->dest, &ss, ss_len);
+ memcpy(&relay->origin, &org, sizeof(org));
/* keep our own segments within the TUN MTU regardless of TCP_MSS */
if (newpcb->mss > this->mtu - 72)
{
@@ -621,6 +859,9 @@ METHOD(direct_tcp_t, reprotect, void,
tcp_relay_t *relay;
enumerator_t *enumerator;
+ /* the network changed: whatever failed before may work now */
+ neg_clear_all(this);
+
enumerator = this->relays->create_enumerator(this->relays);
while (enumerator->enumerate(enumerator, &relay))
{
@@ -689,45 +930,74 @@ METHOD(direct_tcp_t, process_poll, void,
relay_destroy(this, relay, TRUE);
continue;
}
- if (!relay->connected && (re & (POLLOUT | POLLERR | POLLHUP)))
+ if (!relay->connected)
{
- int err = 0;
- socklen_t len = sizeof(err);
- char dest[INET6_ADDRSTRLEN + 8];
+ uint64_t now = now_ms();
+ bool timed_out = now - relay->connect_started > CONNECT_TIMEOUT_MS;
- getsockopt(relay->fd, SOL_SOCKET, SO_ERROR, &err, &len);
- if (err == 0)
+ if (re & (POLLOUT | POLLERR | POLLHUP) || timed_out)
{
- relay->connected = TRUE;
- }
- else
- {
- format_dest(&relay->dest, dest, sizeof(dest));
- if (!relay->retried)
- { /* one retry on a fresh socket: the first attempt may have
- * been pinned to a network that vanished mid-roam (its
- * EHOSTUNREACH can take over a minute to surface, leaving
- * the connection hanging) */
- int nfd;
+ int err = 0;
+ socklen_t len = sizeof(err);
+ char dest[INET6_ADDRSTRLEN + 8];
+ bool do_log;
- DBG1(DBG_NET, "direct tcp: connect failed dst=%s: %s, retrying on a fresh socket",
- dest, strerror(err));
- relay->retried = TRUE;
- close(relay->fd);
- nfd = relay_connect_socket(&relay->dest, relay->dest_len);
- if (nfd >= 0)
+ getsockopt(relay->fd, SOL_SOCKET, SO_ERROR, &err, &len);
+ if (timed_out && err == 0)
+ { /* the peer never answered the SYN: the kernel reports no
+ * error on a connect that is merely stuck */
+ err = ETIMEDOUT;
+ }
+ if (err == 0)
+ {
+ relay->connected = TRUE;
+ neg_clear(this, &relay->dest);
+ }
+ else
+ {
+ format_dest(&relay->dest, dest, sizeof(dest));
+ do_log = neg_should_log(this, &relay->dest, now);
+ if (!relay->retried)
+ { /* one retry on a fresh socket: the first attempt may have
+ * been pinned to a network that vanished mid-roam (its
+ * EHOSTUNREACH can take over a minute to surface, leaving
+ * the connection hanging) */
+ int nfd;
+
+ if (do_log)
+ {
+ DBG1(DBG_NET, "direct tcp: connect failed dst=%s: %s, retrying on a fresh socket",
+ dest, strerror(err));
+ }
+ relay->retried = TRUE;
+ close(relay->fd);
+ nfd = relay_connect_socket(&relay->dest, relay->dest_len);
+ if (nfd >= 0)
+ {
+ relay->fd = nfd;
+ relay->connect_started = now;
+ continue;
+ }
+ err = errno;
+ }
+ DIRECT_STATS_ADD(this->stats->relay_errors, 1);
+ if (neg_record_fail(this, &relay->dest, now))
+ { /* destination just entered its cooldown: name the app
+ * that owns the connection while its socket still
+ * exists (relay_destroy aborts the pcb below) */
+ charonservice_log_connection_owner(
+ (struct sockaddr*)&relay->origin,
+ (struct sockaddr*)&relay->dest, dest);
+ }
+ if (do_log)
{
- relay->fd = nfd;
- continue;
+ DBG1(DBG_NET, "direct tcp: connect failed dst=%s: %s",
+ dest, strerror(err));
}
- err = errno;
+ this->relays->remove_at(this->relays, enumerator);
+ relay_destroy(this, relay, TRUE);
+ continue;
}
- DIRECT_STATS_ADD(this->stats->relay_errors, 1);
- DBG1(DBG_NET, "direct tcp: connect failed dst=%s: %s",
- dest, strerror(err));
- this->relays->remove_at(this->relays, enumerator);
- relay_destroy(this, relay, TRUE);
- continue;
}
}
if (re & (POLLIN | POLLHUP))
@@ -787,6 +1057,7 @@ METHOD(direct_tcp_t, reset, void,
private_direct_tcp_t *this)
{
DIRECT_STATS_SET(this->stats->tcp_flows, 0);
+ neg_clear_all(this);
this->relays->destroy_function(this->relays, relay_free);
this->relays = linked_list_create();
}
diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/ru_set.c b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/ru_set.c
index bf4c4e460..3f124edd7 100644
--- a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/ru_set.c
+++ b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/ru_set.c
@@ -36,27 +36,13 @@ struct private_ru_set_t {
uint8_t *v6_from, *v6_to;
/**
- * Sorted force-tunnel addresses (network byte order)
+ * Sorted force-tunnel ranges (network byte order): DoT/tunnel DNS
+ * servers (single-address ranges) plus the user's prefixes
*/
- uint32_t *ft_v4;
- uint8_t *ft_v6;
+ uint32_t *ft_v4_from, *ft_v4_to;
+ uint8_t *ft_v6_from, *ft_v6_to;
};
-/**
- * Compare two 16-byte keys (network order is big-endian => memcmp is numeric)
- */
-static int cmp_v6(const void *a, const void *b)
-{
- return memcmp(a, b, 16);
-}
-
-static int cmp_v4(const void *a, const void *b)
-{
- uint32_t x = *(uint32_t*)a, y = *(uint32_t*)b;
-
- return x < y ? -1 : (x > y ? 1 : 0);
-}
-
/**
* Sort parallel from/to range arrays by the from key (insertion sort on an
* index permutation keeps the pairs together without a struct copy)
@@ -214,32 +200,17 @@ METHOD(ru_set_t, contains_v6, bool,
METHOD(ru_set_t, force_tunnel_v4, bool,
private_ru_set_t *this, uint32_t addr)
{
- size_t lo = 0, hi = this->public.ft_v4_count;
+ ssize_t i = find_v4(this->ft_v4_from, this->public.ft_v4_count, addr);
- while (lo < hi)
- {
- size_t mid = lo + (hi - lo) / 2;
-
- if (this->ft_v4[mid] == addr)
- {
- return TRUE;
- }
- if (this->ft_v4[mid] < addr)
- {
- lo = mid + 1;
- }
- else
- {
- hi = mid;
- }
- }
- return FALSE;
+ return i >= 0 && addr <= this->ft_v4_to[i];
}
METHOD(ru_set_t, force_tunnel_v6, bool,
private_ru_set_t *this, const uint8_t addr[16])
{
- return bsearch(addr, this->ft_v6, this->public.ft_v6_count, 16, cmp_v6) != NULL;
+ ssize_t i = find_v6(this->ft_v6_from, this->public.ft_v6_count, addr);
+
+ return i >= 0 && memcmp(addr, this->ft_v6_to + i * 16, 16) <= 0;
}
METHOD(ru_set_t, destroy, void,
@@ -249,15 +220,17 @@ METHOD(ru_set_t, destroy, void,
free(this->v4_to);
free(this->v6_from);
free(this->v6_to);
- free(this->ft_v4);
- free(this->ft_v6);
+ free(this->ft_v4_from);
+ free(this->ft_v4_to);
+ free(this->ft_v6_from);
+ free(this->ft_v6_to);
free(this);
}
ru_set_t *ru_set_create(uint32_t *v4_from, uint32_t *v4_to, size_t v4_count,
uint8_t *v6_from, uint8_t *v6_to, size_t v6_count,
- uint32_t *ft_v4, size_t ft_v4_count,
- uint8_t *ft_v6, size_t ft_v6_count)
+ uint32_t *ft_v4_from, uint32_t *ft_v4_to, size_t ft_v4_count,
+ uint8_t *ft_v6_from, uint8_t *ft_v6_to, size_t ft_v6_count)
{
private_ru_set_t *this;
@@ -277,8 +250,10 @@ ru_set_t *ru_set_create(uint32_t *v4_from, uint32_t *v4_to, size_t v4_count,
.v4_to = v4_to,
.v6_from = v6_from,
.v6_to = v6_to,
- .ft_v4 = ft_v4,
- .ft_v6 = ft_v6,
+ .ft_v4_from = ft_v4_from,
+ .ft_v4_to = ft_v4_to,
+ .ft_v6_from = ft_v6_from,
+ .ft_v6_to = ft_v6_to,
);
/* the lookups are binary searches in numeric (network) order; do not
@@ -290,7 +265,7 @@ ru_set_t *ru_set_create(uint32_t *v4_from, uint32_t *v4_to, size_t v4_count,
}
if (ft_v4_count > 1)
{
- qsort(this->ft_v4, ft_v4_count, sizeof(uint32_t), cmp_v4);
+ qsort_ranges_v4(this->ft_v4_from, this->ft_v4_to, ft_v4_count);
}
if (v6_count > 1)
{
@@ -298,7 +273,7 @@ ru_set_t *ru_set_create(uint32_t *v4_from, uint32_t *v4_to, size_t v4_count,
}
if (ft_v6_count > 1)
{
- qsort(this->ft_v6, ft_v6_count, 16, cmp_v6);
+ qsort_ranges_v6(this->ft_v6_from, this->ft_v6_to, ft_v6_count);
}
return &this->public;
}
diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/ru_set.h b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/ru_set.h
index a78064abd..860950d38 100644
--- a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/ru_set.h
+++ b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/ru_set.h
@@ -63,11 +63,13 @@ struct ru_set_t {
/**
* Create a set, taking ownership of the given arrays (network byte order).
- * The force-tunnel arrays are sorted by the constructor.
+ * The range arrays are sorted by the constructor; the force-tunnel ranges
+ * cover the DoT/tunnel DNS servers (single-address ranges) and any
+ * user-defined prefixes.
*/
ru_set_t *ru_set_create(uint32_t *v4_from, uint32_t *v4_to, size_t v4_count,
uint8_t *v6_from, uint8_t *v6_to, size_t v6_count,
- uint32_t *ft_v4, size_t ft_v4_count,
- uint8_t *ft_v6, size_t ft_v6_count);
+ uint32_t *ft_v4_from, uint32_t *ft_v4_to, size_t ft_v4_count,
+ uint8_t *ft_v6_from, uint8_t *ft_v6_to, size_t ft_v6_count);
#endif /** RU_SET_H_ @} */
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 89da19a08..84b9d4bed 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
@@ -41,6 +41,8 @@
Действия с профилем
Очистить поиск
Отменить
+ Готово
+ Назад
Профиль удалён
Ещё
Назад
@@ -206,20 +208,40 @@
Яндекс (напрямую)
Маршрут
Маршрутизация
- Применять правила маршрутизации
- Выбранные приложения ходят в интернет напрямую, мимо туннеля
+ RU-сети напрямую
Исключить приложения из РФ
Все приложения ru.*, Яндекс, VK, Госуслуги, банки, магазины, связь, транспорт и другие сервисы РФ
+ Всегда через VPN
+ Адреса и подсети, которые всегда идут через туннель, даже если попадают в RU-диапазоны (режим userspace)
+ Добавить адрес
+ Адрес или подсеть
+ например 130.49.213.176 или 10.8.0.0/24
+ Некорректный адрес или подсеть
+ Сохранить
+ Удалить
Наборы правил
Добавить набор
Название набора
Приложений: %1$d · Префиксов: %2$d · Слов: %3$d
Выбрать приложения
+ Выбрано: %1$d
+ Набор правил
+ Префиксы пакетов
+ Ключевые слова
+ Подробнее
+ Скрыть
Пресеты
+ Пресеты · выбрано %1$d
+ Готовые группы сервисов
+ Поиск пресетов
+ Ничего не найдено
+ %1$d префиксов
+ %1$d слов
+ нет паттернов
Наборов пока нет. Добавьте свой или используйте быстрое действие выше.
Удалить набор
Набор удалён
- Свой префикс IP
+ Свой префикс пакета
Своё ключевое слово
Маршрутизация включена
Маршрутизация выключена
@@ -229,19 +251,17 @@
IPv6 · %1$d
Обновлено %1$s
Не обновлялся
- RU IP-список (бета)
RU-сети напрямую (geoip)
Трафик к российским IP-адресам идёт мимо VPN-туннеля, напрямую
Обновить список
Обновление…
Список обновлён: %1$d диапазонов
Не удалось обновить, используется сохранённая версия
- Ещё не обновлялся. Нажмите «Обновить список».
- Обновлён %1$s · IPv4: %2$d · IPv6: %3$d
Источник: github.com/MetaCubeX/meta-rules-dat
Список статичный, обновляется с GitHub раз в сутки. Работа по IP: сервисы за иностранным CDN могут не попасть в список. Изменения применяются при переподключении.
Маршруты ОС
Userspace-роутинг (бета)
+ Userspace (бета)
Комплемент RU-списка устанавливается системными маршрутами с округлением под бюджет маршрутов. Проверенная схема, но список грубеет, а для обновления нужно переподключение.
Весь трафик входит в VPN, решение «напрямую/в туннель» принимается по пакету по точному списку, обновления применяются на лету. Напрямую идут TCP и UDP. Ping по RU-адресам напрямую не работает, прочий ICMP отбрасывается.
Проверка маршрута
@@ -449,5 +469,7 @@
Отправлено %1$s, %2$s
Идентификаторы, DNS, MTU, split-туннель
Импорт профиля
+ Расширенная диагностика
+ Периодические снимки диагностики маршрутизации во время подключения (каждые 5 минут); увеличивает расход батареи
diff --git a/src/frontends/android/app/src/main/res/values-uk/strings.xml b/src/frontends/android/app/src/main/res/values-uk/strings.xml
index 6f9dafa87..0b941b931 100644
--- a/src/frontends/android/app/src/main/res/values-uk/strings.xml
+++ b/src/frontends/android/app/src/main/res/values-uk/strings.xml
@@ -186,10 +186,16 @@
Яндекс (напряму)
Маршрут
Маршрутизація
- Застосовувати правила маршрутизації
- Вибрані застосунки ходять в інтернет напряму, повз тунель
Виключити застосунки з РФ
Усі застосунки ru.*, Яндекс, VK, Дії, банки, магазини, зв\'язок, транспорт та інші сервіси РФ
+ Завжди через VPN
+ Адреси та підмережі, які завжди йдуть через тунель, навіть якщо потрапляють у RU-діапазони (режим userspace)
+ Додати адресу
+ Адреса чи підмережа
+ наприклад 130.49.213.176 або 10.8.0.0/24
+ Некоректна адреса чи підмережа
+ Зберегти
+ Видалити
Набори правил
Додати набір
Назва набору
@@ -294,5 +300,7 @@
Toggle VPN
Connect VPN
Disconnect VPN
+ Розширена діагностика
+ Періодичні знімки діагностики маршрутизації під час з\'єднання (кожні 5 хвилин); збільшує витрату батареї
diff --git a/src/frontends/android/app/src/main/res/values-zh-rCN/strings.xml b/src/frontends/android/app/src/main/res/values-zh-rCN/strings.xml
index ff3370a6d..26ee5d50e 100644
--- a/src/frontends/android/app/src/main/res/values-zh-rCN/strings.xml
+++ b/src/frontends/android/app/src/main/res/values-zh-rCN/strings.xml
@@ -185,10 +185,16 @@
Yandex(直连)
路由
路由
- 应用路由规则
- 所选应用将绕过 VPN 隧道直接联网
排除俄罗斯应用
全部 ru.* 应用、Yandex、VK、Gosuslugi、主要银行、零售、电信、交通及其他俄罗斯服务
+ 始终通过 VPN
+ 始终经隧道路由的地址和子网,即使位于 RU 网段内(用户态模式)
+ 添加地址
+ 地址或子网
+ 例如 130.49.213.176 或 10.8.0.0/24
+ 地址或子网无效
+ 保存
+ 删除
规则集
添加规则集
规则集名称
@@ -293,5 +299,7 @@
хИЗцНвVPN
ш┐ЮцОеVPN
цЦнх╝АVPN
+ 扩展诊断
+ 连接期间每 5 分钟记录一次路由诊断快照;会增加耗电
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 86dcac465..eb1266efe 100644
--- a/src/frontends/android/app/src/main/res/values/strings.xml
+++ b/src/frontends/android/app/src/main/res/values/strings.xml
@@ -45,6 +45,8 @@
Profile actions
Clear search
Undo
+ Done
+ Back
Profile deleted
More options
Back
@@ -210,20 +212,40 @@
Yandex (direct)
Routing
Routing
- Apply routing rules
- Selected applications bypass the VPN tunnel and use the connection directly
+ RU networks direct
Exclude apps from RF companies
All ru.* apps, Yandex, VK, Gosuslugi, major banks, retail, telecom, transport and other Russian services
+ Always via VPN
+ Addresses and subnets that are always routed through the tunnel, even when they fall into RU ranges (userspace mode)
+ Add address
+ Address or subnet
+ e.g. 130.49.213.176 or 10.8.0.0/24
+ Invalid address or subnet
+ Save
+ Delete
Rule sets
Add rule set
Rule set name
Apps: %1$d · Prefixes: %2$d · Keywords: %3$d
Choose applications
+ Selected: %1$d
+ Rule set
+ Package prefixes
+ Keywords
+ Details
+ Hide details
Presets
+ Presets · %1$d selected
+ Ready-made groups of services
+ Search presets
+ Nothing found
+ %1$d prefixes
+ %1$d keywords
+ no patterns
No rule sets yet. Add one or use the quick action above.
Delete rule set
Rule set deleted
- Add IP prefix
+ Add package prefix
Add keyword
Routing on
Routing off
@@ -233,19 +255,17 @@
IPv6 · %1$d
Updated %1$s
Never updated
- RU IP list (beta)
Route RU networks directly
Traffic to Russian IP addresses bypasses the VPN tunnel and uses the connection directly (geoip:ru)
Update list
Updating…
List updated: %1$d ranges
Could not update, using the saved version
- Never updated. Tap "Update list" to download it.
- Updated %1$s · IPv4: %2$d · IPv6: %3$d
Source: github.com/MetaCubeX/meta-rules-dat
The list is static and refreshed from GitHub daily. It works by IP: services behind foreign CDNs may not be in the list. Changes apply on reconnect.
OS routes
Userspace routing (beta)
+ Userspace (beta)
The complement of the RU list is installed as system routes, rounded up to fit the route budget. Proven scheme, but the list is coarsened and a reconnect is needed to apply updates.
All traffic enters the VPN and the direct/tunnel decision is made per packet from the exact list. List updates apply on the fly. TCP and UDP go direct. Ping to RU addresses does not work directly in this mode; other ICMP is dropped.
Route check
@@ -454,5 +474,7 @@
Uploaded %1$s, %2$s
Identifiers, DNS, MTU, split tunneling
Import profile
+ Extended diagnostics
+ Periodic routing diagnostics snapshots while connected (every 5 min); increases battery use