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:
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. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<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_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
@@ -339,6 +340,17 @@
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
|
||||
</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"
|
||||
}
|
||||
}
|
||||
+18
-5
@@ -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<Network> mConnectedNetworks = new HashSet<>();
|
||||
private LinkedList<Boolean> 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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
+98
@@ -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
|
||||
}
|
||||
}
|
||||
+28
-51
@@ -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<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")
|
||||
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<ScheduledJob> jobs = new ArrayList<>();
|
||||
long now = System.currentTimeMillis();
|
||||
List<String> 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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -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);
|
||||
|
||||
+42
-6
@@ -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
|
||||
|
||||
+66
-4
@@ -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() }
|
||||
}
|
||||
}
|
||||
+54
-5
@@ -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<String, LongArray>()
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
+100
@@ -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()
|
||||
}
|
||||
}
|
||||
+43
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 остаётся под квотой; склейка заданий лишь освобождает слоты.
|
||||
Reference in new issue
Block a user