diff --git a/src/frontends/android/app/src/main/AndroidManifest.xml b/src/frontends/android/app/src/main/AndroidManifest.xml index 154446118..4bc27b8b7 100644 --- a/src/frontends/android/app/src/main/AndroidManifest.xml +++ b/src/frontends/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,7 @@ + @@ -339,6 +340,17 @@ + + + + + + + + + diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/BootReceiver.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/BootReceiver.kt new file mode 100644 index 000000000..4806ffb5e --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/BootReceiver.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import org.strongswan.android.logic.autoconnect.AutoConnectManager +import org.strongswan.android.logic.scenarios.ScenarioEngine + +/** + * Picks the automation up again after a reboot or a change of the clock. + * + * Starting the process runs the application's start-up (network rules, scenarios), + * but alarms do not survive a reboot, and a time or time zone change moves the + * edges of the scenario time windows. This receiver re-arms what the alarms + * carried and lets the rules decide again. It never starts the tunnel by itself: + * the rules and scenarios do, and a profile without a saved password stays off. + */ +class BootReceiver : BroadcastReceiver() +{ + override fun onReceive(context: Context, intent: Intent?) + { + val action = intent?.action ?: return + DiagLog.i(TAG, "system event: $action") + if (action == Intent.ACTION_BOOT_COMPLETED) + { + PauseManager.rearmAfterRestart(context) + } + /* start() re-arms the time-window alarm for the current clock and zone */ + ScenarioEngine.start(context) + AutoConnectManager.getInstance(context).reevaluate() + val pending = goAsync() + ScenarioEngine.evaluateWhenReady(context) { pending.finish() } + } + + private companion object + { + const val TAG = "BootReceiver" + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/NetworkManager.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/NetworkManager.java index da1ed9853..44dce11cd 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/NetworkManager.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/NetworkManager.java @@ -25,6 +25,7 @@ import android.net.Network; import android.net.NetworkRequest; import android.os.Build; +import java.util.HashSet; import java.util.LinkedList; public class NetworkManager extends BroadcastReceiver implements Runnable @@ -33,7 +34,9 @@ public class NetworkManager extends BroadcastReceiver implements Runnable private volatile boolean mRegistered; private ConnectivityManager.NetworkCallback mCallback; private Thread mEventNotifier; - private int mConnectedNetworks = 0; + /* a set, not a counter: a repeated onAvailable or a missed onLost cannot + * make it drift away from the networks that really exist */ + private final HashSet mConnectedNetworks = new HashSet<>(); private LinkedList mEvents = new LinkedList<>(); public NetworkManager(Context context) @@ -51,7 +54,7 @@ public class NetworkManager extends BroadcastReceiver implements Runnable { /* we expect this to be called if connected to at least one network during * callback registration */ - mConnectedNetworks += 1; + mConnectedNetworks.add(network); mEvents.addLast(true); NetworkManager.this.notifyAll(); } @@ -63,8 +66,8 @@ public class NetworkManager extends BroadcastReceiver implements Runnable synchronized (NetworkManager.this) { /* in particular mobile connections are disconnected overlapping with WiFi */ - mConnectedNetworks -= 1; - mEvents.addLast(mConnectedNetworks > 0); + mConnectedNetworks.remove(network); + mEvents.addLast(!mConnectedNetworks.isEmpty()); NetworkManager.this.notifyAll(); } } @@ -74,7 +77,11 @@ public class NetworkManager extends BroadcastReceiver implements Runnable public void Register() { - mEvents.clear(); + synchronized (this) + { + mEvents.clear(); + mConnectedNetworks.clear(); + } mRegistered = true; mEventNotifier = new Thread(this); mEventNotifier.start(); @@ -174,6 +181,12 @@ public class NetworkManager extends BroadcastReceiver implements Runnable break; } connected = mEvents.removeFirst(); + /* a burst of identical states (several networks coming up at once) + * is one change for charon, not one reconnect attempt each */ + while (!mEvents.isEmpty() && mEvents.getFirst() == connected) + { + mEvents.removeFirst(); + } } /* call the native parts without holding the lock */ networkChanged(!connected); diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt index 9bf489faf..bf5977be6 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt @@ -229,6 +229,32 @@ object PauseManager alarmManager(ctx).cancel(alarmIntent(ctx)) } + /** + * After a reboot: alarms do not survive it, so a pause that was running would + * never end. Re-arms the alarm, or ends the pause right away if its time is up. + */ + @JvmStatic + fun rearmAfterRestart(context: Context) + { + val ctx = context.applicationContext + val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) + val until = prefs.getLong(PREF_UNTIL, 0L) + val profile = prefs.getString(PREF_PROFILE, null) + if (profile == null || until == 0L) + { + return + } + if (until > System.currentTimeMillis()) + { + DiagLog.i(TAG, "pause survives the restart, re-arming its alarm") + store(ctx, until, profile) + } + else + { + onAlarm(ctx) + } + } + private fun store(ctx: Context, until: Long, profileUuid: String) { PreferenceManager.getDefaultSharedPreferences(ctx).edit() diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/ScheduledJobQueue.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/ScheduledJobQueue.kt new file mode 100644 index 000000000..35262376f --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/ScheduledJobQueue.kt @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic + +import java.util.PriorityQueue + +/** + * The job queue behind [Scheduler]: charon's timers, ordered by due time, and the + * bookkeeping of the single alarm that wakes the device for the earliest one. + * Times are on any monotonic clock (the scheduler passes elapsedRealtime). + * + * Jobs due within [coalesceMs] of each other share one alarm: a job may run up to + * [coalesceMs] early, never late. charon only schedules jobs longer than 3 s ahead, + * so 2 s is harmless, and every alarm saved is one more slot in the Doze quota of + * allow-while-idle alarms that the keepalive needs. + * + * Not thread-safe; the caller synchronises. + */ +class ScheduledJobQueue(private val coalesceMs: Long = COALESCE_MS) +{ + private class Job(val id: String, val time: Long) : Comparable + { + override fun compareTo(other: Job): Int = time.compareTo(other.time) + } + + private val jobs = PriorityQueue() + private var armed = NONE + + /** Due time the alarm is currently set for, or [NONE]. */ + val armedAt: Long get() = armed + + val size: Int get() = jobs.size + + /** + * Queues a job. + * + * @return true if the alarm has to be (re)armed for [nextAlarm]; false if the + * armed alarm already covers the job + */ + fun add(id: String, time: Long): Boolean + { + jobs.add(Job(id, time)) + if (armed == NONE || time < armed - coalesceMs) + { + armed = time + return true + } + return false + } + + /** + * Removes the jobs that are due at [now] (or within the coalescing window). + * The alarm is considered fired: [nextAlarm] says whether to arm another one. + */ + fun popDue(now: Long): List + { + val limit = now + coalesceMs + val due = ArrayList() + while (true) + { + val head = jobs.peek() ?: break + if (head.time > limit) + { + break + } + due.add(jobs.remove().id) + } + armed = jobs.peek()?.time ?: NONE + return due + } + + /** Due time of the earliest queued job, or [NONE]; the time to arm after [popDue]. */ + fun nextAlarm(): Long = jobs.peek()?.time ?: NONE + + fun clear() + { + jobs.clear() + armed = NONE + } + + companion object + { + const val NONE = -1L + const val COALESCE_MS = 2000L + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/Scheduler.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/Scheduler.java index 1dcc761b2..9353e3375 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/Scheduler.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/Scheduler.java @@ -24,10 +24,10 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.os.Build; +import android.os.SystemClock; import android.util.Log; -import java.util.ArrayList; -import java.util.PriorityQueue; +import java.util.List; import java.util.UUID; import androidx.annotation.RequiresApi; @@ -38,14 +38,18 @@ public class Scheduler extends BroadcastReceiver private final String TAG = Scheduler.class.getSimpleName(); private final Context mContext; private final AlarmManager mManager; - private final PriorityQueue mJobs; + /* due times are elapsedRealtime: unlike RTC they do not jump when the clock + * is set (NTP, manual change, time zone), which would delay keepalives and + * rekeyings by hours */ + private final ScheduledJobQueue mJobs; + private boolean mExactLogged; @SuppressLint("UnspecifiedRegisterReceiverFlag") public Scheduler(Context context) { mContext = context; mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE); - mJobs = new PriorityQueue<>(); + mJobs = new ScheduledJobQueue(ScheduledJobQueue.COALESCE_MS); IntentFilter filter = new IntentFilter(); filter.addAction(EXECUTE_JOB); @@ -108,7 +112,7 @@ public class Scheduler extends BroadcastReceiver * alarms instead of crashing with a SecurityException (connectivity then * only degrades slightly while timers may be delayed in doze mode). * - * @param time wakeup time based on RTC + * @param time wakeup time based on elapsedRealtime */ @RequiresApi(api = Build.VERSION_CODES.M) private void scheduleAlarm(long time) @@ -119,21 +123,26 @@ public class Scheduler extends BroadcastReceiver { exact = mManager.canScheduleExactAlarms(); } + if (!mExactLogged) + { /* once per run, for support reports */ + mExactLogged = true; + DiagLog.i(TAG, "keepalive alarms are " + (exact ? "exact" : "inexact (exact alarms not allowed)")); + } try { if (exact) { - mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending); + mManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending); } else { - mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending); + mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending); } } catch (SecurityException e) { /* permission might have been revoked, or OEM restrictions apply */ Log.w(TAG, "Falling back to inexact alarm after SecurityException"); - mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending); + mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending); } } @@ -149,12 +158,10 @@ public class Scheduler extends BroadcastReceiver { synchronized (this) { - ScheduledJob job = new ScheduledJob(id, System.currentTimeMillis() + ms); - mJobs.add(job); - - if (job == mJobs.peek()) - { /* update the alarm if the job has to be executed before all others */ - scheduleAlarm(job.Time); + long time = SystemClock.elapsedRealtime() + ms; + if (mJobs.add(id, time)) + { /* the job is due before the armed alarm: move the alarm */ + scheduleAlarm(time); } } } @@ -163,30 +170,21 @@ public class Scheduler extends BroadcastReceiver @Override public void onReceive(Context context, Intent intent) { - ArrayList jobs = new ArrayList<>(); - long now = System.currentTimeMillis(); + List jobs; synchronized (this) { - ScheduledJob job = mJobs.peek(); - while (job != null) + jobs = mJobs.popDue(SystemClock.elapsedRealtime()); + long next = mJobs.nextAlarm(); + if (next != ScheduledJobQueue.NONE) { - if (job.Time > now) - { - break; - } - jobs.add(mJobs.remove()); - job = mJobs.peek(); - } - if (job != null) - { - scheduleAlarm(job.Time); + scheduleAlarm(next); } } - for (ScheduledJob job : jobs) + for (String id : jobs) { - executeJob(job.Id); + executeJob(id); } } @@ -196,25 +194,4 @@ public class Scheduler extends BroadcastReceiver * @param id job ID */ public native void executeJob(String id); - - /** - * Keep track of scheduled jobs. - */ - private static class ScheduledJob implements Comparable - { - String Id; - long Time; - - ScheduledJob(String id, long time) - { - Id = id; - Time = time; - } - - @Override - public int compareTo(ScheduledJob o) - { - return Long.compare(Time, o.Time); - } - } } 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 238ec90f1..2aa11947f 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 @@ -232,6 +232,7 @@ public class WatchdogReceiver extends BroadcastReceiver return; case RESTART: { + boolean autoRestart = uuid == null; if (uuid == null) { uuid = resolveAutoProfile(context, prefs); @@ -260,6 +261,10 @@ public class WatchdogReceiver extends BroadcastReceiver * once, which is harmless (retry flag set, same profile). */ Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it"); DiagLog.i(TAG, "restarting the VPN process"); + if (autoRestart) + { /* this process does not remember the session was auto-started */ + AutoConnectManager.getInstance(context).markAutoSessionRestored(); + } Intent start = new Intent(context, CharonVpnService.class); start.putExtra(VpnProfileDataSource.KEY_UUID, uuid); start.putExtra(CharonVpnService.KEY_IS_RETRY, 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 961c481e6..b450f6a77 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 @@ -91,6 +91,8 @@ class AutoConnectManager private constructor( private var currentNetwork: NetworkKind? = null /* marks the session this manager started itself */ private val autoSession = AtomicBoolean(false) + /* an auto session was restored by the watchdog and its tunnel is not up yet */ + @Volatile private var awaitingRestoredTunnel = false /* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */ private var lastParkAt = 0L @@ -123,10 +125,28 @@ class AutoConnectManager private constructor( } } + /** Network scenarios need the network events even when the network rules are off. */ + private fun scenariosNeedNetwork(): Boolean = + org.strongswan.android.logic.scenarios.ScenarioEngine.needsNetworkEvents(context) + + /** Called when the scenarios changed: starts or stops the network listening to fit. */ + fun scenariosChanged() + { + config = loadConfig() + if (config.enabled || scenariosNeedNetwork()) + { + start() + } + else + { + stop() + } + } + fun start() { config = loadConfig() - if (!config.enabled) + if (!config.enabled && !scenariosNeedNetwork()) { return } @@ -186,8 +206,8 @@ class AutoConnectManager private constructor( } else { - stop() autoSession.set(false) + scenariosChanged() } } @@ -214,8 +234,8 @@ class AutoConnectManager private constructor( } else { - stop() autoSession.set(false) + scenariosChanged() } } @@ -236,12 +256,28 @@ class AutoConnectManager private constructor( override fun stateChanged() { val state = service?.state ?: return - if (state == VpnStateService.State.DISABLED) + if (state != VpnStateService.State.DISABLED) + { + awaitingRestoredTunnel = false + } + else if (!awaitingRestoredTunnel) { autoSession.set(false) } } + /** + * The watchdog brought an auto session back in a fresh process, whose memory + * knows nothing of it. Mark it as auto-started again, so it parks on a trusted + * Wi-Fi instead of running all night; the DISABLED state of the not yet + * started service must not clear the mark. + */ + fun markAutoSessionRestored() + { + awaitingRestoredTunnel = true + autoSession.set(true) + } + /** * Pure decision function, unit-testable. */ @@ -268,8 +304,8 @@ class AutoConnectManager private constructor( private fun evaluate() { - if (!running.get()) - { + if (!running.get() || !config.enabled) + { /* running only for the scenarios: they decide, not the network rules */ return } val svc = service ?: return diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt index 200a725e6..6105dc9d7 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt @@ -25,10 +25,12 @@ import android.content.IntentFilter import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.net.VpnService +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.PowerManager +import android.os.SystemClock import android.telephony.TelephonyManager import android.util.Log import androidx.preference.PreferenceManager @@ -162,6 +164,9 @@ object ScenarioEngine private const val TAG = "Scenarios" private const val FILE_NAME = "scenarios.json" private const val POLL_MS = 4000L + /* how long a broadcast waits for the VPN state service of a fresh process */ + private const val SERVICE_WAIT_MS = 8000L + private const val SERVICE_POLL_MS = 200L /* the foreground app did not change for a minute: poll half as often */ private const val SLOW_AFTER_POLLS = 15 private const val SLOW_POLL_MS = 8000L @@ -193,9 +198,20 @@ object ScenarioEngine _list.value = list persist(context) start(context) + org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).scenariosChanged() evaluate(context) } + /** Whether an enabled scenario reacts to Wi-Fi, mobile network or roaming changes. */ + @JvmStatic + fun needsNetworkEvents(context: Context): Boolean + { + load(context.applicationContext) + return _list.value.any { + it.enabled && (it.trigger == ScenarioTrigger.WIFI || it.trigger == ScenarioTrigger.MOBILE || it.trigger == ScenarioTrigger.ROAMING) + } + } + /** Starts listening (from the application); idempotent. */ @JvmStatic fun start(context: Context) @@ -289,6 +305,39 @@ object ScenarioEngine } } + /** + * Decides again once the VPN state service exists. A broadcast that starts a + * fresh process arrives before the managers bound the service; evaluating + * then would silently drop the event. Gives up after [SERVICE_WAIT_MS] + * (the next network or alarm event evaluates again) and always calls [done]. + */ + @JvmStatic + fun evaluateWhenReady(context: Context, done: Runnable?) + { + val app = context.applicationContext + val deadline = SystemClock.elapsedRealtime() + SERVICE_WAIT_MS + val check = object : Runnable + { + override fun run() + { + if (VpnStateService.getLiveInstance() == null && SystemClock.elapsedRealtime() < deadline) + { + handler.postDelayed(this, SERVICE_POLL_MS) + return + } + try + { + evaluate(app) + } + finally + { + done?.run() + } + } + } + handler.post(check) + } + /** Network, whitelist or alarm: decide again. */ @JvmStatic fun evaluate(context: Context) @@ -431,10 +480,12 @@ object ScenarioEngine { return } + /* without a live service nothing can be put back: keep the record so the + * next evaluation (the receiver waits for the service) still restores */ + val service = VpnStateService.getLiveInstance() ?: return val wasOn = prefs.getBoolean(PREF_WAS_ON, false) val wasProfile = prefs.getString(PREF_WAS_PROFILE, null) prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply() - val service = VpnStateService.getLiveInstance() ?: return DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)") if (PauseManager.isPaused(context)) { @@ -562,12 +613,22 @@ object ScenarioEngine { c.add(Calendar.DAY_OF_YEAR, 1) } + /* same choice as PauseManager: exact when the grant allows it */ + val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms() try { - am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) + if (exact) + { + am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) + } + else + { + am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) + } } catch (_: SecurityException) - { + { /* revoked in between: an inexact alarm is still better than none */ + runCatching { am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) } } } @@ -673,6 +734,7 @@ class ScenarioReceiver : BroadcastReceiver() override fun onReceive(context: Context, intent: Intent?) { ScenarioEngine.start(context) - ScenarioEngine.evaluate(context) + val pending = goAsync() + ScenarioEngine.evaluateWhenReady(context) { pending.finish() } } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/stats/TrafficHistory.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/stats/TrafficHistory.kt index 087f8b07c..b8fe83571 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/stats/TrafficHistory.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/stats/TrafficHistory.kt @@ -15,6 +15,8 @@ package org.strongswan.android.logic.stats import android.content.Context +import android.os.PowerManager +import android.os.SystemClock import android.util.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -37,7 +39,8 @@ data class DayTraffic(val day: String, val vpn: Long, val direct: Long) /** * Daily traffic history (F_Stats). While the tunnel is up the daemon - * counters are sampled every [SAMPLE_S] seconds and the growth is added to + * counters are sampled every [SAMPLE_S] seconds (every [SAMPLE_OFF_S] with + * the screen off: the counters are cumulative, so nothing is lost) and the growth is added to * the current day; the counters restart with every connection, so a drop * starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json. */ @@ -46,12 +49,21 @@ object TrafficHistory private const val TAG = "TrafficHistory" private const val FILE_NAME = "traffic.json" private const val SAMPLE_S = 15L + private const val SAMPLE_OFF_S = 60L + /* the history lives in memory and is written out this often (not on every + * sample: a full JSON rewrite every 15 s is flash wear for nothing); a + * killed process loses at most this much of the statistics */ + internal const val SAVE_EVERY_MS = 5 * 60 * 1000L private const val KEEP_DAYS = 120 private val executor = Executors.newSingleThreadScheduledExecutor() private var task: ScheduledFuture<*>? = null private var lastVpn = -1L private var lastDirect = -1L + private var lastSampleAt = 0L + private var dirty = false + private var lastSavedAt = 0L + private var lastSavedDay: String? = null @Volatile private var loaded = false private val days = LinkedHashMap() private var firstDay: String? = null @@ -86,11 +98,38 @@ object TrafficHistory val running = task ?: return running.cancel(false) task = null - executor.execute { sample(app) } + executor.execute { + sample(app, force = true) + flush(app) + } } - private fun sample(context: Context) + /** Writes the history out now if it changed since the last write. */ + @Synchronized + fun flush(context: Context) { + if (dirty) + { + save(context) + } + } + + /** Whether a changed history has to be written now: periodically and when the day turns. */ + internal fun saveDue(dirty: Boolean, now: Long, lastSavedAt: Long, dayChanged: Boolean): Boolean = + dirty && (dayChanged || now - lastSavedAt >= SAVE_EVERY_MS) + + private fun screenOn(context: Context): Boolean = + (context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive != false + + private fun sample(context: Context, force: Boolean = false) + { + val now = SystemClock.elapsedRealtime() + val screen = screenOn(context) + if (!force && !screen && now - lastSampleAt < SAMPLE_OFF_S * 1000L - 1000L) + { /* screen off: a sample a minute is enough, the counters accumulate */ + return + } + lastSampleAt = now val traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull() val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return /* direct bytes only exist with the userspace router (s[3]) */ @@ -121,9 +160,16 @@ object TrafficHistory firstDay = key } prune() - save(context) + dirty = true + if (saveDue(dirty, SystemClock.elapsedRealtime(), lastSavedAt, lastSavedDay != null && lastSavedDay != key)) + { + save(context) + } _changes.value = System.currentTimeMillis() - org.strongswan.android.ui.widget.ShxWidgetState.refresh(context) + if (screenOn(context)) + { /* nobody sees the widget with the screen off */ + org.strongswan.android.ui.widget.ShxWidgetState.refresh(context) + } } /** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */ @@ -205,6 +251,9 @@ object TrafficHistory file.writeText(json.toString()) tmp.delete() } + dirty = false + lastSavedAt = SystemClock.elapsedRealtime() + lastSavedDay = dayKey(System.currentTimeMillis()) } catch (e: Exception) { 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 9b90b32b5..6b343bb6c 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 @@ -797,10 +797,9 @@ static void *engine_thread(private_direct_engine_t *this) { active = TRUE; this->udp->reprotect(this->udp); - if (this->tcp_active) - { - this->tcp->reprotect(this->tcp); - } + /* also while lwIP is dormant: this clears the negative cache of + * failed direct connects, which must not survive a roam */ + this->tcp->reprotect(this->tcp); /* the network changed: whatever was dead for direct connects * may work again - drop the tunnel fallbacks with the rest of * the failure state (tcp->reprotect clears the neg table) */ @@ -812,8 +811,22 @@ static void *engine_thread(private_direct_engine_t *this) this->bind_lock->unlock(this->bind_lock); if (!bound && this->unbound_flushed) - { /* TUN still gone and already flushed */ + { /* TUN still gone and already flushed: nothing to do until the + * re-bind wakes us, so back off like an idle round instead of + * polling at the active cadence all through a re-authentication */ drain_queue_nobind(this); + if (active) + { + poll_ms = ENGINE_POLL_MS; + } + else + { + poll_ms *= 2; + if (poll_ms > ENGINE_IDLE_POLL_MS) + { + poll_ms = ENGINE_IDLE_POLL_MS; + } + } continue; } @@ -862,6 +875,12 @@ static void *engine_thread(private_direct_engine_t *this) { this->tcp->process_poll(this->tcp, fds + 1 + udp_n, tcp_n); this->tcp->check_timers(this->tcp); + if (!active && this->tcp->flow_count(this->tcp) == 0) + { /* a full idle round with no TCP flow left: let lwIP go dormant + * again (the next TCP packet wakes it, its timers catch up), + * so the idle cadence stretches back to ENGINE_IDLE_POLL_MS */ + this->tcp_active = FALSE; + } } { /* adapt the cadence after the work of this round is known */ 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 830eb33c0..e62230e44 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 @@ -966,6 +966,12 @@ METHOD(direct_tcp_t, check_timers, void, enumerator->destroy(enumerator); } +METHOD(direct_tcp_t, flow_count, size_t, + private_direct_tcp_t *this) +{ + return this->relays->get_count(this->relays); +} + METHOD(direct_tcp_t, set_mtu, void, private_direct_tcp_t *this, uint16_t mtu) { @@ -1290,6 +1296,7 @@ direct_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine, .public = { .handle_packet = _handle_packet, .check_timers = _check_timers, + .flow_count = _flow_count, .set_mtu = _set_mtu, .reprotect = _reprotect, .fill_poll = _fill_poll, diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/ScheduledJobQueueTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/ScheduledJobQueueTest.kt new file mode 100644 index 000000000..2b619ffb9 --- /dev/null +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/ScheduledJobQueueTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class ScheduledJobQueueTest +{ + @Test + fun firstJobArmsTheAlarm() + { + val q = ScheduledJobQueue(2000) + assertThat(q.add("a", 10_000)).isTrue() + assertThat(q.armedAt).isEqualTo(10_000) + } + + @Test + fun laterJobDoesNotRearm() + { + val q = ScheduledJobQueue(2000) + q.add("a", 10_000) + assertThat(q.add("b", 50_000)).isFalse() + assertThat(q.armedAt).isEqualTo(10_000) + } + + @Test + fun jobJustBeforeTheArmedAlarmReusesIt() + { + val q = ScheduledJobQueue(2000) + q.add("a", 10_000) + assertThat(q.add("b", 8_500)).isFalse() + assertThat(q.armedAt).isEqualTo(10_000) + } + + @Test + fun clearlyEarlierJobRearms() + { + val q = ScheduledJobQueue(2000) + q.add("a", 10_000) + assertThat(q.add("b", 5_000)).isTrue() + assertThat(q.armedAt).isEqualTo(5_000) + } + + @Test + fun popDueRunsJobsInOrderIncludingTheCoalescingWindow() + { + val q = ScheduledJobQueue(2000) + q.add("c", 11_900) + q.add("a", 10_000) + q.add("b", 10_500) + q.add("late", 30_000) + assertThat(q.popDue(10_000)).containsExactly("a", "b", "c") + assertThat(q.nextAlarm()).isEqualTo(30_000) + assertThat(q.armedAt).isEqualTo(30_000) + } + + @Test + fun earlyOrSpuriousWakeupKeepsTheQueue() + { + val q = ScheduledJobQueue(2000) + q.add("a", 10_000) + assertThat(q.popDue(5_000)).isEmpty() + assertThat(q.nextAlarm()).isEqualTo(10_000) + assertThat(q.armedAt).isEqualTo(10_000) + } + + @Test + fun emptyQueueLeavesNothingArmed() + { + val q = ScheduledJobQueue(2000) + q.add("a", 10_000) + assertThat(q.popDue(10_000)).containsExactly("a") + assertThat(q.nextAlarm()).isEqualTo(ScheduledJobQueue.NONE) + assertThat(q.armedAt).isEqualTo(ScheduledJobQueue.NONE) + assertThat(q.add("b", 20_000)).isTrue() + } + + @Test + fun clearForgetsTheArmedAlarm() + { + val q = ScheduledJobQueue(2000) + q.add("a", 10_000) + q.clear() + assertThat(q.size).isEqualTo(0) + assertThat(q.add("b", 40_000)).isTrue() + } +} diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/TrafficHistoryTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/TrafficHistoryTest.kt new file mode 100644 index 000000000..86837c819 --- /dev/null +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/TrafficHistoryTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.strongswan.android.logic.stats.TrafficHistory + +class TrafficHistoryTest +{ + private val every = TrafficHistory.SAVE_EVERY_MS + + @Test + fun cleanHistoryIsNeverWritten() + { + assertThat(TrafficHistory.saveDue(false, 10 * every, 0, true)).isFalse() + } + + @Test + fun changedHistoryWaitsForTheInterval() + { + assertThat(TrafficHistory.saveDue(true, every - 1, 0, false)).isFalse() + assertThat(TrafficHistory.saveDue(true, every, 0, false)).isTrue() + } + + @Test + fun dayChangeWritesAtOnce() + { + assertThat(TrafficHistory.saveDue(true, 1000, 900, true)).isTrue() + } +} diff --git a/src/frontends/android/docs/energy-plan.md b/src/frontends/android/docs/energy-plan.md index 201e1c13a..3782c1196 100644 --- a/src/frontends/android/docs/energy-plan.md +++ b/src/frontends/android/docs/energy-plan.md @@ -139,3 +139,38 @@ - minSdk 24: `setExactAndAllowWhileIdle` и ограничения точных будильников (Android 12+/13+) уже учтены в `Scheduler` и `WatchdogReceiver`; не ломать. - Релиз — по процедуре из `docs/README.md` (smoke на AVD `shx36`, выгрузка в S3 только с подтверждения). - Строки UI, если появятся (подсказки в «Батарее»), добавлять сразу во все семь `strings.xml`. + +## 7. Версия 2.19.0: надёжность и лишняя работа (по результатам разбора багов) + +**Стабильность соединения** +- `Scheduler` (будильники keepalive и rekey): `ELAPSED_REALTIME_WAKEUP` вместо `RTC_WAKEUP` — сдвиг часов (NTP, ручная смена, пояс) + больше не уводит срабатывание на часы вперёд. Очередь вынесена в `ScheduledJobQueue` (тесты): задания в окне 2 с делят один будильник + (раньше срабатывает не более чем на 2 с, поздно — никогда; charon ставит задания дольше 3 с). Каждый сэкономленный будильник — слот в квоте + allow-while-idle в Doze. Интервалы и keepalive 45 с не менялись. В `diag.log` один раз пишется, точные ли будильники. +- `BootReceiver` (`BOOT_COMPLETED`, `TIME_SET`, `TIMEZONE_CHANGED`): после перезагрузки и смены времени заново взводит будильник пауз + (раньше пауза после перезагрузки не заканчивалась никогда) и будильник окон сценариев, правила решают заново. Туннель сам не стартует; + профиль без сохранённого пароля по-прежнему не запускается. +- `ScenarioReceiver` ждёт привязки `VpnStateService` (до 8 с) через `goAsync`; раньше срабатывание свежего процесса терялось. `restoreIfApplied` + стирает запись о применённом сценарии только когда сервис есть. +- Будильник окон сценариев: точный, если разрешено (как у паузы). +- Сетевые сценарии (Wi-Fi, мобильная сеть, роуминг) слушают сеть и без включённых правил сетей (`AutoConnectManager.scenariosChanged`). +- Watchdog, поднявший туннель авто-сессии в свежем процессе, помечает сессию авто (`markAutoSessionRestored`): туннель паркуется на доверенной + Wi-Fi, а не работает всю ночь. Постоянный флаг не вводился сознательно: устаревший флаг запарковал бы туннель, запущенный вручную. +- `NetworkManager`: набор сетей вместо счётчика (повтор `onAvailable` или пропуск `onLost` не сдвигают состояние); подряд идущие одинаковые + события схлопываются в одно. + +**Расход ресурсов** +- `direct_engine.c`: `tcp_active` сбрасывается после холостого раунда без TCP-потоков (`flow_count` в `direct_tcp.c` объявлен, но не был + реализован — теперь реализован), холостой интервал снова растягивается до 4 с; ветка «TUN не привязан» наращивает интервал, а не опрашивает + 4 раза в секунду; `reprotect` очищает отрицательный кэш и при спящем lwIP. +- `TrafficHistory`: история в памяти, на диск раз в 5 мин, при остановке и смене дня; с погашенным экраном опрос раз в минуту; виджет обновляется + только при включённом экране. Теряется не более 5 мин статистики при убийстве процесса. + +**Сознательно не делали (нет выигрыша или он не стоит риска)** +- Проверка здоровья уже растягивает паузы до штатного интервала и выключена по умолчанию, отдельная «остановка на мёртвом туннеле» не нужна. +- Проверка белых списков: цепочка идёт только пока белый список обнаружен, шаг не больше 5 мин; удлинение при погашенном экране отложило бы + возврат туннеля на минуты ради долей процента. +- Сеть без процесса (`registerNetworkCallback` на `PendingIntent`) — гипотеза не проверена на устройстве. +- `UnderlyingNetworkTracker` и SSID для сценариев из кэша `NetworkMonitor` — нужен лог с реальных сетей. + +**Известное ограничение:** без «Будильников и напоминаний» keepalive в глубоком Doze остаётся под квотой; склейка заданий лишь освобождает слоты.