Compare commits
11
Commits
e0113e3413
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e35a794c9 | ||
|
|
5ffb5a2dbb | ||
|
|
ae06a58013 | ||
|
|
9109eb8460 | ||
|
|
1ef8c983c7 | ||
|
|
072674d1a8 | ||
|
|
228f9f6004 | ||
|
|
6756d2fca5 | ||
|
|
5998c0fc46 | ||
|
|
f5b15a0394 | ||
|
|
38e5fd84bb |
No files matched your search
@@ -45,8 +45,8 @@ android {
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
|
||||
versionCode 165
|
||||
versionName "2.18.0"
|
||||
versionCode 169
|
||||
versionName "2.19.1"
|
||||
|
||||
/* Base URL of the update storage, trailing slash included */
|
||||
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
|
||||
|
||||
@@ -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>
|
||||
+13
-3
@@ -54,6 +54,16 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A profile keeps the data source it was loaded from, which may be closed
|
||||
* by the time the profile is updated or deleted, so reopen on demand.
|
||||
*/
|
||||
private SQLiteDatabase database()
|
||||
{
|
||||
open();
|
||||
return mDatabase;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
@@ -67,7 +77,7 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
||||
public VpnProfile insertProfile(VpnProfile profile)
|
||||
{
|
||||
ContentValues values = ContentValuesFromVpnProfile(profile);
|
||||
long insertId = mDatabase.insert(DatabaseHelper.TABLE_VPN_PROFILE.Name, null, values);
|
||||
long insertId = database().insert(DatabaseHelper.TABLE_VPN_PROFILE.Name, null, values);
|
||||
if (insertId == -1)
|
||||
{
|
||||
return null;
|
||||
@@ -82,14 +92,14 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
||||
{
|
||||
final UUID uuid = profile.getUUID();
|
||||
ContentValues values = ContentValuesFromVpnProfile(profile);
|
||||
return mDatabase.update(DatabaseHelper.TABLE_VPN_PROFILE.Name, values, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
|
||||
return database().update(DatabaseHelper.TABLE_VPN_PROFILE.Name, values, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteVpnProfile(VpnProfile profile)
|
||||
{
|
||||
final UUID uuid = profile.getUUID();
|
||||
return mDatabase.delete(DatabaseHelper.TABLE_VPN_PROFILE.Name, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
|
||||
return database().delete(DatabaseHelper.TABLE_VPN_PROFILE.Name, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -269,7 +269,13 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
VpnProfile profile = loadProfile(uuid);
|
||||
if (profile != null)
|
||||
{
|
||||
profile.setPassword(bundle.getString(VpnProfileDataSource.KEY_PASSWORD));
|
||||
String password = bundle.getString(VpnProfileDataSource.KEY_PASSWORD);
|
||||
if (password != null)
|
||||
{ /* an unattended start (watchdog, pause end, FGS retry) carries no
|
||||
* password: keep the one saved with the profile instead of
|
||||
* wiping it, or the tunnel never comes back («password unavailable») */
|
||||
profile.setPassword(password);
|
||||
}
|
||||
/* a backup port of F_Profile, for this attempt only */
|
||||
int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0);
|
||||
if (port > 0)
|
||||
|
||||
+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
|
||||
|
||||
+71
-1
@@ -30,6 +30,7 @@ import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -43,6 +44,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.events.Events
|
||||
|
||||
@@ -130,6 +132,9 @@ class HealthCheckManager private constructor(
|
||||
private var tunnelAttempts = 0
|
||||
private var fastPollShifts = 0
|
||||
private var powerState = PowerState()
|
||||
/* tunnel inbound bytes at the previous cycle (-1: unknown) and when the last real probe ran */
|
||||
private var lastCycleInbound = -1L
|
||||
private var lastProbeAt = 0L
|
||||
|
||||
/* tracks screen/battery state so probing can back off while the device sleeps */
|
||||
private val powerReceiver = object : BroadcastReceiver()
|
||||
@@ -360,7 +365,31 @@ class HealthCheckManager private constructor(
|
||||
return
|
||||
}
|
||||
val gateway = svc.profile?.gateway
|
||||
val targets = config.targets
|
||||
val adapted = adaptToPowerState(config, powerState)
|
||||
val nowMs = SystemClock.elapsedRealtime()
|
||||
/* passive liveness: payload that came back through the tunnel since the
|
||||
* previous cycle proves it works without waking the radio for probes */
|
||||
val inbound = tunnelInboundBytes()
|
||||
val inboundDelta = if (lastCycleInbound >= 0 && inbound >= 0) inbound - lastCycleInbound else -1L
|
||||
lastCycleInbound = inbound
|
||||
if (trafficProvesAlive(
|
||||
adapted, serverAttempts + tunnelAttempts, fastPollShifts, pendingDecision != null,
|
||||
inboundDelta, nowMs - lastProbeAt,
|
||||
))
|
||||
{
|
||||
scheduleCycle(effectiveIntervalSec(adapted) * 1000L)
|
||||
return
|
||||
}
|
||||
lastProbeAt = nowMs
|
||||
/* screen off: one target is enough (the confirmation probe uses it too) */
|
||||
val targets = if (adapted.adaptiveMode && !powerState.screenOn && config.targets.size > 1)
|
||||
{
|
||||
setOf(config.targets.first())
|
||||
}
|
||||
else
|
||||
{
|
||||
config.targets
|
||||
}
|
||||
val mode = config.serverCheckMode
|
||||
val timeout = config.timeoutMs
|
||||
|
||||
@@ -609,6 +638,21 @@ class HealthCheckManager private constructor(
|
||||
tunnelAttempts = 0
|
||||
fastPollShifts = 0
|
||||
pendingDecision = null
|
||||
lastCycleInbound = -1L
|
||||
lastProbeAt = 0L
|
||||
}
|
||||
|
||||
/** Inbound byte counter of the tunnel's CHILD_SAs, -1 when unavailable. */
|
||||
private fun tunnelInboundBytes(): Long
|
||||
{
|
||||
return try
|
||||
{
|
||||
CharonVpnService.queryTraffic()?.getOrNull(0) ?: -1L
|
||||
}
|
||||
catch (_: Throwable)
|
||||
{
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerVpnCallback()
|
||||
@@ -705,6 +749,32 @@ class HealthCheckManager private constructor(
|
||||
private const val FAST_POLL_MAX_SHIFTS = 3
|
||||
private const val AGGRESSIVE_INTERVAL_SEC = 5
|
||||
|
||||
/** Inbound tunnel payload since the last cycle that counts as proof of life. */
|
||||
internal const val PASSIVE_MIN_INBOUND_BYTES = 4096L
|
||||
|
||||
/** Even with traffic flowing a real probe runs at least this often. */
|
||||
internal const val PASSIVE_MAX_PROBE_AGE_MS = 5 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Pure function: whether the cycle may skip its probes because the
|
||||
* tunnel carried inbound data since the previous cycle. Only in
|
||||
* adaptive mode, never in aggressive mode, never while a failure is
|
||||
* being observed, and never for longer than [PASSIVE_MAX_PROBE_AGE_MS].
|
||||
* Outbound-only traffic proves nothing, so it does not count.
|
||||
*/
|
||||
fun trafficProvesAlive(
|
||||
adapted: HealthConfig,
|
||||
failedAttempts: Int,
|
||||
fastPollShifts: Int,
|
||||
decisionPending: Boolean,
|
||||
inboundDelta: Long,
|
||||
sinceLastProbeMs: Long,
|
||||
): Boolean =
|
||||
adapted.adaptiveMode && !adapted.aggressiveMode &&
|
||||
failedAttempts == 0 && fastPollShifts == 0 && !decisionPending &&
|
||||
inboundDelta >= PASSIVE_MIN_INBOUND_BYTES &&
|
||||
sinceLastProbeMs in 0 until PASSIVE_MAX_PROBE_AGE_MS
|
||||
|
||||
/** Minimum probe interval in adaptive mode while the screen is off. */
|
||||
internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60
|
||||
|
||||
|
||||
+15
@@ -542,10 +542,22 @@ class RoutingManager private constructor(private val context: Context)
|
||||
break
|
||||
}
|
||||
delay(SITE_REFRESH_MS)
|
||||
/* nobody looks at the screen: resolving only wakes the radio and
|
||||
* CPU, so wait for it to come on (re-checked every few minutes),
|
||||
* but never let the answers get older than the cap */
|
||||
var waited = SITE_REFRESH_MS
|
||||
while (waited < SITE_REFRESH_SCREEN_OFF_MAX_MS && !isScreenOn())
|
||||
{
|
||||
delay(SITE_SCREEN_RECHECK_MS)
|
||||
waited += SITE_SCREEN_RECHECK_MS
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isScreenOn(): Boolean =
|
||||
(context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager)?.isInteractive ?: true
|
||||
|
||||
/**
|
||||
* Like [updateConfig], but force prefixes apply on the fly through the
|
||||
* atomic set swap of the userspace router - no reconnect needed.
|
||||
@@ -833,6 +845,9 @@ class RoutingManager private constructor(private val context: Context)
|
||||
private const val KEY_NOTICE_ONLY = "notice_only_profiles"
|
||||
private const val RECONNECT_DEBOUNCE_MS = 2000L
|
||||
const val SITE_REFRESH_MS = 30L * 60 * 1000
|
||||
/* with the screen off the refresh waits for it, up to this age */
|
||||
private const val SITE_REFRESH_SCREEN_OFF_MAX_MS = 3L * 60 * 60 * 1000
|
||||
private const val SITE_SCREEN_RECHECK_MS = 5L * 60 * 1000
|
||||
|
||||
/** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */
|
||||
const val CONFIG_VERSION = 2
|
||||
|
||||
+129
-13
@@ -21,13 +21,16 @@ import android.app.usage.UsageStatsManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
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
|
||||
@@ -161,6 +164,12 @@ 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
|
||||
private const val PREF_APPLIED = "pref_scenario_applied"
|
||||
private const val PREF_WAS_ON = "pref_scenario_was_on"
|
||||
private const val PREF_WAS_PROFILE = "pref_scenario_was_profile"
|
||||
@@ -174,6 +183,7 @@ object ScenarioEngine
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var appContext: Context? = null
|
||||
private var polling = false
|
||||
private var unchangedPolls = 0
|
||||
private var lastForeground: String? = null
|
||||
|
||||
fun flow(context: Context): StateFlow<List<Scenario>>
|
||||
@@ -188,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)
|
||||
@@ -203,36 +224,118 @@ object ScenarioEngine
|
||||
if (needPoll && !polling)
|
||||
{
|
||||
polling = true
|
||||
handler.post(poll)
|
||||
registerScreenReceiver(app)
|
||||
if (isScreenOn(app))
|
||||
{
|
||||
handler.post(poll)
|
||||
}
|
||||
}
|
||||
else if (!needPoll)
|
||||
{
|
||||
polling = false
|
||||
handler.removeCallbacks(poll)
|
||||
unregisterScreenReceiver(app)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isScreenOn(context: Context): Boolean =
|
||||
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
||||
|
||||
/* the foreground app only changes while the screen is on: no polling (and no
|
||||
* wake-ups for it) while it is off, one immediate poll when it comes back */
|
||||
private val screenReceiver = object : BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(context: Context, intent: Intent)
|
||||
{
|
||||
handler.removeCallbacks(poll)
|
||||
if (polling && intent.action == Intent.ACTION_SCREEN_ON)
|
||||
{
|
||||
unchangedPolls = 0
|
||||
handler.post(poll)
|
||||
}
|
||||
}
|
||||
}
|
||||
private var screenReceiverRegistered = false
|
||||
|
||||
private fun registerScreenReceiver(app: Context)
|
||||
{
|
||||
if (screenReceiverRegistered)
|
||||
{
|
||||
return
|
||||
}
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
}
|
||||
runCatching { app.registerReceiver(screenReceiver, filter, null, handler) }
|
||||
.onSuccess { screenReceiverRegistered = true }
|
||||
}
|
||||
|
||||
private fun unregisterScreenReceiver(app: Context)
|
||||
{
|
||||
if (!screenReceiverRegistered)
|
||||
{
|
||||
return
|
||||
}
|
||||
runCatching { app.unregisterReceiver(screenReceiver) }
|
||||
screenReceiverRegistered = false
|
||||
}
|
||||
|
||||
private val poll = object : Runnable
|
||||
{
|
||||
override fun run()
|
||||
{
|
||||
val app = appContext ?: return
|
||||
if (!polling)
|
||||
{
|
||||
if (!polling || !isScreenOn(app))
|
||||
{ /* resumed by the screen-on broadcast */
|
||||
return
|
||||
}
|
||||
val screenOn = (app.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
||||
if (screenOn)
|
||||
val fg = foregroundApp(app)
|
||||
if (fg != null && fg != lastForeground)
|
||||
{
|
||||
val fg = foregroundApp(app)
|
||||
if (fg != null && fg != lastForeground)
|
||||
lastForeground = fg
|
||||
unchangedPolls = 0
|
||||
evaluate(app)
|
||||
}
|
||||
else
|
||||
{
|
||||
unchangedPolls++
|
||||
}
|
||||
handler.postDelayed(this, if (unchangedPolls >= SLOW_AFTER_POLLS) SLOW_POLL_MS else POLL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
lastForeground = fg
|
||||
evaluate(app)
|
||||
}
|
||||
finally
|
||||
{
|
||||
done?.run()
|
||||
}
|
||||
}
|
||||
handler.postDelayed(this, POLL_MS)
|
||||
}
|
||||
handler.post(check)
|
||||
}
|
||||
|
||||
/** Network, whitelist or alarm: decide again. */
|
||||
@@ -377,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))
|
||||
{
|
||||
@@ -508,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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,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)
|
||||
{
|
||||
|
||||
+17
-11
@@ -71,6 +71,7 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
@@ -193,19 +194,24 @@ private fun ServerScreen(uuid: String, checkOnOpen: Boolean, onBack: () -> Unit)
|
||||
val extras by remember { ProfileExtras.flow(context) }.collectAsStateWithLifecycle()
|
||||
val extra = extras[uuid] ?: ProfileExtra(uuid)
|
||||
/* the live state of the tunnel and a direct ping of the server */
|
||||
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null) {
|
||||
while (true)
|
||||
{
|
||||
val service = VpnStateService.getLiveInstance()
|
||||
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
|
||||
delay(1500)
|
||||
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null, owner) {
|
||||
/* only while the page is on screen */
|
||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
while (true)
|
||||
{
|
||||
val service = VpnStateService.getLiveInstance()
|
||||
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
|
||||
delay(1500)
|
||||
}
|
||||
}
|
||||
}
|
||||
val rtt by produceState<Long?>(null, profile.gateway) {
|
||||
while (true)
|
||||
{
|
||||
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
|
||||
delay(30_000)
|
||||
val rtt by produceState<Long?>(null, profile.gateway, owner) {
|
||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
while (true)
|
||||
{
|
||||
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
|
||||
delay(30_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
val active = vpn?.second == uuid
|
||||
|
||||
+12
-1
@@ -199,7 +199,14 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
{
|
||||
synchronized(underlyingNetworks)
|
||||
{
|
||||
underlyingNetworks[network] = capabilities
|
||||
val previous = underlyingNetworks.put(network, capabilities)
|
||||
/* fired on every signal-strength tick: the published state (network
|
||||
* kinds, SSID) only depends on the transports, and reading the SSID
|
||||
* is a system call, so unchanged transports end here */
|
||||
if (previous != null && sameTransports(previous, capabilities))
|
||||
{
|
||||
return
|
||||
}
|
||||
publishNetworks()
|
||||
}
|
||||
}
|
||||
@@ -214,6 +221,10 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
}
|
||||
}
|
||||
|
||||
private fun sameTransports(a: NetworkCapabilities, b: NetworkCapabilities): Boolean =
|
||||
a.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == b.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
a.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == b.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
|
||||
private fun publishNetworks()
|
||||
{
|
||||
_networkAvailable.value = underlyingNetworks.isNotEmpty()
|
||||
|
||||
+17
-8
@@ -124,10 +124,23 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
|
||||
var result by remember { mutableStateOf<CheckResult?>(null) }
|
||||
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
|
||||
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
|
||||
val apps by produceState<List<InstalledApp>>(emptyList(), mode) {
|
||||
if (mode == RouteCheckMode.APP && value.isEmpty())
|
||||
var apps by remember { mutableStateOf<List<InstalledApp>>(emptyList()) }
|
||||
|
||||
/** The installed apps; loads them on first use, so a check never runs against a list that is still empty. */
|
||||
suspend fun loadApps(): List<InstalledApp>
|
||||
{
|
||||
if (apps.isEmpty())
|
||||
{
|
||||
value = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
|
||||
apps = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
|
||||
}
|
||||
return apps
|
||||
}
|
||||
|
||||
LaunchedEffect(mode)
|
||||
{
|
||||
if (mode == RouteCheckMode.APP)
|
||||
{
|
||||
loadApps()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +163,7 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
|
||||
{
|
||||
RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive)
|
||||
RouteCheckMode.IP -> checkIp(context, manager, input, rulesActive)
|
||||
RouteCheckMode.APP -> apps.firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
|
||||
RouteCheckMode.APP -> loadApps().firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
|
||||
?.let { CheckResult.App(it) } ?: CheckResult.BadInput
|
||||
}
|
||||
if (result !is CheckResult.BadInput)
|
||||
@@ -173,10 +186,6 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
|
||||
{
|
||||
if (initialQuery.isNotBlank())
|
||||
{
|
||||
if (mode == RouteCheckMode.APP)
|
||||
{
|
||||
delay(300)
|
||||
}
|
||||
run()
|
||||
}
|
||||
}
|
||||
|
||||
+12
-5
@@ -40,6 +40,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.PauseManager
|
||||
@@ -103,12 +106,16 @@ internal fun HomeProblem.titleRes(): Int = when (this)
|
||||
internal fun rememberNow(): Long
|
||||
{
|
||||
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit)
|
||||
{
|
||||
while (true)
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
LaunchedEffect(lifecycle)
|
||||
{ /* no ticking while the app is in the background */
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
|
||||
{
|
||||
now = System.currentTimeMillis()
|
||||
delay(1000L - now % 1000L)
|
||||
while (true)
|
||||
{
|
||||
now = System.currentTimeMillis()
|
||||
delay(1000L - now % 1000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
return now
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
*/
|
||||
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
#include <stdint.h>
|
||||
#include <sys/eventfd.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "android_jni.h"
|
||||
@@ -70,6 +73,12 @@ struct private_android_service_t {
|
||||
*/
|
||||
int tunfd;
|
||||
|
||||
/**
|
||||
* eventfd that wakes the TUN reader whenever tunfd is replaced or closed
|
||||
* (-1 if it could not be created, then the reader falls back to a 1 s poll)
|
||||
*/
|
||||
int wakefd;
|
||||
|
||||
/**
|
||||
* MTU of TUN device
|
||||
*/
|
||||
@@ -136,6 +145,21 @@ static void engine_write_packet(void *ctx, ip_packet_t *packet)
|
||||
deliver_plain(ctx, packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake the TUN reader so it picks up a replaced or closed tunfd. Call it
|
||||
* after this->tunfd was changed (the eventfd counter stays readable until the
|
||||
* reader drains it, so a wake-up before the reader polls is not lost).
|
||||
*/
|
||||
static void wake_reader(private_android_service_t *this)
|
||||
{
|
||||
uint64_t one = 1;
|
||||
|
||||
if (this->wakefd >= 0 && write(this->wakefd, &one, sizeof(one)) < 0)
|
||||
{
|
||||
DBG1(DBG_DMN, "waking the TUN reader failed: %s", strerror(errno));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Job handling outbound plaintext packets
|
||||
*/
|
||||
@@ -143,16 +167,10 @@ static job_requeue_t handle_plain(private_android_service_t *this)
|
||||
{
|
||||
ip_packet_t *packet;
|
||||
chunk_t raw;
|
||||
fd_set set;
|
||||
struct pollfd fds[2];
|
||||
ssize_t len;
|
||||
int tunfd;
|
||||
int tunfd, wakefd, nfds = 1, timeout;
|
||||
bool old, dns_proxy;
|
||||
timeval_t tv = {
|
||||
/* check every second if tunfd is still valid */
|
||||
.tv_sec = 1,
|
||||
};
|
||||
|
||||
FD_ZERO(&set);
|
||||
|
||||
this->lock->read_lock(this->lock);
|
||||
if (this->tunfd < 0)
|
||||
@@ -161,28 +179,60 @@ static job_requeue_t handle_plain(private_android_service_t *this)
|
||||
return JOB_REQUEUE_NONE;
|
||||
}
|
||||
tunfd = this->tunfd;
|
||||
FD_SET(tunfd, &set);
|
||||
wakefd = this->wakefd;
|
||||
/* cache this while we have the lock */
|
||||
dns_proxy = this->use_dns_proxy;
|
||||
this->lock->unlock(this->lock);
|
||||
|
||||
fds[0].fd = tunfd;
|
||||
fds[0].events = POLLIN;
|
||||
fds[0].revents = 0;
|
||||
if (wakefd >= 0)
|
||||
{ /* a replaced or closed tunfd wakes us, so the timeout is only a
|
||||
* safety net; without the eventfd re-check every second as before */
|
||||
fds[1].fd = wakefd;
|
||||
fds[1].events = POLLIN;
|
||||
fds[1].revents = 0;
|
||||
nfds = 2;
|
||||
timeout = 60000;
|
||||
}
|
||||
else
|
||||
{
|
||||
timeout = 1000;
|
||||
}
|
||||
|
||||
old = thread_cancelability(TRUE);
|
||||
len = select(tunfd + 1, &set, NULL, NULL, &tv);
|
||||
len = poll(fds, nfds, timeout);
|
||||
thread_cancelability(old);
|
||||
|
||||
if (len < 0)
|
||||
{
|
||||
if (errno == EBADF)
|
||||
{ /* the TUN device got closed just before calling select(), retry */
|
||||
{ /* the TUN device got closed just before calling poll(), retry */
|
||||
return JOB_REQUEUE_FAIR;
|
||||
}
|
||||
DBG1(DBG_DMN, "select on TUN device failed: %s", strerror(errno));
|
||||
DBG1(DBG_DMN, "poll on TUN device failed: %s", strerror(errno));
|
||||
return JOB_REQUEUE_NONE;
|
||||
}
|
||||
else if (len == 0)
|
||||
{ /* timeout, check again right away */
|
||||
return JOB_REQUEUE_DIRECT;
|
||||
}
|
||||
if (nfds == 2 && (fds[1].revents & POLLIN))
|
||||
{ /* tunfd was replaced or closed: drain the counter and start over with
|
||||
* the current fd (pending TUN data stays, poll is level-triggered) */
|
||||
uint64_t cnt;
|
||||
|
||||
if (read(wakefd, &cnt, sizeof(cnt)) < 0 && errno != EAGAIN)
|
||||
{
|
||||
DBG1(DBG_DMN, "reading the wake-up fd failed: %s", strerror(errno));
|
||||
}
|
||||
return JOB_REQUEUE_DIRECT;
|
||||
}
|
||||
if (fds[0].revents & POLLNVAL)
|
||||
{ /* the fd was closed while polling, retry with the current one */
|
||||
return JOB_REQUEUE_FAIR;
|
||||
}
|
||||
|
||||
raw = chunk_alloc(this->mtu);
|
||||
len = read(tunfd, raw.ptr, raw.len);
|
||||
@@ -375,6 +425,8 @@ static bool setup_tun_device(private_android_service_t *this,
|
||||
}
|
||||
this->tunfd = tunfd;
|
||||
this->lock->unlock(this->lock);
|
||||
/* a reader polling the previous fd must switch to the new one */
|
||||
wake_reader(this);
|
||||
|
||||
DBG1(DBG_DMN, "successfully created TUN device");
|
||||
|
||||
@@ -422,6 +474,7 @@ static bool setup_tun_device_without_dns(private_android_service_t *this)
|
||||
}
|
||||
this->tunfd = tunfd;
|
||||
this->lock->unlock(this->lock);
|
||||
wake_reader(this);
|
||||
|
||||
/* the fd changed, re-bind the userspace router to the new TUN */
|
||||
direct_engine_set_tun(this, engine_write_packet, this->mtu);
|
||||
@@ -446,6 +499,8 @@ static void close_tun_device(private_android_service_t *this)
|
||||
tunfd = this->tunfd;
|
||||
this->tunfd = -1;
|
||||
this->lock->unlock(this->lock);
|
||||
/* let the reader notice tunfd < 0 and quit instead of polling a dead fd */
|
||||
wake_reader(this);
|
||||
|
||||
/* drop the remembered binding too, so a lazily created engine does not
|
||||
* attach to the dead TUN, and flush the engine if it exists */
|
||||
@@ -977,6 +1032,10 @@ METHOD(android_service_t, destroy, void,
|
||||
close_tun_device(this);
|
||||
this->dns_proxy->destroy(this->dns_proxy);
|
||||
this->lock->destroy(this->lock);
|
||||
if (this->wakefd >= 0)
|
||||
{
|
||||
close(this->wakefd);
|
||||
}
|
||||
this->settings->destroy(this->settings);
|
||||
free(this);
|
||||
}
|
||||
@@ -1005,6 +1064,7 @@ android_service_t *android_service_create(android_creds_t *creds,
|
||||
.settings = settings,
|
||||
.creds = creds,
|
||||
.tunfd = -1,
|
||||
.wakefd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC),
|
||||
.mtu = settings->get_int(settings, "global.mtu", ANDROID_DEFAULT_MTU),
|
||||
);
|
||||
/* only allow queries for the VPN gateway */
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+26
@@ -242,4 +242,30 @@ class HealthCheckManagerTest
|
||||
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 3)).isTrue()
|
||||
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 0)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inboundTrafficSkipsProbesInAdaptiveMode()
|
||||
{
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 10_000, 60_000)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noPassiveSkipWithoutInboundOrAfterCap()
|
||||
{
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 100, 60_000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, -1, 60_000)).isFalse()
|
||||
assertThat(
|
||||
HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 10_000, HealthCheckManager.PASSIVE_MAX_PROBE_AGE_MS)
|
||||
).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noPassiveSkipWhileFailureIsObservedOrNotAdaptive()
|
||||
{
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 1, 0, false, 10_000, 1000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 2, false, 10_000, 1000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, true, 10_000, 1000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config.copy(adaptiveMode = false), 0, 0, false, 10_000, 1000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config.copy(aggressiveMode = true), 0, 0, false, 10_000, 1000)).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# SHX VPN для Android
|
||||
|
||||
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.18.0 (versionCode 165)**.
|
||||
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.19.1 (versionCode 169)**.
|
||||
Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само.
|
||||
|
||||
- Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду.
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# План оптимизации энергопотребления SHX VPN (Android)
|
||||
|
||||
Основа — разбор кода версии 2.18.0 (165). Пункты, помеченные «проверить», в коде не подтверждены и требуют замера
|
||||
или чтения кода перед правкой. Цель: меньше пробуждений CPU и радио при подключённом туннеле с погашенным экраном.
|
||||
|
||||
## 1. Что уже сделано (не трогать без замеров)
|
||||
|
||||
- `HealthCheckManager`: адаптивный режим (экран выключен → интервал не меньше 60 с, экономия заряда или низкий заряд → 300 с, без быстрых опросов),
|
||||
экспоненциальный бэкофф при сбое, без будильников и wakelock'ов.
|
||||
- `WatchdogReceiver`: будильники растягиваются — «припаркован» 5 мин / 30 мин (wakeup), «соединён» 5 мин / 60 мин.
|
||||
- `HomeViewModel`: опрос трафика (1 с) и проб (10 с) только пока видна вкладка «Главная» и VPN соединён (`repeatOnLifecycle` + `whileHomeConnected`).
|
||||
- `CharonVpnService`: диагностика маршрутов раз в 5 мин только в расширенном режиме; уведомление не чаще раза в 5 с (`NOTIFICATION_MIN_INTERVAL_MS`).
|
||||
- Фоновая работа: `WorkManager` (обновления 12 ч — с условиями сети и зарядки, профили 24 ч, итоги недели 7 дн.), wakelock'ов в коде нет.
|
||||
- `DiagLog`: писатель открыт, ротация по счётчику байт, а не по `stat` на каждую строку.
|
||||
|
||||
## 1a. Главный упор: лишняя работа CPU
|
||||
|
||||
Приоритет всего плана — убрать работу, которую телефон делает без пользы (пробуждения, холостые циклы, повторные вычисления).
|
||||
Критерий: меньше CPU-времени и пробуждений в час на подключённом туннеле, при погашенном экране — близко к нулю сверх IKE/ESP.
|
||||
|
||||
**Подтверждено по коду:**
|
||||
- **Холостое пробуждение раз в секунду в нативе.** `backend/android_service.c`: цикл чтения TUN делает `select()` с таймаутом 1 с
|
||||
(«check every second if tunfd is still valid») и по таймауту сразу возвращается в очередь заданий — поток просыпается каждую секунду
|
||||
даже без трафика. Заменить на ожидание без таймера: `poll()` по TUN и `eventfd`/pipe, который будит закрытие TUN (или таймаут 30–60 с как страховка).
|
||||
Это самый дешёвый и самый бесспорный выигрыш: один поток, ~3600 пробуждений в час → единицы.
|
||||
- Движок прямых потоков (`routing/direct_engine.c`) уже адаптивен: простой растягивает `poll` до 4 с (1 с, пока есть TCP-потоки).
|
||||
Остаётся: не вызывать `tcp->check_timers` / `sys_check_timeouts` и не пересобирать `pollfd` в холостых раундах, довести холостой интервал
|
||||
до 10–30 с при погашенном экране (будить через `wake`-fd при новом пакете — он уже есть).
|
||||
- Java: `ScenarioEngine.poll` (4 с, IPC в `UsageStatsManager`), `rememberNow()` (1 с), опрос трафика 1 с — только при видимой «Главной», это нормально.
|
||||
- `RoutingManager.refreshSites` каждые 30 мин резолвит все домены — разовая нагрузка на радио и CPU, но без пользы при погашенном экране.
|
||||
- `DiagLog`: каждая строка — `format.format(Date())` + `toByteArray` + `flush()` (системный вызов). При частых событиях сети это лишний CPU и запись во флеш.
|
||||
|
||||
**Проверить замером (гипотезы):**
|
||||
- сколько CPU съедает разбор/поиск по базе адресов РФ (`RuIpListManager`, `IPRangeSet`) при загрузке и обновлении, и не пересобирается ли набор без изменений
|
||||
(сравнивать хэш до применения, не вызывать `pushUserspaceRouting` без реального диффа);
|
||||
- Compose: лишние рекомпозиции «Главной» (счётчик трафика раз в секунду трогает большое дерево?) — Layout Inspector / recomposition counts;
|
||||
- число одновременных `NetworkCallback` (см. C) — каждое событие сети запускает дублирующую работу в семи местах.
|
||||
|
||||
## 2. Фаза 0 — замер базовой линии (до любых правок)
|
||||
|
||||
Без цифр правки ниже — гипотезы. Сценарии (эмулятор не годится, нужен реальный телефон):
|
||||
|
||||
1. Ночь: VPN соединён, экран выключен, Wi-Fi, 8 ч. То же на мобильной сети.
|
||||
2. Те же условия с включёнными: проверкой здоровья, сценарием «приложение открыто», сайтами в правилах, «сайты РФ напрямую».
|
||||
3. День: экран включён, вкладки «Главная» / «Маршруты» / «Диагностика».
|
||||
|
||||
Инструменты: `adb shell dumpsys batterystats --reset` → прогон → `dumpsys batterystats one.shx.strongswan.ext`,
|
||||
`dumpsys alarm` (число и тип будильников пакета), `dumpsys jobscheduler`, Perfetto (пробуждения CPU, радио), Battery Historian.
|
||||
Фиксировать: mAh за час, число wakeup-будильников в час, время активного радио, CPU нативного роутера.
|
||||
|
||||
## 3. Кандидаты на оптимизацию (по убыванию ожидаемого эффекта)
|
||||
|
||||
### A. Проверка здоровья: пассивные сигналы вместо проб (сделано в этой ветке)
|
||||
`HealthCheckManager` каждые 30 с (по умолчанию в `HealthConfig`; проверить фактический дефолт в UI настроек) шлёт HTTP/ICMP-пробы
|
||||
через туннель — каждая держит радио. Идеи:
|
||||
- пропускать цикл, если по `TrafficStats` за интервал был входящий трафик через туннель (туннель очевидно жив);
|
||||
- при выключенном экране: один таргет вместо трёх, интервал 5 мин;
|
||||
- не стартовать цикл вообще, пока устройство в Doze — не делалось: корутинные таймеры и так не будят спящий CPU.
|
||||
|
||||
Реализовано: в адаптивном режиме цикл пропускает пробы, если за интервал через туннель пришло ≥ 4 КБ входящих данных
|
||||
(`CharonVpnService.queryTraffic()`), но реальная проба идёт не реже раза в 5 минут; при наблюдаемом сбое, ожидающем решении,
|
||||
агрессивном режиме или неизвестном счётчике пропуска нет. При выключенном экране проверяется один адрес вместо трёх.
|
||||
Сама проверка здоровья включается пользователем (`HealthConfig.enabled = false` по умолчанию), поэтому выигрыш — для тех, кто её включил.
|
||||
|
||||
### B. Keepalive и будильники strongSwan — проверено, код не менялся
|
||||
Умолчание уже экономное: `charonservice.c` ставит `charon.keep_alive = 45` с (`ANDROID_KEEPALIVE_INTERVAL`, у upstream-strongSwan 20 с)
|
||||
и DPD-запас 20 с; профильный `nat_keepalive` (10–120 с) его переопределяет. Keepalive уходит только когда исходящего трафика не было.
|
||||
Поднимать 45 с выше без замеров нельзя: UDP-таймауты NAT операторов бывают 30–60 с, туннель «жив», но молчит — это прямое нарушение условия раздела 5.
|
||||
Что остаётся: замерить число будильников (`dumpsys alarm`) за ночь и, если вклад велик, добавить в профиль **необязательную** настройку,
|
||||
не меняя умолчание; рекомендовать значения по результатам теста на конкретной сети пользователя.
|
||||
|
||||
### C. Сетевые колбэки — единый хаб отклонён, исправлен один шумный
|
||||
Разбор показал, что хаб не нужен: `NetworkMonitor` и `WhiteListManager` уже отсекают тики уровня сигнала в `onCapabilitiesChanged`,
|
||||
`PauseManager` регистрируется только на время паузы, проверка здоровья — только при включённой функции и соединённом VPN,
|
||||
а `NetworkManager` и `UnderlyingNetworkTracker` в `CharonVpnService` — часть самого туннеля (трогать нельзя по разделу 5).
|
||||
Выигрыш от объединения — единицы вызовов Binder на смену сети при заметном риске для автоподключения и белых списков.
|
||||
Исправлено единственное реальное место: `HomeViewModel.onCapabilitiesChanged` на каждый тик сигнала Wi-Fi заново читал SSID (системный вызов);
|
||||
теперь состояние пересчитывается только при смене транспорта.
|
||||
|
||||
### D. Опрос переднего приложения в сценариях (средний эффект, низкая сложность)
|
||||
`ScenarioEngine.poll` — каждые 4 с `UsageStatsManager.queryEvents` (IPC), пока есть сценарий с триггером «приложение».
|
||||
При выключенном экране выборка пропускается, но `postDelayed` продолжает тикать.
|
||||
- остановить цикл по `ACTION_SCREEN_OFF`, запускать по `ACTION_SCREEN_ON`;
|
||||
- увеличить интервал до 8–10 с, если foreground-приложение не менялось несколько циклов.
|
||||
|
||||
### E. Обновление адресов сайтов из правил (средний эффект, низкая сложность)
|
||||
`RoutingManager.refreshSites` каждые 30 мин резолвит все домены правил, пока соединён VPN, независимо от экрана.
|
||||
- пока экран выключен — не резолвить; обновить один раз при `ACTION_SCREEN_ON`, если прошло больше `SITE_REFRESH_MS`;
|
||||
- резолвить только домены, чьи TTL истекли.
|
||||
|
||||
### F. UI-циклы после ухода с экрана (низкий эффект, низкая сложность)
|
||||
Проверить, что все циклы отвязаны от жизненного цикла (через `repeatOnLifecycle(STARTED)`):
|
||||
`RoutingScreen` (5 с, уже на `repeatOnLifecycle`), `DiagnosticsScreen` (5 с), `ProfilesScreen` и `ServerActivity` (30 с, 1,5 с),
|
||||
`RecentAppsScreen` (10 с), `LogActivity` (800 мс), `rememberNow()` в `HomeStatus.kt` (тик каждую секунду, пока composable жив).
|
||||
Отдельно: `rememberNow()` должен тикать только если на экране есть обратный отсчёт паузы или время подключения.
|
||||
|
||||
### G. Нативный роутер «сайты РФ напрямую» (неизвестный эффект, замер)
|
||||
Пользовательский роутер (`getRoutingStats`, relay UDP/TCP) обрабатывает пакеты в процессе. Замерить CPU-время потоков
|
||||
в Perfetto в режимах «Стабильно» и «Гибко» на типовой нагрузке (видео, мессенджер). Если вклад заметный — смотреть размеры очередей,
|
||||
размер буферов, пакетную обработку и спин-ожидание в нативной части (`app/src/main/jni`).
|
||||
|
||||
### H. Мелочи
|
||||
- `DiagLog`: flush на каждую строку нужен для живучести; при выключенном экране можно понизить подробность (watchdog, network-change)
|
||||
или не логировать повторы.
|
||||
- Проверить `WhiteListManager` (перепроверки `recheckJob`, `refreshIfStale`) на периодические пробы при выключенном экране.
|
||||
- Подсказка пользователю на экране «Батарея» (`BatteryActivity`): показывать, какие функции (проверка здоровья, сценарии по приложению)
|
||||
заметно расходуют заряд и как их смягчить.
|
||||
|
||||
## 4. Порядок работ
|
||||
|
||||
1. Фаза 0: базовая линия на реальном телефоне (раздел 2); для CPU добавить `adb shell top -H -p <pid>` / Perfetto по потокам (счёт пробуждений потока чтения TUN).
|
||||
2. Быстрые правки с низким риском: секундный `select` TUN (раздел 1a), D, E, F, `DiagLog` — один релиз, сравнить с базой.
|
||||
3. A (пассивная проверка здоровья) и B (keepalive) — отдельные релизы, каждый со своим замером; B только после проверки на сетях операторов.
|
||||
4. C (хаб колбэков) — рефакторинг, после A/B; регрессия по сценариям смены сети (Wi-Fi ↔ мобильная, роуминг, белые списки, пауза).
|
||||
5. G — по результатам Perfetto.
|
||||
6. После каждой правки: тот же прогон из фазы 0; принимается, если mAh/ч и число будильников не растут, а надёжность не падает.
|
||||
|
||||
## 5. Жёсткое условие: туннель не должен падать в фоне
|
||||
|
||||
Экономия не оправдывает потери туннеля при свёрнутом приложении, погашенном экране или Doze. Правила для каждой правки:
|
||||
|
||||
- **Не трогать** то, что держит процесс и туннель живыми: foreground-сервис `CharonVpnService` (`startForegroundSafe`), будильники `Scheduler`
|
||||
(NAT-keepalive, rekey), `FgsStartRetry`, страховочные будильники `WatchdogReceiver`, DPD charon. Их интервалы не растягивать «заодно».
|
||||
- **Растягивать можно только** вторичные проверки поверх IKE DPD (пробы здоровья, опросы UI/сценариев, диагностика, DNS сайтов).
|
||||
Любое растяжение обязано иметь быстрый выход по событию: смена сети, `ACTION_SCREEN_ON`, потеря валидации VPN-сети (`vpnCallback`).
|
||||
- **TUN `select` (1а):** секундный таймаут сейчас ещё и ловит закрытие TUN (`EBADF`). Замена обязана будить поток при закрытии
|
||||
(`eventfd` пишется в `close`/реконнекте/реавторизации) — иначе поток зависнет на закрытом дескрипторе, и туннель не переподнимется.
|
||||
Страховочный таймаут оставить (30–60 с), не убирать совсем.
|
||||
- **Keepalive (B):** менять умолчание только после проверки на реальных операторах; слишком большой интервал — NAT закрывает соединение, туннель «жив», но молчит.
|
||||
- **Пробы здоровья (A):** пассивный сигнал «трафик идёт» не должен подавлять проверку, если исходящий трафик есть, а входящего нет.
|
||||
- **Приёмка каждого релиза** (кроме замеров): ночь с погашенным экраном — туннель не разорван, входящий пуш/пинг проходит утром без ручных действий;
|
||||
`dumpsys deviceidle force-idle` → выход из Doze → трафик идёт; смена Wi-Fi ↔ мобильная сеть при погашенном экране; убийство процесса
|
||||
(`adb shell am kill`) → watchdog поднимает туннель; `adb logcat -b crash` чист. Не проходит хотя бы один пункт — правка откатывается.
|
||||
|
||||
## 6. Риски и ограничения
|
||||
|
||||
- Надёжность туннеля важнее экономии: правки A, B, E не должны ухудшать восстановление после смены сети и Doze
|
||||
(смоделировать `adb shell dumpsys deviceidle force-idle`, отключение и возврат сети, подмену SSID).
|
||||
- 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 остаётся под квотой; склейка заданий лишь освобождает слоты.
|
||||
|
||||
**Найдено при smoke-тесте 2.19.0:** `CharonVpnService` при старте по `Intent` без пароля (watchdog, конец паузы, `FgsStartRetry`) затирал сохранённый в профиле пароль значением `null`;
|
||||
туннель после смерти процесса не возвращался («Password unavailable»). Теперь пароль из `Intent` подставляется, только если он там есть.
|
||||
Проверено на эмуляторе `shx36` (профиль на 192.0.2.1, туннель не поднимается, проверялась логика): `am crash` → watchdog поднимает сервис (~80 с, окно неточного
|
||||
будильника); сценарий «Wi-Fi → включить» срабатывает по событию сети при выключенных правилах сетей (~10 с); будильник сценария по времени будит убитый процесс,
|
||||
`ScenarioReceiver` ждёт сервис и возвращает прежнее состояние VPN. Без реального сервера не проверены keepalive/rekey, NAT-таймауты и энергопотребление.
|
||||
Reference in new issue
Block a user