android: keepalive/rekey alarms on elapsedRealtime with coalescing; BootReceiver re-arms pause and scenario alarms; scenarios survive a fresh process and run without network rules; watchdog-restored auto session parks again; NetworkManager tracks a network set; idle native engine goes dormant again; traffic history written every 5 min

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-14 11:48:27 +07:00
1 parent 072674d1a8
commit 1ef8c983c7
15 files changed
+611 -76

No files matched your search

@@ -27,6 +27,7 @@
<!-- SSID is redacted in the background on Android 10+ without this. --> <!-- SSID is redacted in the background on Android 10+ without this. -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" /> <uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
@@ -339,6 +340,17 @@
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" /> <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- after a reboot or a clock change: re-arm the pause and scenario alarms, evaluate the rules again -->
<receiver
android:name=".logic.BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
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"
}
}
@@ -25,6 +25,7 @@ import android.net.Network;
import android.net.NetworkRequest; import android.net.NetworkRequest;
import android.os.Build; import android.os.Build;
import java.util.HashSet;
import java.util.LinkedList; import java.util.LinkedList;
public class NetworkManager extends BroadcastReceiver implements Runnable public class NetworkManager extends BroadcastReceiver implements Runnable
@@ -33,7 +34,9 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
private volatile boolean mRegistered; private volatile boolean mRegistered;
private ConnectivityManager.NetworkCallback mCallback; private ConnectivityManager.NetworkCallback mCallback;
private Thread mEventNotifier; 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<Network> mConnectedNetworks = new HashSet<>();
private LinkedList<Boolean> mEvents = new LinkedList<>(); private LinkedList<Boolean> mEvents = new LinkedList<>();
public NetworkManager(Context context) 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 /* we expect this to be called if connected to at least one network during
* callback registration */ * callback registration */
mConnectedNetworks += 1; mConnectedNetworks.add(network);
mEvents.addLast(true); mEvents.addLast(true);
NetworkManager.this.notifyAll(); NetworkManager.this.notifyAll();
} }
@@ -63,8 +66,8 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
synchronized (NetworkManager.this) synchronized (NetworkManager.this)
{ {
/* in particular mobile connections are disconnected overlapping with WiFi */ /* in particular mobile connections are disconnected overlapping with WiFi */
mConnectedNetworks -= 1; mConnectedNetworks.remove(network);
mEvents.addLast(mConnectedNetworks > 0); mEvents.addLast(!mConnectedNetworks.isEmpty());
NetworkManager.this.notifyAll(); NetworkManager.this.notifyAll();
} }
} }
@@ -74,7 +77,11 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
public void Register() public void Register()
{ {
mEvents.clear(); synchronized (this)
{
mEvents.clear();
mConnectedNetworks.clear();
}
mRegistered = true; mRegistered = true;
mEventNotifier = new Thread(this); mEventNotifier = new Thread(this);
mEventNotifier.start(); mEventNotifier.start();
@@ -174,6 +181,12 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
break; break;
} }
connected = mEvents.removeFirst(); 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 */ /* call the native parts without holding the lock */
networkChanged(!connected); networkChanged(!connected);
@@ -229,6 +229,32 @@ object PauseManager
alarmManager(ctx).cancel(alarmIntent(ctx)) 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) private fun store(ctx: Context, until: Long, profileUuid: String)
{ {
PreferenceManager.getDefaultSharedPreferences(ctx).edit() PreferenceManager.getDefaultSharedPreferences(ctx).edit()
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
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<Job>
{
override fun compareTo(other: Job): Int = time.compareTo(other.time)
}
private val jobs = PriorityQueue<Job>()
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<String>
{
val limit = now + coalesceMs
val due = ArrayList<String>()
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
}
}
@@ -24,10 +24,10 @@ import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter; import android.content.IntentFilter;
import android.os.Build; import android.os.Build;
import android.os.SystemClock;
import android.util.Log; import android.util.Log;
import java.util.ArrayList; import java.util.List;
import java.util.PriorityQueue;
import java.util.UUID; import java.util.UUID;
import androidx.annotation.RequiresApi; import androidx.annotation.RequiresApi;
@@ -38,14 +38,18 @@ public class Scheduler extends BroadcastReceiver
private final String TAG = Scheduler.class.getSimpleName(); private final String TAG = Scheduler.class.getSimpleName();
private final Context mContext; private final Context mContext;
private final AlarmManager mManager; private final AlarmManager mManager;
private final PriorityQueue<ScheduledJob> 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") @SuppressLint("UnspecifiedRegisterReceiverFlag")
public Scheduler(Context context) public Scheduler(Context context)
{ {
mContext = context; mContext = context;
mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE); mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
mJobs = new PriorityQueue<>(); mJobs = new ScheduledJobQueue(ScheduledJobQueue.COALESCE_MS);
IntentFilter filter = new IntentFilter(); IntentFilter filter = new IntentFilter();
filter.addAction(EXECUTE_JOB); filter.addAction(EXECUTE_JOB);
@@ -108,7 +112,7 @@ public class Scheduler extends BroadcastReceiver
* alarms instead of crashing with a SecurityException (connectivity then * alarms instead of crashing with a SecurityException (connectivity then
* only degrades slightly while timers may be delayed in doze mode). * 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) @RequiresApi(api = Build.VERSION_CODES.M)
private void scheduleAlarm(long time) private void scheduleAlarm(long time)
@@ -119,21 +123,26 @@ public class Scheduler extends BroadcastReceiver
{ {
exact = mManager.canScheduleExactAlarms(); 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 try
{ {
if (exact) if (exact)
{ {
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending); mManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
} }
else else
{ {
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending); mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
} }
} }
catch (SecurityException e) catch (SecurityException e)
{ /* permission might have been revoked, or OEM restrictions apply */ { /* permission might have been revoked, or OEM restrictions apply */
Log.w(TAG, "Falling back to inexact alarm after SecurityException"); 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) synchronized (this)
{ {
ScheduledJob job = new ScheduledJob(id, System.currentTimeMillis() + ms); long time = SystemClock.elapsedRealtime() + ms;
mJobs.add(job); if (mJobs.add(id, time))
{ /* the job is due before the armed alarm: move the alarm */
if (job == mJobs.peek()) scheduleAlarm(time);
{ /* update the alarm if the job has to be executed before all others */
scheduleAlarm(job.Time);
} }
} }
} }
@@ -163,30 +170,21 @@ public class Scheduler extends BroadcastReceiver
@Override @Override
public void onReceive(Context context, Intent intent) public void onReceive(Context context, Intent intent)
{ {
ArrayList<ScheduledJob> jobs = new ArrayList<>(); List<String> jobs;
long now = System.currentTimeMillis();
synchronized (this) synchronized (this)
{ {
ScheduledJob job = mJobs.peek(); jobs = mJobs.popDue(SystemClock.elapsedRealtime());
while (job != null) long next = mJobs.nextAlarm();
if (next != ScheduledJobQueue.NONE)
{ {
if (job.Time > now) scheduleAlarm(next);
{
break;
}
jobs.add(mJobs.remove());
job = mJobs.peek();
}
if (job != null)
{
scheduleAlarm(job.Time);
} }
} }
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 * @param id job ID
*/ */
public native void executeJob(String id); public native void executeJob(String id);
/**
* Keep track of scheduled jobs.
*/
private static class ScheduledJob implements Comparable<ScheduledJob>
{
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);
}
}
} }
@@ -232,6 +232,7 @@ public class WatchdogReceiver extends BroadcastReceiver
return; return;
case RESTART: case RESTART:
{ {
boolean autoRestart = uuid == null;
if (uuid == null) if (uuid == null)
{ {
uuid = resolveAutoProfile(context, prefs); uuid = resolveAutoProfile(context, prefs);
@@ -260,6 +261,10 @@ public class WatchdogReceiver extends BroadcastReceiver
* once, which is harmless (retry flag set, same profile). */ * once, which is harmless (retry flag set, same profile). */
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it"); Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
DiagLog.i(TAG, "restarting the VPN process"); 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); Intent start = new Intent(context, CharonVpnService.class);
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid); start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
start.putExtra(CharonVpnService.KEY_IS_RETRY, true); start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
@@ -91,6 +91,8 @@ class AutoConnectManager private constructor(
private var currentNetwork: NetworkKind? = null private var currentNetwork: NetworkKind? = null
/* marks the session this manager started itself */ /* marks the session this manager started itself */
private val autoSession = AtomicBoolean(false) 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) */ /* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
private var lastParkAt = 0L 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() fun start()
{ {
config = loadConfig() config = loadConfig()
if (!config.enabled) if (!config.enabled && !scenariosNeedNetwork())
{ {
return return
} }
@@ -186,8 +206,8 @@ class AutoConnectManager private constructor(
} }
else else
{ {
stop()
autoSession.set(false) autoSession.set(false)
scenariosChanged()
} }
} }
@@ -214,8 +234,8 @@ class AutoConnectManager private constructor(
} }
else else
{ {
stop()
autoSession.set(false) autoSession.set(false)
scenariosChanged()
} }
} }
@@ -236,12 +256,28 @@ class AutoConnectManager private constructor(
override fun stateChanged() override fun stateChanged()
{ {
val state = service?.state ?: return val state = service?.state ?: return
if (state == VpnStateService.State.DISABLED) if (state != VpnStateService.State.DISABLED)
{
awaitingRestoredTunnel = false
}
else if (!awaitingRestoredTunnel)
{ {
autoSession.set(false) 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. * Pure decision function, unit-testable.
*/ */
@@ -268,8 +304,8 @@ class AutoConnectManager private constructor(
private fun evaluate() private fun evaluate()
{ {
if (!running.get()) if (!running.get() || !config.enabled)
{ { /* running only for the scenarios: they decide, not the network rules */
return return
} }
val svc = service ?: return val svc = service ?: return
@@ -25,10 +25,12 @@ import android.content.IntentFilter
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.VpnService import android.net.VpnService
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.PowerManager import android.os.PowerManager
import android.os.SystemClock
import android.telephony.TelephonyManager import android.telephony.TelephonyManager
import android.util.Log import android.util.Log
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
@@ -162,6 +164,9 @@ object ScenarioEngine
private const val TAG = "Scenarios" private const val TAG = "Scenarios"
private const val FILE_NAME = "scenarios.json" private const val FILE_NAME = "scenarios.json"
private const val POLL_MS = 4000L 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 */ /* the foreground app did not change for a minute: poll half as often */
private const val SLOW_AFTER_POLLS = 15 private const val SLOW_AFTER_POLLS = 15
private const val SLOW_POLL_MS = 8000L private const val SLOW_POLL_MS = 8000L
@@ -193,9 +198,20 @@ object ScenarioEngine
_list.value = list _list.value = list
persist(context) persist(context)
start(context) start(context)
org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).scenariosChanged()
evaluate(context) 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. */ /** Starts listening (from the application); idempotent. */
@JvmStatic @JvmStatic
fun start(context: Context) 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. */ /** Network, whitelist or alarm: decide again. */
@JvmStatic @JvmStatic
fun evaluate(context: Context) fun evaluate(context: Context)
@@ -431,10 +480,12 @@ object ScenarioEngine
{ {
return 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 wasOn = prefs.getBoolean(PREF_WAS_ON, false)
val wasProfile = prefs.getString(PREF_WAS_PROFILE, null) val wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply() 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)") DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)")
if (PauseManager.isPaused(context)) if (PauseManager.isPaused(context))
{ {
@@ -562,12 +613,22 @@ object ScenarioEngine
{ {
c.add(Calendar.DAY_OF_YEAR, 1) 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 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) 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?) override fun onReceive(context: Context, intent: Intent?)
{ {
ScenarioEngine.start(context) ScenarioEngine.start(context)
ScenarioEngine.evaluate(context) val pending = goAsync()
ScenarioEngine.evaluateWhenReady(context) { pending.finish() }
} }
} }
@@ -15,6 +15,8 @@
package org.strongswan.android.logic.stats package org.strongswan.android.logic.stats
import android.content.Context import android.content.Context
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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 * 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 * the current day; the counters restart with every connection, so a drop
* starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json. * 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 TAG = "TrafficHistory"
private const val FILE_NAME = "traffic.json" private const val FILE_NAME = "traffic.json"
private const val SAMPLE_S = 15L 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 const val KEEP_DAYS = 120
private val executor = Executors.newSingleThreadScheduledExecutor() private val executor = Executors.newSingleThreadScheduledExecutor()
private var task: ScheduledFuture<*>? = null private var task: ScheduledFuture<*>? = null
private var lastVpn = -1L private var lastVpn = -1L
private var lastDirect = -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 @Volatile private var loaded = false
private val days = LinkedHashMap<String, LongArray>() private val days = LinkedHashMap<String, LongArray>()
private var firstDay: String? = null private var firstDay: String? = null
@@ -86,11 +98,38 @@ object TrafficHistory
val running = task ?: return val running = task ?: return
running.cancel(false) running.cancel(false)
task = null 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 traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull()
val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return
/* direct bytes only exist with the userspace router (s[3]) */ /* direct bytes only exist with the userspace router (s[3]) */
@@ -121,9 +160,16 @@ object TrafficHistory
firstDay = key firstDay = key
} }
prune() prune()
save(context) dirty = true
if (saveDue(dirty, SystemClock.elapsedRealtime(), lastSavedAt, lastSavedDay != null && lastSavedDay != key))
{
save(context)
}
_changes.value = System.currentTimeMillis() _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. */ /** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */
@@ -205,6 +251,9 @@ object TrafficHistory
file.writeText(json.toString()) file.writeText(json.toString())
tmp.delete() tmp.delete()
} }
dirty = false
lastSavedAt = SystemClock.elapsedRealtime()
lastSavedDay = dayKey(System.currentTimeMillis())
} }
catch (e: Exception) catch (e: Exception)
{ {
@@ -797,10 +797,9 @@ static void *engine_thread(private_direct_engine_t *this)
{ {
active = TRUE; active = TRUE;
this->udp->reprotect(this->udp); this->udp->reprotect(this->udp);
if (this->tcp_active) /* 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); this->tcp->reprotect(this->tcp);
}
/* the network changed: whatever was dead for direct connects /* the network changed: whatever was dead for direct connects
* may work again - drop the tunnel fallbacks with the rest of * may work again - drop the tunnel fallbacks with the rest of
* the failure state (tcp->reprotect clears the neg table) */ * 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); this->bind_lock->unlock(this->bind_lock);
if (!bound && this->unbound_flushed) 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); 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; 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->process_poll(this->tcp, fds + 1 + udp_n, tcp_n);
this->tcp->check_timers(this->tcp); 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 */ { /* adapt the cadence after the work of this round is known */
@@ -966,6 +966,12 @@ METHOD(direct_tcp_t, check_timers, void,
enumerator->destroy(enumerator); 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, METHOD(direct_tcp_t, set_mtu, void,
private_direct_tcp_t *this, uint16_t mtu) 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 = { .public = {
.handle_packet = _handle_packet, .handle_packet = _handle_packet,
.check_timers = _check_timers, .check_timers = _check_timers,
.flow_count = _flow_count,
.set_mtu = _set_mtu, .set_mtu = _set_mtu,
.reprotect = _reprotect, .reprotect = _reprotect,
.fill_poll = _fill_poll, .fill_poll = _fill_poll,
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
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()
}
}
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
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()
}
}
+35
View File
@@ -139,3 +139,38 @@
- minSdk 24: `setExactAndAllowWhileIdle` и ограничения точных будильников (Android 12+/13+) уже учтены в `Scheduler` и `WatchdogReceiver`; не ломать. - minSdk 24: `setExactAndAllowWhileIdle` и ограничения точных будильников (Android 12+/13+) уже учтены в `Scheduler` и `WatchdogReceiver`; не ломать.
- Релиз — по процедуре из `docs/README.md` (smoke на AVD `shx36`, выгрузка в S3 только с подтверждения). - Релиз — по процедуре из `docs/README.md` (smoke на AVD `shx36`, выгрузка в S3 только с подтверждения).
- Строки UI, если появятся (подсказки в «Батарее»), добавлять сразу во все семь `strings.xml`. - Строки 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 остаётся под квотой; склейка заданий лишь освобождает слоты.