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
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
|
|
||||||
versionCode 165
|
versionCode 169
|
||||||
versionName "2.18.0"
|
versionName "2.19.1"
|
||||||
|
|
||||||
/* Base URL of the update storage, trailing slash included */
|
/* Base URL of the update storage, trailing slash included */
|
||||||
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
|
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
<!-- SSID is redacted in the background on Android 10+ without this. -->
|
<!-- SSID is redacted in the background on Android 10+ without this. -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
@@ -339,6 +340,17 @@
|
|||||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- after a reboot or a clock change: re-arm the pause and scenario alarms, evaluate the rules again -->
|
||||||
|
<receiver
|
||||||
|
android:name=".logic.BootReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
+13
-3
@@ -54,6 +54,16 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
|||||||
return this;
|
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
|
@Override
|
||||||
public void close()
|
public void close()
|
||||||
{
|
{
|
||||||
@@ -67,7 +77,7 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
|||||||
public VpnProfile insertProfile(VpnProfile profile)
|
public VpnProfile insertProfile(VpnProfile profile)
|
||||||
{
|
{
|
||||||
ContentValues values = ContentValuesFromVpnProfile(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)
|
if (insertId == -1)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -82,14 +92,14 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
|||||||
{
|
{
|
||||||
final UUID uuid = profile.getUUID();
|
final UUID uuid = profile.getUUID();
|
||||||
ContentValues values = ContentValuesFromVpnProfile(profile);
|
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
|
@Override
|
||||||
public boolean deleteVpnProfile(VpnProfile profile)
|
public boolean deleteVpnProfile(VpnProfile profile)
|
||||||
{
|
{
|
||||||
final UUID uuid = profile.getUUID();
|
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
|
@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);
|
VpnProfile profile = loadProfile(uuid);
|
||||||
if (profile != null)
|
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 */
|
/* a backup port of F_Profile, for this attempt only */
|
||||||
int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0);
|
int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0);
|
||||||
if (port > 0)
|
if (port > 0)
|
||||||
|
|||||||
+18
-5
@@ -25,6 +25,7 @@ import android.net.Network;
|
|||||||
import android.net.NetworkRequest;
|
import android.net.NetworkRequest;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
|
|
||||||
public class NetworkManager extends BroadcastReceiver implements Runnable
|
public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||||
@@ -33,7 +34,9 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
private volatile boolean mRegistered;
|
private volatile boolean mRegistered;
|
||||||
private ConnectivityManager.NetworkCallback mCallback;
|
private ConnectivityManager.NetworkCallback mCallback;
|
||||||
private Thread mEventNotifier;
|
private Thread mEventNotifier;
|
||||||
private int mConnectedNetworks = 0;
|
/* a set, not a counter: a repeated onAvailable or a missed onLost cannot
|
||||||
|
* make it drift away from the networks that really exist */
|
||||||
|
private final HashSet<Network> mConnectedNetworks = new HashSet<>();
|
||||||
private LinkedList<Boolean> mEvents = new LinkedList<>();
|
private LinkedList<Boolean> mEvents = new LinkedList<>();
|
||||||
|
|
||||||
public NetworkManager(Context context)
|
public NetworkManager(Context context)
|
||||||
@@ -51,7 +54,7 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
{
|
{
|
||||||
/* we expect this to be called if connected to at least one network during
|
/* we expect this to be called if connected to at least one network during
|
||||||
* callback registration */
|
* callback registration */
|
||||||
mConnectedNetworks += 1;
|
mConnectedNetworks.add(network);
|
||||||
mEvents.addLast(true);
|
mEvents.addLast(true);
|
||||||
NetworkManager.this.notifyAll();
|
NetworkManager.this.notifyAll();
|
||||||
}
|
}
|
||||||
@@ -63,8 +66,8 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
synchronized (NetworkManager.this)
|
synchronized (NetworkManager.this)
|
||||||
{
|
{
|
||||||
/* in particular mobile connections are disconnected overlapping with WiFi */
|
/* in particular mobile connections are disconnected overlapping with WiFi */
|
||||||
mConnectedNetworks -= 1;
|
mConnectedNetworks.remove(network);
|
||||||
mEvents.addLast(mConnectedNetworks > 0);
|
mEvents.addLast(!mConnectedNetworks.isEmpty());
|
||||||
NetworkManager.this.notifyAll();
|
NetworkManager.this.notifyAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +77,11 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
|
|
||||||
public void Register()
|
public void Register()
|
||||||
{
|
{
|
||||||
mEvents.clear();
|
synchronized (this)
|
||||||
|
{
|
||||||
|
mEvents.clear();
|
||||||
|
mConnectedNetworks.clear();
|
||||||
|
}
|
||||||
mRegistered = true;
|
mRegistered = true;
|
||||||
mEventNotifier = new Thread(this);
|
mEventNotifier = new Thread(this);
|
||||||
mEventNotifier.start();
|
mEventNotifier.start();
|
||||||
@@ -174,6 +181,12 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
connected = mEvents.removeFirst();
|
connected = mEvents.removeFirst();
|
||||||
|
/* a burst of identical states (several networks coming up at once)
|
||||||
|
* is one change for charon, not one reconnect attempt each */
|
||||||
|
while (!mEvents.isEmpty() && mEvents.getFirst() == connected)
|
||||||
|
{
|
||||||
|
mEvents.removeFirst();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/* call the native parts without holding the lock */
|
/* call the native parts without holding the lock */
|
||||||
networkChanged(!connected);
|
networkChanged(!connected);
|
||||||
|
|||||||
@@ -229,6 +229,32 @@ object PauseManager
|
|||||||
alarmManager(ctx).cancel(alarmIntent(ctx))
|
alarmManager(ctx).cancel(alarmIntent(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a reboot: alarms do not survive it, so a pause that was running would
|
||||||
|
* never end. Re-arms the alarm, or ends the pause right away if its time is up.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun rearmAfterRestart(context: Context)
|
||||||
|
{
|
||||||
|
val ctx = context.applicationContext
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||||
|
val until = prefs.getLong(PREF_UNTIL, 0L)
|
||||||
|
val profile = prefs.getString(PREF_PROFILE, null)
|
||||||
|
if (profile == null || until == 0L)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (until > System.currentTimeMillis())
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "pause survives the restart, re-arming its alarm")
|
||||||
|
store(ctx, until, profile)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
onAlarm(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun store(ctx: Context, until: Long, profileUuid: String)
|
private fun store(ctx: Context, until: Long, profileUuid: String)
|
||||||
{
|
{
|
||||||
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
|
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
|
||||||
|
|||||||
+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.Intent;
|
||||||
import android.content.IntentFilter;
|
import android.content.IntentFilter;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
import android.os.SystemClock;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.List;
|
||||||
import java.util.PriorityQueue;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import androidx.annotation.RequiresApi;
|
import androidx.annotation.RequiresApi;
|
||||||
@@ -38,14 +38,18 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
private final String TAG = Scheduler.class.getSimpleName();
|
private final String TAG = Scheduler.class.getSimpleName();
|
||||||
private final Context mContext;
|
private final Context mContext;
|
||||||
private final AlarmManager mManager;
|
private final AlarmManager mManager;
|
||||||
private final PriorityQueue<ScheduledJob> mJobs;
|
/* due times are elapsedRealtime: unlike RTC they do not jump when the clock
|
||||||
|
* is set (NTP, manual change, time zone), which would delay keepalives and
|
||||||
|
* rekeyings by hours */
|
||||||
|
private final ScheduledJobQueue mJobs;
|
||||||
|
private boolean mExactLogged;
|
||||||
|
|
||||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||||
public Scheduler(Context context)
|
public Scheduler(Context context)
|
||||||
{
|
{
|
||||||
mContext = context;
|
mContext = context;
|
||||||
mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
||||||
mJobs = new PriorityQueue<>();
|
mJobs = new ScheduledJobQueue(ScheduledJobQueue.COALESCE_MS);
|
||||||
|
|
||||||
IntentFilter filter = new IntentFilter();
|
IntentFilter filter = new IntentFilter();
|
||||||
filter.addAction(EXECUTE_JOB);
|
filter.addAction(EXECUTE_JOB);
|
||||||
@@ -108,7 +112,7 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
* alarms instead of crashing with a SecurityException (connectivity then
|
* alarms instead of crashing with a SecurityException (connectivity then
|
||||||
* only degrades slightly while timers may be delayed in doze mode).
|
* only degrades slightly while timers may be delayed in doze mode).
|
||||||
*
|
*
|
||||||
* @param time wakeup time based on RTC
|
* @param time wakeup time based on elapsedRealtime
|
||||||
*/
|
*/
|
||||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||||
private void scheduleAlarm(long time)
|
private void scheduleAlarm(long time)
|
||||||
@@ -119,21 +123,26 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
{
|
{
|
||||||
exact = mManager.canScheduleExactAlarms();
|
exact = mManager.canScheduleExactAlarms();
|
||||||
}
|
}
|
||||||
|
if (!mExactLogged)
|
||||||
|
{ /* once per run, for support reports */
|
||||||
|
mExactLogged = true;
|
||||||
|
DiagLog.i(TAG, "keepalive alarms are " + (exact ? "exact" : "inexact (exact alarms not allowed)"));
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (exact)
|
if (exact)
|
||||||
{
|
{
|
||||||
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
mManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (SecurityException e)
|
catch (SecurityException e)
|
||||||
{ /* permission might have been revoked, or OEM restrictions apply */
|
{ /* permission might have been revoked, or OEM restrictions apply */
|
||||||
Log.w(TAG, "Falling back to inexact alarm after SecurityException");
|
Log.w(TAG, "Falling back to inexact alarm after SecurityException");
|
||||||
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,12 +158,10 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
{
|
{
|
||||||
synchronized (this)
|
synchronized (this)
|
||||||
{
|
{
|
||||||
ScheduledJob job = new ScheduledJob(id, System.currentTimeMillis() + ms);
|
long time = SystemClock.elapsedRealtime() + ms;
|
||||||
mJobs.add(job);
|
if (mJobs.add(id, time))
|
||||||
|
{ /* the job is due before the armed alarm: move the alarm */
|
||||||
if (job == mJobs.peek())
|
scheduleAlarm(time);
|
||||||
{ /* update the alarm if the job has to be executed before all others */
|
|
||||||
scheduleAlarm(job.Time);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,30 +170,21 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
@Override
|
@Override
|
||||||
public void onReceive(Context context, Intent intent)
|
public void onReceive(Context context, Intent intent)
|
||||||
{
|
{
|
||||||
ArrayList<ScheduledJob> jobs = new ArrayList<>();
|
List<String> jobs;
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
|
|
||||||
synchronized (this)
|
synchronized (this)
|
||||||
{
|
{
|
||||||
ScheduledJob job = mJobs.peek();
|
jobs = mJobs.popDue(SystemClock.elapsedRealtime());
|
||||||
while (job != null)
|
long next = mJobs.nextAlarm();
|
||||||
|
if (next != ScheduledJobQueue.NONE)
|
||||||
{
|
{
|
||||||
if (job.Time > now)
|
scheduleAlarm(next);
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
jobs.add(mJobs.remove());
|
|
||||||
job = mJobs.peek();
|
|
||||||
}
|
|
||||||
if (job != null)
|
|
||||||
{
|
|
||||||
scheduleAlarm(job.Time);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (ScheduledJob job : jobs)
|
for (String id : jobs)
|
||||||
{
|
{
|
||||||
executeJob(job.Id);
|
executeJob(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,25 +194,4 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
* @param id job ID
|
* @param id job ID
|
||||||
*/
|
*/
|
||||||
public native void executeJob(String id);
|
public native void executeJob(String id);
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep track of scheduled jobs.
|
|
||||||
*/
|
|
||||||
private static class ScheduledJob implements Comparable<ScheduledJob>
|
|
||||||
{
|
|
||||||
String Id;
|
|
||||||
long Time;
|
|
||||||
|
|
||||||
ScheduledJob(String id, long time)
|
|
||||||
{
|
|
||||||
Id = id;
|
|
||||||
Time = time;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int compareTo(ScheduledJob o)
|
|
||||||
{
|
|
||||||
return Long.compare(Time, o.Time);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+5
@@ -232,6 +232,7 @@ public class WatchdogReceiver extends BroadcastReceiver
|
|||||||
return;
|
return;
|
||||||
case RESTART:
|
case RESTART:
|
||||||
{
|
{
|
||||||
|
boolean autoRestart = uuid == null;
|
||||||
if (uuid == null)
|
if (uuid == null)
|
||||||
{
|
{
|
||||||
uuid = resolveAutoProfile(context, prefs);
|
uuid = resolveAutoProfile(context, prefs);
|
||||||
@@ -260,6 +261,10 @@ public class WatchdogReceiver extends BroadcastReceiver
|
|||||||
* once, which is harmless (retry flag set, same profile). */
|
* once, which is harmless (retry flag set, same profile). */
|
||||||
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
|
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
|
||||||
DiagLog.i(TAG, "restarting the VPN process");
|
DiagLog.i(TAG, "restarting the VPN process");
|
||||||
|
if (autoRestart)
|
||||||
|
{ /* this process does not remember the session was auto-started */
|
||||||
|
AutoConnectManager.getInstance(context).markAutoSessionRestored();
|
||||||
|
}
|
||||||
Intent start = new Intent(context, CharonVpnService.class);
|
Intent start = new Intent(context, CharonVpnService.class);
|
||||||
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
|
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
|
||||||
start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
|
start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
|
||||||
|
|||||||
+42
-6
@@ -91,6 +91,8 @@ class AutoConnectManager private constructor(
|
|||||||
private var currentNetwork: NetworkKind? = null
|
private var currentNetwork: NetworkKind? = null
|
||||||
/* marks the session this manager started itself */
|
/* marks the session this manager started itself */
|
||||||
private val autoSession = AtomicBoolean(false)
|
private val autoSession = AtomicBoolean(false)
|
||||||
|
/* an auto session was restored by the watchdog and its tunnel is not up yet */
|
||||||
|
@Volatile private var awaitingRestoredTunnel = false
|
||||||
/* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
|
/* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
|
||||||
private var lastParkAt = 0L
|
private var lastParkAt = 0L
|
||||||
|
|
||||||
@@ -123,10 +125,28 @@ class AutoConnectManager private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Network scenarios need the network events even when the network rules are off. */
|
||||||
|
private fun scenariosNeedNetwork(): Boolean =
|
||||||
|
org.strongswan.android.logic.scenarios.ScenarioEngine.needsNetworkEvents(context)
|
||||||
|
|
||||||
|
/** Called when the scenarios changed: starts or stops the network listening to fit. */
|
||||||
|
fun scenariosChanged()
|
||||||
|
{
|
||||||
|
config = loadConfig()
|
||||||
|
if (config.enabled || scenariosNeedNetwork())
|
||||||
|
{
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun start()
|
fun start()
|
||||||
{
|
{
|
||||||
config = loadConfig()
|
config = loadConfig()
|
||||||
if (!config.enabled)
|
if (!config.enabled && !scenariosNeedNetwork())
|
||||||
{
|
{
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -186,8 +206,8 @@ class AutoConnectManager private constructor(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
stop()
|
|
||||||
autoSession.set(false)
|
autoSession.set(false)
|
||||||
|
scenariosChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,8 +234,8 @@ class AutoConnectManager private constructor(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
stop()
|
|
||||||
autoSession.set(false)
|
autoSession.set(false)
|
||||||
|
scenariosChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,12 +256,28 @@ class AutoConnectManager private constructor(
|
|||||||
override fun stateChanged()
|
override fun stateChanged()
|
||||||
{
|
{
|
||||||
val state = service?.state ?: return
|
val state = service?.state ?: return
|
||||||
if (state == VpnStateService.State.DISABLED)
|
if (state != VpnStateService.State.DISABLED)
|
||||||
|
{
|
||||||
|
awaitingRestoredTunnel = false
|
||||||
|
}
|
||||||
|
else if (!awaitingRestoredTunnel)
|
||||||
{
|
{
|
||||||
autoSession.set(false)
|
autoSession.set(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The watchdog brought an auto session back in a fresh process, whose memory
|
||||||
|
* knows nothing of it. Mark it as auto-started again, so it parks on a trusted
|
||||||
|
* Wi-Fi instead of running all night; the DISABLED state of the not yet
|
||||||
|
* started service must not clear the mark.
|
||||||
|
*/
|
||||||
|
fun markAutoSessionRestored()
|
||||||
|
{
|
||||||
|
awaitingRestoredTunnel = true
|
||||||
|
autoSession.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure decision function, unit-testable.
|
* Pure decision function, unit-testable.
|
||||||
*/
|
*/
|
||||||
@@ -268,8 +304,8 @@ class AutoConnectManager private constructor(
|
|||||||
|
|
||||||
private fun evaluate()
|
private fun evaluate()
|
||||||
{
|
{
|
||||||
if (!running.get())
|
if (!running.get() || !config.enabled)
|
||||||
{
|
{ /* running only for the scenarios: they decide, not the network rules */
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val svc = service ?: return
|
val svc = service ?: return
|
||||||
|
|||||||
+71
-1
@@ -30,6 +30,7 @@ import android.os.Build
|
|||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.os.SystemClock
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -43,6 +44,7 @@ import kotlinx.coroutines.sync.Mutex
|
|||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
import org.strongswan.android.logic.VpnStateService
|
import org.strongswan.android.logic.VpnStateService
|
||||||
import org.strongswan.android.logic.events.Events
|
import org.strongswan.android.logic.events.Events
|
||||||
|
|
||||||
@@ -130,6 +132,9 @@ class HealthCheckManager private constructor(
|
|||||||
private var tunnelAttempts = 0
|
private var tunnelAttempts = 0
|
||||||
private var fastPollShifts = 0
|
private var fastPollShifts = 0
|
||||||
private var powerState = PowerState()
|
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 */
|
/* tracks screen/battery state so probing can back off while the device sleeps */
|
||||||
private val powerReceiver = object : BroadcastReceiver()
|
private val powerReceiver = object : BroadcastReceiver()
|
||||||
@@ -360,7 +365,31 @@ class HealthCheckManager private constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val gateway = svc.profile?.gateway
|
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 mode = config.serverCheckMode
|
||||||
val timeout = config.timeoutMs
|
val timeout = config.timeoutMs
|
||||||
|
|
||||||
@@ -609,6 +638,21 @@ class HealthCheckManager private constructor(
|
|||||||
tunnelAttempts = 0
|
tunnelAttempts = 0
|
||||||
fastPollShifts = 0
|
fastPollShifts = 0
|
||||||
pendingDecision = null
|
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()
|
private fun registerVpnCallback()
|
||||||
@@ -705,6 +749,32 @@ class HealthCheckManager private constructor(
|
|||||||
private const val FAST_POLL_MAX_SHIFTS = 3
|
private const val FAST_POLL_MAX_SHIFTS = 3
|
||||||
private const val AGGRESSIVE_INTERVAL_SEC = 5
|
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. */
|
/** Minimum probe interval in adaptive mode while the screen is off. */
|
||||||
internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60
|
internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60
|
||||||
|
|
||||||
|
|||||||
+15
@@ -542,10 +542,22 @@ class RoutingManager private constructor(private val context: Context)
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
delay(SITE_REFRESH_MS)
|
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
|
* Like [updateConfig], but force prefixes apply on the fly through the
|
||||||
* atomic set swap of the userspace router - no reconnect needed.
|
* 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 KEY_NOTICE_ONLY = "notice_only_profiles"
|
||||||
private const val RECONNECT_DEBOUNCE_MS = 2000L
|
private const val RECONNECT_DEBOUNCE_MS = 2000L
|
||||||
const val SITE_REFRESH_MS = 30L * 60 * 1000
|
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. */
|
/** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */
|
||||||
const val CONFIG_VERSION = 2
|
const val CONFIG_VERSION = 2
|
||||||
|
|||||||
+129
-13
@@ -21,13 +21,16 @@ import android.app.usage.UsageStatsManager
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.VpnService
|
import android.net.VpnService
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
|
import android.os.SystemClock
|
||||||
import android.telephony.TelephonyManager
|
import android.telephony.TelephonyManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
@@ -161,6 +164,12 @@ object ScenarioEngine
|
|||||||
private const val TAG = "Scenarios"
|
private const val TAG = "Scenarios"
|
||||||
private const val FILE_NAME = "scenarios.json"
|
private const val FILE_NAME = "scenarios.json"
|
||||||
private const val POLL_MS = 4000L
|
private const val POLL_MS = 4000L
|
||||||
|
/* how long a broadcast waits for the VPN state service of a fresh process */
|
||||||
|
private const val SERVICE_WAIT_MS = 8000L
|
||||||
|
private const val SERVICE_POLL_MS = 200L
|
||||||
|
/* the foreground app did not change for a minute: poll half as often */
|
||||||
|
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_APPLIED = "pref_scenario_applied"
|
||||||
private const val PREF_WAS_ON = "pref_scenario_was_on"
|
private const val PREF_WAS_ON = "pref_scenario_was_on"
|
||||||
private const val PREF_WAS_PROFILE = "pref_scenario_was_profile"
|
private const val PREF_WAS_PROFILE = "pref_scenario_was_profile"
|
||||||
@@ -174,6 +183,7 @@ object ScenarioEngine
|
|||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
private var polling = false
|
private var polling = false
|
||||||
|
private var unchangedPolls = 0
|
||||||
private var lastForeground: String? = null
|
private var lastForeground: String? = null
|
||||||
|
|
||||||
fun flow(context: Context): StateFlow<List<Scenario>>
|
fun flow(context: Context): StateFlow<List<Scenario>>
|
||||||
@@ -188,9 +198,20 @@ object ScenarioEngine
|
|||||||
_list.value = list
|
_list.value = list
|
||||||
persist(context)
|
persist(context)
|
||||||
start(context)
|
start(context)
|
||||||
|
org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).scenariosChanged()
|
||||||
evaluate(context)
|
evaluate(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether an enabled scenario reacts to Wi-Fi, mobile network or roaming changes. */
|
||||||
|
@JvmStatic
|
||||||
|
fun needsNetworkEvents(context: Context): Boolean
|
||||||
|
{
|
||||||
|
load(context.applicationContext)
|
||||||
|
return _list.value.any {
|
||||||
|
it.enabled && (it.trigger == ScenarioTrigger.WIFI || it.trigger == ScenarioTrigger.MOBILE || it.trigger == ScenarioTrigger.ROAMING)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Starts listening (from the application); idempotent. */
|
/** Starts listening (from the application); idempotent. */
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun start(context: Context)
|
fun start(context: Context)
|
||||||
@@ -203,36 +224,118 @@ object ScenarioEngine
|
|||||||
if (needPoll && !polling)
|
if (needPoll && !polling)
|
||||||
{
|
{
|
||||||
polling = true
|
polling = true
|
||||||
handler.post(poll)
|
registerScreenReceiver(app)
|
||||||
|
if (isScreenOn(app))
|
||||||
|
{
|
||||||
|
handler.post(poll)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (!needPoll)
|
else if (!needPoll)
|
||||||
{
|
{
|
||||||
polling = false
|
polling = false
|
||||||
handler.removeCallbacks(poll)
|
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
|
private val poll = object : Runnable
|
||||||
{
|
{
|
||||||
override fun run()
|
override fun run()
|
||||||
{
|
{
|
||||||
val app = appContext ?: return
|
val app = appContext ?: return
|
||||||
if (!polling)
|
if (!polling || !isScreenOn(app))
|
||||||
{
|
{ /* resumed by the screen-on broadcast */
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val screenOn = (app.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
val fg = foregroundApp(app)
|
||||||
if (screenOn)
|
if (fg != null && fg != lastForeground)
|
||||||
{
|
{
|
||||||
val fg = foregroundApp(app)
|
lastForeground = fg
|
||||||
if (fg != null && fg != lastForeground)
|
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)
|
evaluate(app)
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
done?.run()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
handler.postDelayed(this, POLL_MS)
|
|
||||||
}
|
}
|
||||||
|
handler.post(check)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Network, whitelist or alarm: decide again. */
|
/** Network, whitelist or alarm: decide again. */
|
||||||
@@ -377,10 +480,12 @@ object ScenarioEngine
|
|||||||
{
|
{
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
/* without a live service nothing can be put back: keep the record so the
|
||||||
|
* next evaluation (the receiver waits for the service) still restores */
|
||||||
|
val service = VpnStateService.getLiveInstance() ?: return
|
||||||
val wasOn = prefs.getBoolean(PREF_WAS_ON, false)
|
val wasOn = prefs.getBoolean(PREF_WAS_ON, false)
|
||||||
val wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
|
val wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
|
||||||
prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
|
prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
|
||||||
val service = VpnStateService.getLiveInstance() ?: return
|
|
||||||
DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)")
|
DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)")
|
||||||
if (PauseManager.isPaused(context))
|
if (PauseManager.isPaused(context))
|
||||||
{
|
{
|
||||||
@@ -508,12 +613,22 @@ object ScenarioEngine
|
|||||||
{
|
{
|
||||||
c.add(Calendar.DAY_OF_YEAR, 1)
|
c.add(Calendar.DAY_OF_YEAR, 1)
|
||||||
}
|
}
|
||||||
|
/* same choice as PauseManager: exact when the grant allows it */
|
||||||
|
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
|
if (exact)
|
||||||
|
{
|
||||||
|
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (_: SecurityException)
|
catch (_: SecurityException)
|
||||||
{
|
{ /* revoked in between: an inexact alarm is still better than none */
|
||||||
|
runCatching { am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,6 +734,7 @@ class ScenarioReceiver : BroadcastReceiver()
|
|||||||
override fun onReceive(context: Context, intent: Intent?)
|
override fun onReceive(context: Context, intent: Intent?)
|
||||||
{
|
{
|
||||||
ScenarioEngine.start(context)
|
ScenarioEngine.start(context)
|
||||||
ScenarioEngine.evaluate(context)
|
val pending = goAsync()
|
||||||
|
ScenarioEngine.evaluateWhenReady(context) { pending.finish() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+54
-5
@@ -15,6 +15,8 @@
|
|||||||
package org.strongswan.android.logic.stats
|
package org.strongswan.android.logic.stats
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -37,7 +39,8 @@ data class DayTraffic(val day: String, val vpn: Long, val direct: Long)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Daily traffic history (F_Stats). While the tunnel is up the daemon
|
* Daily traffic history (F_Stats). While the tunnel is up the daemon
|
||||||
* counters are sampled every [SAMPLE_S] seconds and the growth is added to
|
* counters are sampled every [SAMPLE_S] seconds (every [SAMPLE_OFF_S] with
|
||||||
|
* the screen off: the counters are cumulative, so nothing is lost) and the growth is added to
|
||||||
* the current day; the counters restart with every connection, so a drop
|
* the current day; the counters restart with every connection, so a drop
|
||||||
* starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json.
|
* starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json.
|
||||||
*/
|
*/
|
||||||
@@ -46,12 +49,21 @@ object TrafficHistory
|
|||||||
private const val TAG = "TrafficHistory"
|
private const val TAG = "TrafficHistory"
|
||||||
private const val FILE_NAME = "traffic.json"
|
private const val FILE_NAME = "traffic.json"
|
||||||
private const val SAMPLE_S = 15L
|
private const val SAMPLE_S = 15L
|
||||||
|
private const val SAMPLE_OFF_S = 60L
|
||||||
|
/* the history lives in memory and is written out this often (not on every
|
||||||
|
* sample: a full JSON rewrite every 15 s is flash wear for nothing); a
|
||||||
|
* killed process loses at most this much of the statistics */
|
||||||
|
internal const val SAVE_EVERY_MS = 5 * 60 * 1000L
|
||||||
private const val KEEP_DAYS = 120
|
private const val KEEP_DAYS = 120
|
||||||
|
|
||||||
private val executor = Executors.newSingleThreadScheduledExecutor()
|
private val executor = Executors.newSingleThreadScheduledExecutor()
|
||||||
private var task: ScheduledFuture<*>? = null
|
private var task: ScheduledFuture<*>? = null
|
||||||
private var lastVpn = -1L
|
private var lastVpn = -1L
|
||||||
private var lastDirect = -1L
|
private var lastDirect = -1L
|
||||||
|
private var lastSampleAt = 0L
|
||||||
|
private var dirty = false
|
||||||
|
private var lastSavedAt = 0L
|
||||||
|
private var lastSavedDay: String? = null
|
||||||
@Volatile private var loaded = false
|
@Volatile private var loaded = false
|
||||||
private val days = LinkedHashMap<String, LongArray>()
|
private val days = LinkedHashMap<String, LongArray>()
|
||||||
private var firstDay: String? = null
|
private var firstDay: String? = null
|
||||||
@@ -86,11 +98,38 @@ object TrafficHistory
|
|||||||
val running = task ?: return
|
val running = task ?: return
|
||||||
running.cancel(false)
|
running.cancel(false)
|
||||||
task = null
|
task = null
|
||||||
executor.execute { sample(app) }
|
executor.execute {
|
||||||
|
sample(app, force = true)
|
||||||
|
flush(app)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sample(context: Context)
|
/** Writes the history out now if it changed since the last write. */
|
||||||
|
@Synchronized
|
||||||
|
fun flush(context: Context)
|
||||||
{
|
{
|
||||||
|
if (dirty)
|
||||||
|
{
|
||||||
|
save(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a changed history has to be written now: periodically and when the day turns. */
|
||||||
|
internal fun saveDue(dirty: Boolean, now: Long, lastSavedAt: Long, dayChanged: Boolean): Boolean =
|
||||||
|
dirty && (dayChanged || now - lastSavedAt >= SAVE_EVERY_MS)
|
||||||
|
|
||||||
|
private fun screenOn(context: Context): Boolean =
|
||||||
|
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive != false
|
||||||
|
|
||||||
|
private fun sample(context: Context, force: Boolean = false)
|
||||||
|
{
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
val screen = screenOn(context)
|
||||||
|
if (!force && !screen && now - lastSampleAt < SAMPLE_OFF_S * 1000L - 1000L)
|
||||||
|
{ /* screen off: a sample a minute is enough, the counters accumulate */
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastSampleAt = now
|
||||||
val traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull()
|
val traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull()
|
||||||
val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return
|
val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return
|
||||||
/* direct bytes only exist with the userspace router (s[3]) */
|
/* direct bytes only exist with the userspace router (s[3]) */
|
||||||
@@ -121,9 +160,16 @@ object TrafficHistory
|
|||||||
firstDay = key
|
firstDay = key
|
||||||
}
|
}
|
||||||
prune()
|
prune()
|
||||||
save(context)
|
dirty = true
|
||||||
|
if (saveDue(dirty, SystemClock.elapsedRealtime(), lastSavedAt, lastSavedDay != null && lastSavedDay != key))
|
||||||
|
{
|
||||||
|
save(context)
|
||||||
|
}
|
||||||
_changes.value = System.currentTimeMillis()
|
_changes.value = System.currentTimeMillis()
|
||||||
org.strongswan.android.ui.widget.ShxWidgetState.refresh(context)
|
if (screenOn(context))
|
||||||
|
{ /* nobody sees the widget with the screen off */
|
||||||
|
org.strongswan.android.ui.widget.ShxWidgetState.refresh(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */
|
/** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */
|
||||||
@@ -205,6 +251,9 @@ object TrafficHistory
|
|||||||
file.writeText(json.toString())
|
file.writeText(json.toString())
|
||||||
tmp.delete()
|
tmp.delete()
|
||||||
}
|
}
|
||||||
|
dirty = false
|
||||||
|
lastSavedAt = SystemClock.elapsedRealtime()
|
||||||
|
lastSavedDay = dayKey(System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
catch (e: Exception)
|
catch (e: Exception)
|
||||||
{
|
{
|
||||||
|
|||||||
+17
-11
@@ -71,6 +71,7 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
import com.journeyapps.barcodescanner.ScanContract
|
import com.journeyapps.barcodescanner.ScanContract
|
||||||
import com.journeyapps.barcodescanner.ScanOptions
|
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 extras by remember { ProfileExtras.flow(context) }.collectAsStateWithLifecycle()
|
||||||
val extra = extras[uuid] ?: ProfileExtra(uuid)
|
val extra = extras[uuid] ?: ProfileExtra(uuid)
|
||||||
/* the live state of the tunnel and a direct ping of the server */
|
/* the live state of the tunnel and a direct ping of the server */
|
||||||
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null) {
|
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null, owner) {
|
||||||
while (true)
|
/* only while the page is on screen */
|
||||||
{
|
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
val service = VpnStateService.getLiveInstance()
|
while (true)
|
||||||
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
|
{
|
||||||
delay(1500)
|
val service = VpnStateService.getLiveInstance()
|
||||||
|
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
|
||||||
|
delay(1500)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val rtt by produceState<Long?>(null, profile.gateway) {
|
val rtt by produceState<Long?>(null, profile.gateway, owner) {
|
||||||
while (true)
|
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
{
|
while (true)
|
||||||
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
|
{
|
||||||
delay(30_000)
|
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
|
||||||
|
delay(30_000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val active = vpn?.second == uuid
|
val active = vpn?.second == uuid
|
||||||
|
|||||||
+12
-1
@@ -199,7 +199,14 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
|||||||
{
|
{
|
||||||
synchronized(underlyingNetworks)
|
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()
|
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()
|
private fun publishNetworks()
|
||||||
{
|
{
|
||||||
_networkAvailable.value = underlyingNetworks.isNotEmpty()
|
_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 result by remember { mutableStateOf<CheckResult?>(null) }
|
||||||
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
|
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
|
||||||
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
|
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
|
||||||
val apps by produceState<List<InstalledApp>>(emptyList(), mode) {
|
var apps by remember { mutableStateOf<List<InstalledApp>>(emptyList()) }
|
||||||
if (mode == RouteCheckMode.APP && value.isEmpty())
|
|
||||||
|
/** 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.SITE -> checkSite(context, manager, input, rulesActive)
|
||||||
RouteCheckMode.IP -> checkIp(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
|
?.let { CheckResult.App(it) } ?: CheckResult.BadInput
|
||||||
}
|
}
|
||||||
if (result !is CheckResult.BadInput)
|
if (result !is CheckResult.BadInput)
|
||||||
@@ -173,10 +186,6 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
|
|||||||
{
|
{
|
||||||
if (initialQuery.isNotBlank())
|
if (initialQuery.isNotBlank())
|
||||||
{
|
{
|
||||||
if (mode == RouteCheckMode.APP)
|
|
||||||
{
|
|
||||||
delay(300)
|
|
||||||
}
|
|
||||||
run()
|
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.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
import org.strongswan.android.logic.PauseManager
|
import org.strongswan.android.logic.PauseManager
|
||||||
@@ -103,12 +106,16 @@ internal fun HomeProblem.titleRes(): Int = when (this)
|
|||||||
internal fun rememberNow(): Long
|
internal fun rememberNow(): Long
|
||||||
{
|
{
|
||||||
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||||
LaunchedEffect(Unit)
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
{
|
LaunchedEffect(lifecycle)
|
||||||
while (true)
|
{ /* no ticking while the app is in the background */
|
||||||
|
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
|
||||||
{
|
{
|
||||||
now = System.currentTimeMillis()
|
while (true)
|
||||||
delay(1000L - now % 1000L)
|
{
|
||||||
|
now = System.currentTimeMillis()
|
||||||
|
delay(1000L - now % 1000L)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return now
|
return now
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
|
#include <poll.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <sys/eventfd.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
#include "android_jni.h"
|
#include "android_jni.h"
|
||||||
@@ -70,6 +73,12 @@ struct private_android_service_t {
|
|||||||
*/
|
*/
|
||||||
int tunfd;
|
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
|
* MTU of TUN device
|
||||||
*/
|
*/
|
||||||
@@ -136,6 +145,21 @@ static void engine_write_packet(void *ctx, ip_packet_t *packet)
|
|||||||
deliver_plain(ctx, 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
|
* Job handling outbound plaintext packets
|
||||||
*/
|
*/
|
||||||
@@ -143,16 +167,10 @@ static job_requeue_t handle_plain(private_android_service_t *this)
|
|||||||
{
|
{
|
||||||
ip_packet_t *packet;
|
ip_packet_t *packet;
|
||||||
chunk_t raw;
|
chunk_t raw;
|
||||||
fd_set set;
|
struct pollfd fds[2];
|
||||||
ssize_t len;
|
ssize_t len;
|
||||||
int tunfd;
|
int tunfd, wakefd, nfds = 1, timeout;
|
||||||
bool old, dns_proxy;
|
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);
|
this->lock->read_lock(this->lock);
|
||||||
if (this->tunfd < 0)
|
if (this->tunfd < 0)
|
||||||
@@ -161,28 +179,60 @@ static job_requeue_t handle_plain(private_android_service_t *this)
|
|||||||
return JOB_REQUEUE_NONE;
|
return JOB_REQUEUE_NONE;
|
||||||
}
|
}
|
||||||
tunfd = this->tunfd;
|
tunfd = this->tunfd;
|
||||||
FD_SET(tunfd, &set);
|
wakefd = this->wakefd;
|
||||||
/* cache this while we have the lock */
|
/* cache this while we have the lock */
|
||||||
dns_proxy = this->use_dns_proxy;
|
dns_proxy = this->use_dns_proxy;
|
||||||
this->lock->unlock(this->lock);
|
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);
|
old = thread_cancelability(TRUE);
|
||||||
len = select(tunfd + 1, &set, NULL, NULL, &tv);
|
len = poll(fds, nfds, timeout);
|
||||||
thread_cancelability(old);
|
thread_cancelability(old);
|
||||||
|
|
||||||
if (len < 0)
|
if (len < 0)
|
||||||
{
|
{
|
||||||
if (errno == EBADF)
|
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;
|
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;
|
return JOB_REQUEUE_NONE;
|
||||||
}
|
}
|
||||||
else if (len == 0)
|
else if (len == 0)
|
||||||
{ /* timeout, check again right away */
|
{ /* timeout, check again right away */
|
||||||
return JOB_REQUEUE_DIRECT;
|
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);
|
raw = chunk_alloc(this->mtu);
|
||||||
len = read(tunfd, raw.ptr, raw.len);
|
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->tunfd = tunfd;
|
||||||
this->lock->unlock(this->lock);
|
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");
|
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->tunfd = tunfd;
|
||||||
this->lock->unlock(this->lock);
|
this->lock->unlock(this->lock);
|
||||||
|
wake_reader(this);
|
||||||
|
|
||||||
/* the fd changed, re-bind the userspace router to the new TUN */
|
/* the fd changed, re-bind the userspace router to the new TUN */
|
||||||
direct_engine_set_tun(this, engine_write_packet, this->mtu);
|
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;
|
tunfd = this->tunfd;
|
||||||
this->tunfd = -1;
|
this->tunfd = -1;
|
||||||
this->lock->unlock(this->lock);
|
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
|
/* drop the remembered binding too, so a lazily created engine does not
|
||||||
* attach to the dead TUN, and flush the engine if it exists */
|
* 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);
|
close_tun_device(this);
|
||||||
this->dns_proxy->destroy(this->dns_proxy);
|
this->dns_proxy->destroy(this->dns_proxy);
|
||||||
this->lock->destroy(this->lock);
|
this->lock->destroy(this->lock);
|
||||||
|
if (this->wakefd >= 0)
|
||||||
|
{
|
||||||
|
close(this->wakefd);
|
||||||
|
}
|
||||||
this->settings->destroy(this->settings);
|
this->settings->destroy(this->settings);
|
||||||
free(this);
|
free(this);
|
||||||
}
|
}
|
||||||
@@ -1005,6 +1064,7 @@ android_service_t *android_service_create(android_creds_t *creds,
|
|||||||
.settings = settings,
|
.settings = settings,
|
||||||
.creds = creds,
|
.creds = creds,
|
||||||
.tunfd = -1,
|
.tunfd = -1,
|
||||||
|
.wakefd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC),
|
||||||
.mtu = settings->get_int(settings, "global.mtu", ANDROID_DEFAULT_MTU),
|
.mtu = settings->get_int(settings, "global.mtu", ANDROID_DEFAULT_MTU),
|
||||||
);
|
);
|
||||||
/* only allow queries for the VPN gateway */
|
/* only allow queries for the VPN gateway */
|
||||||
|
|||||||
@@ -797,10 +797,9 @@ static void *engine_thread(private_direct_engine_t *this)
|
|||||||
{
|
{
|
||||||
active = TRUE;
|
active = TRUE;
|
||||||
this->udp->reprotect(this->udp);
|
this->udp->reprotect(this->udp);
|
||||||
if (this->tcp_active)
|
/* also while lwIP is dormant: this clears the negative cache of
|
||||||
{
|
* failed direct connects, which must not survive a roam */
|
||||||
this->tcp->reprotect(this->tcp);
|
this->tcp->reprotect(this->tcp);
|
||||||
}
|
|
||||||
/* the network changed: whatever was dead for direct connects
|
/* the network changed: whatever was dead for direct connects
|
||||||
* may work again - drop the tunnel fallbacks with the rest of
|
* may work again - drop the tunnel fallbacks with the rest of
|
||||||
* the failure state (tcp->reprotect clears the neg table) */
|
* the failure state (tcp->reprotect clears the neg table) */
|
||||||
@@ -812,8 +811,22 @@ static void *engine_thread(private_direct_engine_t *this)
|
|||||||
this->bind_lock->unlock(this->bind_lock);
|
this->bind_lock->unlock(this->bind_lock);
|
||||||
|
|
||||||
if (!bound && this->unbound_flushed)
|
if (!bound && this->unbound_flushed)
|
||||||
{ /* TUN still gone and already flushed */
|
{ /* TUN still gone and already flushed: nothing to do until the
|
||||||
|
* re-bind wakes us, so back off like an idle round instead of
|
||||||
|
* polling at the active cadence all through a re-authentication */
|
||||||
drain_queue_nobind(this);
|
drain_queue_nobind(this);
|
||||||
|
if (active)
|
||||||
|
{
|
||||||
|
poll_ms = ENGINE_POLL_MS;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
poll_ms *= 2;
|
||||||
|
if (poll_ms > ENGINE_IDLE_POLL_MS)
|
||||||
|
{
|
||||||
|
poll_ms = ENGINE_IDLE_POLL_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,6 +875,12 @@ static void *engine_thread(private_direct_engine_t *this)
|
|||||||
{
|
{
|
||||||
this->tcp->process_poll(this->tcp, fds + 1 + udp_n, tcp_n);
|
this->tcp->process_poll(this->tcp, fds + 1 + udp_n, tcp_n);
|
||||||
this->tcp->check_timers(this->tcp);
|
this->tcp->check_timers(this->tcp);
|
||||||
|
if (!active && this->tcp->flow_count(this->tcp) == 0)
|
||||||
|
{ /* a full idle round with no TCP flow left: let lwIP go dormant
|
||||||
|
* again (the next TCP packet wakes it, its timers catch up),
|
||||||
|
* so the idle cadence stretches back to ENGINE_IDLE_POLL_MS */
|
||||||
|
this->tcp_active = FALSE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{ /* adapt the cadence after the work of this round is known */
|
{ /* adapt the cadence after the work of this round is known */
|
||||||
|
|||||||
@@ -966,6 +966,12 @@ METHOD(direct_tcp_t, check_timers, void,
|
|||||||
enumerator->destroy(enumerator);
|
enumerator->destroy(enumerator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
METHOD(direct_tcp_t, flow_count, size_t,
|
||||||
|
private_direct_tcp_t *this)
|
||||||
|
{
|
||||||
|
return this->relays->get_count(this->relays);
|
||||||
|
}
|
||||||
|
|
||||||
METHOD(direct_tcp_t, set_mtu, void,
|
METHOD(direct_tcp_t, set_mtu, void,
|
||||||
private_direct_tcp_t *this, uint16_t mtu)
|
private_direct_tcp_t *this, uint16_t mtu)
|
||||||
{
|
{
|
||||||
@@ -1290,6 +1296,7 @@ direct_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine,
|
|||||||
.public = {
|
.public = {
|
||||||
.handle_packet = _handle_packet,
|
.handle_packet = _handle_packet,
|
||||||
.check_timers = _check_timers,
|
.check_timers = _check_timers,
|
||||||
|
.flow_count = _flow_count,
|
||||||
.set_mtu = _set_mtu,
|
.set_mtu = _set_mtu,
|
||||||
.reprotect = _reprotect,
|
.reprotect = _reprotect,
|
||||||
.fill_poll = _fill_poll,
|
.fill_poll = _fill_poll,
|
||||||
|
|||||||
+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, 3)).isTrue()
|
||||||
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 0)).isFalse()
|
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
|
# 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, приложение обновляется само.
|
Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само.
|
||||||
|
|
||||||
- Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду.
|
- Дизайн: [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