Compare commits

...
11 Commits
Author SHA1 Message Date
DenozordecandClaude Sonnet 5.5 0e35a794c9 android: version 2.19.1 (169)
Android / pre-check (push) Failing after 5s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 4s
CIFuzz / fuzzing (undefined) (push) Skipped
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 4s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:55:25 +07:00
DenozordecandClaude Sonnet 5.5 5ffb5a2dbb android: «How traffic goes» no longer says the app is not found on the first try: the check waits for the installed-apps list instead of a fixed 300 ms delay
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:50:50 +07:00
DenozordecandClaude Sonnet 5.5 ae06a58013 android: version 2.19.0 (168)
Android / pre-check (push) Failing after 7s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 3s
CIFuzz / fuzzing (undefined) (push) Skipped
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 3s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
macOS / pre-check (push) Failing after 5s
Linux / alpine (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:30:09 +07:00
DenozordecandClaude Sonnet 5.5 9109eb8460 android: an unattended service start (watchdog, pause end, FGS retry) no longer wipes the password saved with the profile; smoke-test notes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:21:38 +07:00
DenozordecandClaude Sonnet 5.5 1ef8c983c7 android: keepalive/rekey alarms on elapsedRealtime with coalescing; BootReceiver re-arms pause and scenario alarms; scenarios survive a fresh process and run without network rules; watchdog-restored auto session parks again; NetworkManager tracks a network set; idle native engine goes dormant again; traffic history written every 5 min
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 11:48:27 +07:00
DenozordecandClaude Sonnet 5.5 072674d1a8 android: version 2.18.2 (167)
Android / pre-check (push) Failing after 6s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 3s
CIFuzz / fuzzing (undefined) (push) Skipped
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 4s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:42:48 +07:00
DenozordecandClaude Sonnet 5.5 228f9f6004 android: deleting or updating a profile no longer crashes when its data source was already closed
A profile keeps the VpnProfileSqlDataSource it was loaded from, and close() nulls its database, so insert/update/delete on that profile hit a NullPointerException and the profile stayed in place. The data source now reopens the database on demand.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:41:29 +07:00
DenozordecandClaude Sonnet 5.5 6756d2fca5 android: version 2.18.1 (166)
Android / pre-check (push) Failing after 5s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 4s
CIFuzz / fuzzing (undefined) (push) Skipped
CodeQL / pre-check (push) Failing after 5s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 4s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 01:52:12 +07:00
DenozordecandClaude Sonnet 5.5 5998c0fc46 android: Home view model no longer re-reads the Wi-Fi SSID on every signal-strength tick (state recomputed only when the transports change); energy plan: no shared network-callback hub, why
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 01:44:03 +07:00
DenozordecandClaude Sonnet 5.5 f5b15a0394 android: health check skips probes while inbound tunnel traffic proves liveness (adaptive mode, real probe at least every 5 min) and probes a single target with the screen off; unit tests; energy plan notes keepalive stays at 45 s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 01:22:09 +07:00
DenozordecandClaude Sonnet 5.5 38e5fd84bb android: less idle CPU work: TUN reader waits on poll+eventfd instead of a 1 s select; scenario app polling stops with the screen and slows down when idle; site DNS refresh waits for the screen; ServerActivity and rememberNow loops follow the lifecycle; energy plan
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 01:18:15 +07:00
27 changed files with 1086 additions and 130 deletions

No files matched your search

+2 -2
View File
@@ -45,8 +45,8 @@ android {
minSdk = 24
targetSdk = 36
versionCode 165
versionName "2.18.0"
versionCode 169
versionName "2.19.1"
/* Base URL of the update storage, trailing slash included */
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
@@ -27,6 +27,7 @@
<!-- SSID is redacted in the background on Android 10+ without this. -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
@@ -339,6 +340,17 @@
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- after a reboot or a clock change: re-arm the pause and scenario alarms, evaluate the rules again -->
<receiver
android:name=".logic.BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -54,6 +54,16 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
return this;
}
/**
* A profile keeps the data source it was loaded from, which may be closed
* by the time the profile is updated or deleted, so reopen on demand.
*/
private SQLiteDatabase database()
{
open();
return mDatabase;
}
@Override
public void close()
{
@@ -67,7 +77,7 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
public VpnProfile insertProfile(VpnProfile profile)
{
ContentValues values = ContentValuesFromVpnProfile(profile);
long insertId = mDatabase.insert(DatabaseHelper.TABLE_VPN_PROFILE.Name, null, values);
long insertId = database().insert(DatabaseHelper.TABLE_VPN_PROFILE.Name, null, values);
if (insertId == -1)
{
return null;
@@ -82,14 +92,14 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
{
final UUID uuid = profile.getUUID();
ContentValues values = ContentValuesFromVpnProfile(profile);
return mDatabase.update(DatabaseHelper.TABLE_VPN_PROFILE.Name, values, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
return database().update(DatabaseHelper.TABLE_VPN_PROFILE.Name, values, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
}
@Override
public boolean deleteVpnProfile(VpnProfile profile)
{
final UUID uuid = profile.getUUID();
return mDatabase.delete(DatabaseHelper.TABLE_VPN_PROFILE.Name, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
return database().delete(DatabaseHelper.TABLE_VPN_PROFILE.Name, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
}
@Override
@@ -0,0 +1,53 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import org.strongswan.android.logic.autoconnect.AutoConnectManager
import org.strongswan.android.logic.scenarios.ScenarioEngine
/**
* Picks the automation up again after a reboot or a change of the clock.
*
* Starting the process runs the application's start-up (network rules, scenarios),
* but alarms do not survive a reboot, and a time or time zone change moves the
* edges of the scenario time windows. This receiver re-arms what the alarms
* carried and lets the rules decide again. It never starts the tunnel by itself:
* the rules and scenarios do, and a profile without a saved password stays off.
*/
class BootReceiver : BroadcastReceiver()
{
override fun onReceive(context: Context, intent: Intent?)
{
val action = intent?.action ?: return
DiagLog.i(TAG, "system event: $action")
if (action == Intent.ACTION_BOOT_COMPLETED)
{
PauseManager.rearmAfterRestart(context)
}
/* start() re-arms the time-window alarm for the current clock and zone */
ScenarioEngine.start(context)
AutoConnectManager.getInstance(context).reevaluate()
val pending = goAsync()
ScenarioEngine.evaluateWhenReady(context) { pending.finish() }
}
private companion object
{
const val TAG = "BootReceiver"
}
}
@@ -269,7 +269,13 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
VpnProfile profile = loadProfile(uuid);
if (profile != null)
{
profile.setPassword(bundle.getString(VpnProfileDataSource.KEY_PASSWORD));
String password = bundle.getString(VpnProfileDataSource.KEY_PASSWORD);
if (password != null)
{ /* an unattended start (watchdog, pause end, FGS retry) carries no
* password: keep the one saved with the profile instead of
* wiping it, or the tunnel never comes back («password unavailable») */
profile.setPassword(password);
}
/* a backup port of F_Profile, for this attempt only */
int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0);
if (port > 0)
@@ -25,6 +25,7 @@ import android.net.Network;
import android.net.NetworkRequest;
import android.os.Build;
import java.util.HashSet;
import java.util.LinkedList;
public class NetworkManager extends BroadcastReceiver implements Runnable
@@ -33,7 +34,9 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
private volatile boolean mRegistered;
private ConnectivityManager.NetworkCallback mCallback;
private Thread mEventNotifier;
private int mConnectedNetworks = 0;
/* a set, not a counter: a repeated onAvailable or a missed onLost cannot
* make it drift away from the networks that really exist */
private final HashSet<Network> mConnectedNetworks = new HashSet<>();
private LinkedList<Boolean> mEvents = new LinkedList<>();
public NetworkManager(Context context)
@@ -51,7 +54,7 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
{
/* we expect this to be called if connected to at least one network during
* callback registration */
mConnectedNetworks += 1;
mConnectedNetworks.add(network);
mEvents.addLast(true);
NetworkManager.this.notifyAll();
}
@@ -63,8 +66,8 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
synchronized (NetworkManager.this)
{
/* in particular mobile connections are disconnected overlapping with WiFi */
mConnectedNetworks -= 1;
mEvents.addLast(mConnectedNetworks > 0);
mConnectedNetworks.remove(network);
mEvents.addLast(!mConnectedNetworks.isEmpty());
NetworkManager.this.notifyAll();
}
}
@@ -74,7 +77,11 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
public void Register()
{
mEvents.clear();
synchronized (this)
{
mEvents.clear();
mConnectedNetworks.clear();
}
mRegistered = true;
mEventNotifier = new Thread(this);
mEventNotifier.start();
@@ -174,6 +181,12 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
break;
}
connected = mEvents.removeFirst();
/* a burst of identical states (several networks coming up at once)
* is one change for charon, not one reconnect attempt each */
while (!mEvents.isEmpty() && mEvents.getFirst() == connected)
{
mEvents.removeFirst();
}
}
/* call the native parts without holding the lock */
networkChanged(!connected);
@@ -229,6 +229,32 @@ object PauseManager
alarmManager(ctx).cancel(alarmIntent(ctx))
}
/**
* After a reboot: alarms do not survive it, so a pause that was running would
* never end. Re-arms the alarm, or ends the pause right away if its time is up.
*/
@JvmStatic
fun rearmAfterRestart(context: Context)
{
val ctx = context.applicationContext
val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
val until = prefs.getLong(PREF_UNTIL, 0L)
val profile = prefs.getString(PREF_PROFILE, null)
if (profile == null || until == 0L)
{
return
}
if (until > System.currentTimeMillis())
{
DiagLog.i(TAG, "pause survives the restart, re-arming its alarm")
store(ctx, until, profile)
}
else
{
onAlarm(ctx)
}
}
private fun store(ctx: Context, until: Long, profileUuid: String)
{
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
@@ -0,0 +1,98 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
import java.util.PriorityQueue
/**
* The job queue behind [Scheduler]: charon's timers, ordered by due time, and the
* bookkeeping of the single alarm that wakes the device for the earliest one.
* Times are on any monotonic clock (the scheduler passes elapsedRealtime).
*
* Jobs due within [coalesceMs] of each other share one alarm: a job may run up to
* [coalesceMs] early, never late. charon only schedules jobs longer than 3 s ahead,
* so 2 s is harmless, and every alarm saved is one more slot in the Doze quota of
* allow-while-idle alarms that the keepalive needs.
*
* Not thread-safe; the caller synchronises.
*/
class ScheduledJobQueue(private val coalesceMs: Long = COALESCE_MS)
{
private class Job(val id: String, val time: Long) : Comparable<Job>
{
override fun compareTo(other: Job): Int = time.compareTo(other.time)
}
private val jobs = PriorityQueue<Job>()
private var armed = NONE
/** Due time the alarm is currently set for, or [NONE]. */
val armedAt: Long get() = armed
val size: Int get() = jobs.size
/**
* Queues a job.
*
* @return true if the alarm has to be (re)armed for [nextAlarm]; false if the
* armed alarm already covers the job
*/
fun add(id: String, time: Long): Boolean
{
jobs.add(Job(id, time))
if (armed == NONE || time < armed - coalesceMs)
{
armed = time
return true
}
return false
}
/**
* Removes the jobs that are due at [now] (or within the coalescing window).
* The alarm is considered fired: [nextAlarm] says whether to arm another one.
*/
fun popDue(now: Long): List<String>
{
val limit = now + coalesceMs
val due = ArrayList<String>()
while (true)
{
val head = jobs.peek() ?: break
if (head.time > limit)
{
break
}
due.add(jobs.remove().id)
}
armed = jobs.peek()?.time ?: NONE
return due
}
/** Due time of the earliest queued job, or [NONE]; the time to arm after [popDue]. */
fun nextAlarm(): Long = jobs.peek()?.time ?: NONE
fun clear()
{
jobs.clear()
armed = NONE
}
companion object
{
const val NONE = -1L
const val COALESCE_MS = 2000L
}
}
@@ -24,10 +24,10 @@ import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
import android.os.SystemClock;
import android.util.Log;
import java.util.ArrayList;
import java.util.PriorityQueue;
import java.util.List;
import java.util.UUID;
import androidx.annotation.RequiresApi;
@@ -38,14 +38,18 @@ public class Scheduler extends BroadcastReceiver
private final String TAG = Scheduler.class.getSimpleName();
private final Context mContext;
private final AlarmManager mManager;
private final PriorityQueue<ScheduledJob> mJobs;
/* due times are elapsedRealtime: unlike RTC they do not jump when the clock
* is set (NTP, manual change, time zone), which would delay keepalives and
* rekeyings by hours */
private final ScheduledJobQueue mJobs;
private boolean mExactLogged;
@SuppressLint("UnspecifiedRegisterReceiverFlag")
public Scheduler(Context context)
{
mContext = context;
mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
mJobs = new PriorityQueue<>();
mJobs = new ScheduledJobQueue(ScheduledJobQueue.COALESCE_MS);
IntentFilter filter = new IntentFilter();
filter.addAction(EXECUTE_JOB);
@@ -108,7 +112,7 @@ public class Scheduler extends BroadcastReceiver
* alarms instead of crashing with a SecurityException (connectivity then
* only degrades slightly while timers may be delayed in doze mode).
*
* @param time wakeup time based on RTC
* @param time wakeup time based on elapsedRealtime
*/
@RequiresApi(api = Build.VERSION_CODES.M)
private void scheduleAlarm(long time)
@@ -119,21 +123,26 @@ public class Scheduler extends BroadcastReceiver
{
exact = mManager.canScheduleExactAlarms();
}
if (!mExactLogged)
{ /* once per run, for support reports */
mExactLogged = true;
DiagLog.i(TAG, "keepalive alarms are " + (exact ? "exact" : "inexact (exact alarms not allowed)"));
}
try
{
if (exact)
{
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
mManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
}
else
{
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
}
}
catch (SecurityException e)
{ /* permission might have been revoked, or OEM restrictions apply */
Log.w(TAG, "Falling back to inexact alarm after SecurityException");
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
}
}
@@ -149,12 +158,10 @@ public class Scheduler extends BroadcastReceiver
{
synchronized (this)
{
ScheduledJob job = new ScheduledJob(id, System.currentTimeMillis() + ms);
mJobs.add(job);
if (job == mJobs.peek())
{ /* update the alarm if the job has to be executed before all others */
scheduleAlarm(job.Time);
long time = SystemClock.elapsedRealtime() + ms;
if (mJobs.add(id, time))
{ /* the job is due before the armed alarm: move the alarm */
scheduleAlarm(time);
}
}
}
@@ -163,30 +170,21 @@ public class Scheduler extends BroadcastReceiver
@Override
public void onReceive(Context context, Intent intent)
{
ArrayList<ScheduledJob> jobs = new ArrayList<>();
long now = System.currentTimeMillis();
List<String> jobs;
synchronized (this)
{
ScheduledJob job = mJobs.peek();
while (job != null)
jobs = mJobs.popDue(SystemClock.elapsedRealtime());
long next = mJobs.nextAlarm();
if (next != ScheduledJobQueue.NONE)
{
if (job.Time > now)
{
break;
}
jobs.add(mJobs.remove());
job = mJobs.peek();
}
if (job != null)
{
scheduleAlarm(job.Time);
scheduleAlarm(next);
}
}
for (ScheduledJob job : jobs)
for (String id : jobs)
{
executeJob(job.Id);
executeJob(id);
}
}
@@ -196,25 +194,4 @@ public class Scheduler extends BroadcastReceiver
* @param id job ID
*/
public native void executeJob(String id);
/**
* Keep track of scheduled jobs.
*/
private static class ScheduledJob implements Comparable<ScheduledJob>
{
String Id;
long Time;
ScheduledJob(String id, long time)
{
Id = id;
Time = time;
}
@Override
public int compareTo(ScheduledJob o)
{
return Long.compare(Time, o.Time);
}
}
}
@@ -232,6 +232,7 @@ public class WatchdogReceiver extends BroadcastReceiver
return;
case RESTART:
{
boolean autoRestart = uuid == null;
if (uuid == null)
{
uuid = resolveAutoProfile(context, prefs);
@@ -260,6 +261,10 @@ public class WatchdogReceiver extends BroadcastReceiver
* once, which is harmless (retry flag set, same profile). */
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
DiagLog.i(TAG, "restarting the VPN process");
if (autoRestart)
{ /* this process does not remember the session was auto-started */
AutoConnectManager.getInstance(context).markAutoSessionRestored();
}
Intent start = new Intent(context, CharonVpnService.class);
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
@@ -91,6 +91,8 @@ class AutoConnectManager private constructor(
private var currentNetwork: NetworkKind? = null
/* marks the session this manager started itself */
private val autoSession = AtomicBoolean(false)
/* an auto session was restored by the watchdog and its tunnel is not up yet */
@Volatile private var awaitingRestoredTunnel = false
/* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
private var lastParkAt = 0L
@@ -123,10 +125,28 @@ class AutoConnectManager private constructor(
}
}
/** Network scenarios need the network events even when the network rules are off. */
private fun scenariosNeedNetwork(): Boolean =
org.strongswan.android.logic.scenarios.ScenarioEngine.needsNetworkEvents(context)
/** Called when the scenarios changed: starts or stops the network listening to fit. */
fun scenariosChanged()
{
config = loadConfig()
if (config.enabled || scenariosNeedNetwork())
{
start()
}
else
{
stop()
}
}
fun start()
{
config = loadConfig()
if (!config.enabled)
if (!config.enabled && !scenariosNeedNetwork())
{
return
}
@@ -186,8 +206,8 @@ class AutoConnectManager private constructor(
}
else
{
stop()
autoSession.set(false)
scenariosChanged()
}
}
@@ -214,8 +234,8 @@ class AutoConnectManager private constructor(
}
else
{
stop()
autoSession.set(false)
scenariosChanged()
}
}
@@ -236,12 +256,28 @@ class AutoConnectManager private constructor(
override fun stateChanged()
{
val state = service?.state ?: return
if (state == VpnStateService.State.DISABLED)
if (state != VpnStateService.State.DISABLED)
{
awaitingRestoredTunnel = false
}
else if (!awaitingRestoredTunnel)
{
autoSession.set(false)
}
}
/**
* The watchdog brought an auto session back in a fresh process, whose memory
* knows nothing of it. Mark it as auto-started again, so it parks on a trusted
* Wi-Fi instead of running all night; the DISABLED state of the not yet
* started service must not clear the mark.
*/
fun markAutoSessionRestored()
{
awaitingRestoredTunnel = true
autoSession.set(true)
}
/**
* Pure decision function, unit-testable.
*/
@@ -268,8 +304,8 @@ class AutoConnectManager private constructor(
private fun evaluate()
{
if (!running.get())
{
if (!running.get() || !config.enabled)
{ /* running only for the scenarios: they decide, not the network rules */
return
}
val svc = service ?: return
@@ -30,6 +30,7 @@ import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.SystemClock
import android.os.PowerManager
import android.util.Log
import kotlinx.coroutines.CoroutineScope
@@ -43,6 +44,7 @@ import kotlinx.coroutines.sync.Mutex
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.events.Events
@@ -130,6 +132,9 @@ class HealthCheckManager private constructor(
private var tunnelAttempts = 0
private var fastPollShifts = 0
private var powerState = PowerState()
/* tunnel inbound bytes at the previous cycle (-1: unknown) and when the last real probe ran */
private var lastCycleInbound = -1L
private var lastProbeAt = 0L
/* tracks screen/battery state so probing can back off while the device sleeps */
private val powerReceiver = object : BroadcastReceiver()
@@ -360,7 +365,31 @@ class HealthCheckManager private constructor(
return
}
val gateway = svc.profile?.gateway
val targets = config.targets
val adapted = adaptToPowerState(config, powerState)
val nowMs = SystemClock.elapsedRealtime()
/* passive liveness: payload that came back through the tunnel since the
* previous cycle proves it works without waking the radio for probes */
val inbound = tunnelInboundBytes()
val inboundDelta = if (lastCycleInbound >= 0 && inbound >= 0) inbound - lastCycleInbound else -1L
lastCycleInbound = inbound
if (trafficProvesAlive(
adapted, serverAttempts + tunnelAttempts, fastPollShifts, pendingDecision != null,
inboundDelta, nowMs - lastProbeAt,
))
{
scheduleCycle(effectiveIntervalSec(adapted) * 1000L)
return
}
lastProbeAt = nowMs
/* screen off: one target is enough (the confirmation probe uses it too) */
val targets = if (adapted.adaptiveMode && !powerState.screenOn && config.targets.size > 1)
{
setOf(config.targets.first())
}
else
{
config.targets
}
val mode = config.serverCheckMode
val timeout = config.timeoutMs
@@ -609,6 +638,21 @@ class HealthCheckManager private constructor(
tunnelAttempts = 0
fastPollShifts = 0
pendingDecision = null
lastCycleInbound = -1L
lastProbeAt = 0L
}
/** Inbound byte counter of the tunnel's CHILD_SAs, -1 when unavailable. */
private fun tunnelInboundBytes(): Long
{
return try
{
CharonVpnService.queryTraffic()?.getOrNull(0) ?: -1L
}
catch (_: Throwable)
{
-1L
}
}
private fun registerVpnCallback()
@@ -705,6 +749,32 @@ class HealthCheckManager private constructor(
private const val FAST_POLL_MAX_SHIFTS = 3
private const val AGGRESSIVE_INTERVAL_SEC = 5
/** Inbound tunnel payload since the last cycle that counts as proof of life. */
internal const val PASSIVE_MIN_INBOUND_BYTES = 4096L
/** Even with traffic flowing a real probe runs at least this often. */
internal const val PASSIVE_MAX_PROBE_AGE_MS = 5 * 60 * 1000L
/**
* Pure function: whether the cycle may skip its probes because the
* tunnel carried inbound data since the previous cycle. Only in
* adaptive mode, never in aggressive mode, never while a failure is
* being observed, and never for longer than [PASSIVE_MAX_PROBE_AGE_MS].
* Outbound-only traffic proves nothing, so it does not count.
*/
fun trafficProvesAlive(
adapted: HealthConfig,
failedAttempts: Int,
fastPollShifts: Int,
decisionPending: Boolean,
inboundDelta: Long,
sinceLastProbeMs: Long,
): Boolean =
adapted.adaptiveMode && !adapted.aggressiveMode &&
failedAttempts == 0 && fastPollShifts == 0 && !decisionPending &&
inboundDelta >= PASSIVE_MIN_INBOUND_BYTES &&
sinceLastProbeMs in 0 until PASSIVE_MAX_PROBE_AGE_MS
/** Minimum probe interval in adaptive mode while the screen is off. */
internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60
@@ -542,10 +542,22 @@ class RoutingManager private constructor(private val context: Context)
break
}
delay(SITE_REFRESH_MS)
/* nobody looks at the screen: resolving only wakes the radio and
* CPU, so wait for it to come on (re-checked every few minutes),
* but never let the answers get older than the cap */
var waited = SITE_REFRESH_MS
while (waited < SITE_REFRESH_SCREEN_OFF_MAX_MS && !isScreenOn())
{
delay(SITE_SCREEN_RECHECK_MS)
waited += SITE_SCREEN_RECHECK_MS
}
}
}
}
private fun isScreenOn(): Boolean =
(context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager)?.isInteractive ?: true
/**
* Like [updateConfig], but force prefixes apply on the fly through the
* atomic set swap of the userspace router - no reconnect needed.
@@ -833,6 +845,9 @@ class RoutingManager private constructor(private val context: Context)
private const val KEY_NOTICE_ONLY = "notice_only_profiles"
private const val RECONNECT_DEBOUNCE_MS = 2000L
const val SITE_REFRESH_MS = 30L * 60 * 1000
/* with the screen off the refresh waits for it, up to this age */
private const val SITE_REFRESH_SCREEN_OFF_MAX_MS = 3L * 60 * 60 * 1000
private const val SITE_SCREEN_RECHECK_MS = 5L * 60 * 1000
/** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */
const val CONFIG_VERSION = 2
@@ -21,13 +21,16 @@ import android.app.usage.UsageStatsManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.VpnService
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import android.telephony.TelephonyManager
import android.util.Log
import androidx.preference.PreferenceManager
@@ -161,6 +164,12 @@ object ScenarioEngine
private const val TAG = "Scenarios"
private const val FILE_NAME = "scenarios.json"
private const val POLL_MS = 4000L
/* how long a broadcast waits for the VPN state service of a fresh process */
private const val SERVICE_WAIT_MS = 8000L
private const val SERVICE_POLL_MS = 200L
/* the foreground app did not change for a minute: poll half as often */
private const val SLOW_AFTER_POLLS = 15
private const val SLOW_POLL_MS = 8000L
private const val PREF_APPLIED = "pref_scenario_applied"
private const val PREF_WAS_ON = "pref_scenario_was_on"
private const val PREF_WAS_PROFILE = "pref_scenario_was_profile"
@@ -174,6 +183,7 @@ object ScenarioEngine
private val handler = Handler(Looper.getMainLooper())
private var appContext: Context? = null
private var polling = false
private var unchangedPolls = 0
private var lastForeground: String? = null
fun flow(context: Context): StateFlow<List<Scenario>>
@@ -188,9 +198,20 @@ object ScenarioEngine
_list.value = list
persist(context)
start(context)
org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).scenariosChanged()
evaluate(context)
}
/** Whether an enabled scenario reacts to Wi-Fi, mobile network or roaming changes. */
@JvmStatic
fun needsNetworkEvents(context: Context): Boolean
{
load(context.applicationContext)
return _list.value.any {
it.enabled && (it.trigger == ScenarioTrigger.WIFI || it.trigger == ScenarioTrigger.MOBILE || it.trigger == ScenarioTrigger.ROAMING)
}
}
/** Starts listening (from the application); idempotent. */
@JvmStatic
fun start(context: Context)
@@ -203,36 +224,118 @@ object ScenarioEngine
if (needPoll && !polling)
{
polling = true
handler.post(poll)
registerScreenReceiver(app)
if (isScreenOn(app))
{
handler.post(poll)
}
}
else if (!needPoll)
{
polling = false
handler.removeCallbacks(poll)
unregisterScreenReceiver(app)
}
}
private fun isScreenOn(context: Context): Boolean =
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
/* the foreground app only changes while the screen is on: no polling (and no
* wake-ups for it) while it is off, one immediate poll when it comes back */
private val screenReceiver = object : BroadcastReceiver()
{
override fun onReceive(context: Context, intent: Intent)
{
handler.removeCallbacks(poll)
if (polling && intent.action == Intent.ACTION_SCREEN_ON)
{
unchangedPolls = 0
handler.post(poll)
}
}
}
private var screenReceiverRegistered = false
private fun registerScreenReceiver(app: Context)
{
if (screenReceiverRegistered)
{
return
}
val filter = IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
}
runCatching { app.registerReceiver(screenReceiver, filter, null, handler) }
.onSuccess { screenReceiverRegistered = true }
}
private fun unregisterScreenReceiver(app: Context)
{
if (!screenReceiverRegistered)
{
return
}
runCatching { app.unregisterReceiver(screenReceiver) }
screenReceiverRegistered = false
}
private val poll = object : Runnable
{
override fun run()
{
val app = appContext ?: return
if (!polling)
{
if (!polling || !isScreenOn(app))
{ /* resumed by the screen-on broadcast */
return
}
val screenOn = (app.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
if (screenOn)
val fg = foregroundApp(app)
if (fg != null && fg != lastForeground)
{
val fg = foregroundApp(app)
if (fg != null && fg != lastForeground)
lastForeground = fg
unchangedPolls = 0
evaluate(app)
}
else
{
unchangedPolls++
}
handler.postDelayed(this, if (unchangedPolls >= SLOW_AFTER_POLLS) SLOW_POLL_MS else POLL_MS)
}
}
/**
* Decides again once the VPN state service exists. A broadcast that starts a
* fresh process arrives before the managers bound the service; evaluating
* then would silently drop the event. Gives up after [SERVICE_WAIT_MS]
* (the next network or alarm event evaluates again) and always calls [done].
*/
@JvmStatic
fun evaluateWhenReady(context: Context, done: Runnable?)
{
val app = context.applicationContext
val deadline = SystemClock.elapsedRealtime() + SERVICE_WAIT_MS
val check = object : Runnable
{
override fun run()
{
if (VpnStateService.getLiveInstance() == null && SystemClock.elapsedRealtime() < deadline)
{
handler.postDelayed(this, SERVICE_POLL_MS)
return
}
try
{
lastForeground = fg
evaluate(app)
}
finally
{
done?.run()
}
}
handler.postDelayed(this, POLL_MS)
}
handler.post(check)
}
/** Network, whitelist or alarm: decide again. */
@@ -377,10 +480,12 @@ object ScenarioEngine
{
return
}
/* without a live service nothing can be put back: keep the record so the
* next evaluation (the receiver waits for the service) still restores */
val service = VpnStateService.getLiveInstance() ?: return
val wasOn = prefs.getBoolean(PREF_WAS_ON, false)
val wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
val service = VpnStateService.getLiveInstance() ?: return
DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)")
if (PauseManager.isPaused(context))
{
@@ -508,12 +613,22 @@ object ScenarioEngine
{
c.add(Calendar.DAY_OF_YEAR, 1)
}
/* same choice as PauseManager: exact when the grant allows it */
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
try
{
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
if (exact)
{
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
}
else
{
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
}
}
catch (_: SecurityException)
{
{ /* revoked in between: an inexact alarm is still better than none */
runCatching { am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) }
}
}
@@ -619,6 +734,7 @@ class ScenarioReceiver : BroadcastReceiver()
override fun onReceive(context: Context, intent: Intent?)
{
ScenarioEngine.start(context)
ScenarioEngine.evaluate(context)
val pending = goAsync()
ScenarioEngine.evaluateWhenReady(context) { pending.finish() }
}
}
@@ -15,6 +15,8 @@
package org.strongswan.android.logic.stats
import android.content.Context
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -37,7 +39,8 @@ data class DayTraffic(val day: String, val vpn: Long, val direct: Long)
/**
* Daily traffic history (F_Stats). While the tunnel is up the daemon
* counters are sampled every [SAMPLE_S] seconds and the growth is added to
* counters are sampled every [SAMPLE_S] seconds (every [SAMPLE_OFF_S] with
* the screen off: the counters are cumulative, so nothing is lost) and the growth is added to
* the current day; the counters restart with every connection, so a drop
* starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json.
*/
@@ -46,12 +49,21 @@ object TrafficHistory
private const val TAG = "TrafficHistory"
private const val FILE_NAME = "traffic.json"
private const val SAMPLE_S = 15L
private const val SAMPLE_OFF_S = 60L
/* the history lives in memory and is written out this often (not on every
* sample: a full JSON rewrite every 15 s is flash wear for nothing); a
* killed process loses at most this much of the statistics */
internal const val SAVE_EVERY_MS = 5 * 60 * 1000L
private const val KEEP_DAYS = 120
private val executor = Executors.newSingleThreadScheduledExecutor()
private var task: ScheduledFuture<*>? = null
private var lastVpn = -1L
private var lastDirect = -1L
private var lastSampleAt = 0L
private var dirty = false
private var lastSavedAt = 0L
private var lastSavedDay: String? = null
@Volatile private var loaded = false
private val days = LinkedHashMap<String, LongArray>()
private var firstDay: String? = null
@@ -86,11 +98,38 @@ object TrafficHistory
val running = task ?: return
running.cancel(false)
task = null
executor.execute { sample(app) }
executor.execute {
sample(app, force = true)
flush(app)
}
}
private fun sample(context: Context)
/** Writes the history out now if it changed since the last write. */
@Synchronized
fun flush(context: Context)
{
if (dirty)
{
save(context)
}
}
/** Whether a changed history has to be written now: periodically and when the day turns. */
internal fun saveDue(dirty: Boolean, now: Long, lastSavedAt: Long, dayChanged: Boolean): Boolean =
dirty && (dayChanged || now - lastSavedAt >= SAVE_EVERY_MS)
private fun screenOn(context: Context): Boolean =
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive != false
private fun sample(context: Context, force: Boolean = false)
{
val now = SystemClock.elapsedRealtime()
val screen = screenOn(context)
if (!force && !screen && now - lastSampleAt < SAMPLE_OFF_S * 1000L - 1000L)
{ /* screen off: a sample a minute is enough, the counters accumulate */
return
}
lastSampleAt = now
val traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull()
val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return
/* direct bytes only exist with the userspace router (s[3]) */
@@ -121,9 +160,16 @@ object TrafficHistory
firstDay = key
}
prune()
save(context)
dirty = true
if (saveDue(dirty, SystemClock.elapsedRealtime(), lastSavedAt, lastSavedDay != null && lastSavedDay != key))
{
save(context)
}
_changes.value = System.currentTimeMillis()
org.strongswan.android.ui.widget.ShxWidgetState.refresh(context)
if (screenOn(context))
{ /* nobody sees the widget with the screen off */
org.strongswan.android.ui.widget.ShxWidgetState.refresh(context)
}
}
/** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */
@@ -205,6 +251,9 @@ object TrafficHistory
file.writeText(json.toString())
tmp.delete()
}
dirty = false
lastSavedAt = SystemClock.elapsedRealtime()
lastSavedDay = dayKey(System.currentTimeMillis())
}
catch (e: Exception)
{
@@ -71,6 +71,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
@@ -193,19 +194,24 @@ private fun ServerScreen(uuid: String, checkOnOpen: Boolean, onBack: () -> Unit)
val extras by remember { ProfileExtras.flow(context) }.collectAsStateWithLifecycle()
val extra = extras[uuid] ?: ProfileExtra(uuid)
/* the live state of the tunnel and a direct ping of the server */
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null) {
while (true)
{
val service = VpnStateService.getLiveInstance()
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
delay(1500)
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null, owner) {
/* only while the page is on screen */
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true)
{
val service = VpnStateService.getLiveInstance()
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
delay(1500)
}
}
}
val rtt by produceState<Long?>(null, profile.gateway) {
while (true)
{
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
delay(30_000)
val rtt by produceState<Long?>(null, profile.gateway, owner) {
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true)
{
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
delay(30_000)
}
}
}
val active = vpn?.second == uuid
@@ -199,7 +199,14 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
{
synchronized(underlyingNetworks)
{
underlyingNetworks[network] = capabilities
val previous = underlyingNetworks.put(network, capabilities)
/* fired on every signal-strength tick: the published state (network
* kinds, SSID) only depends on the transports, and reading the SSID
* is a system call, so unchanged transports end here */
if (previous != null && sameTransports(previous, capabilities))
{
return
}
publishNetworks()
}
}
@@ -214,6 +221,10 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
}
}
private fun sameTransports(a: NetworkCapabilities, b: NetworkCapabilities): Boolean =
a.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == b.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
a.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == b.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
private fun publishNetworks()
{
_networkAvailable.value = underlyingNetworks.isNotEmpty()
@@ -124,10 +124,23 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
var result by remember { mutableStateOf<CheckResult?>(null) }
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
val apps by produceState<List<InstalledApp>>(emptyList(), mode) {
if (mode == RouteCheckMode.APP && value.isEmpty())
var apps by remember { mutableStateOf<List<InstalledApp>>(emptyList()) }
/** The installed apps; loads them on first use, so a check never runs against a list that is still empty. */
suspend fun loadApps(): List<InstalledApp>
{
if (apps.isEmpty())
{
value = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
apps = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
}
return apps
}
LaunchedEffect(mode)
{
if (mode == RouteCheckMode.APP)
{
loadApps()
}
}
@@ -150,7 +163,7 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
{
RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive)
RouteCheckMode.IP -> checkIp(context, manager, input, rulesActive)
RouteCheckMode.APP -> apps.firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
RouteCheckMode.APP -> loadApps().firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
?.let { CheckResult.App(it) } ?: CheckResult.BadInput
}
if (result !is CheckResult.BadInput)
@@ -173,10 +186,6 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
{
if (initialQuery.isNotBlank())
{
if (mode == RouteCheckMode.APP)
{
delay(300)
}
run()
}
}
@@ -40,6 +40,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.delay
import org.strongswan.android.R
import org.strongswan.android.logic.PauseManager
@@ -103,12 +106,16 @@ internal fun HomeProblem.titleRes(): Int = when (this)
internal fun rememberNow(): Long
{
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit)
{
while (true)
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(lifecycle)
{ /* no ticking while the app is in the background */
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
{
now = System.currentTimeMillis()
delay(1000L - now % 1000L)
while (true)
{
now = System.currentTimeMillis()
delay(1000L - now % 1000L)
}
}
}
return now
@@ -17,6 +17,9 @@
*/
#include <errno.h>
#include <poll.h>
#include <stdint.h>
#include <sys/eventfd.h>
#include <unistd.h>
#include "android_jni.h"
@@ -70,6 +73,12 @@ struct private_android_service_t {
*/
int tunfd;
/**
* eventfd that wakes the TUN reader whenever tunfd is replaced or closed
* (-1 if it could not be created, then the reader falls back to a 1 s poll)
*/
int wakefd;
/**
* MTU of TUN device
*/
@@ -136,6 +145,21 @@ static void engine_write_packet(void *ctx, ip_packet_t *packet)
deliver_plain(ctx, packet);
}
/**
* Wake the TUN reader so it picks up a replaced or closed tunfd. Call it
* after this->tunfd was changed (the eventfd counter stays readable until the
* reader drains it, so a wake-up before the reader polls is not lost).
*/
static void wake_reader(private_android_service_t *this)
{
uint64_t one = 1;
if (this->wakefd >= 0 && write(this->wakefd, &one, sizeof(one)) < 0)
{
DBG1(DBG_DMN, "waking the TUN reader failed: %s", strerror(errno));
}
}
/**
* Job handling outbound plaintext packets
*/
@@ -143,16 +167,10 @@ static job_requeue_t handle_plain(private_android_service_t *this)
{
ip_packet_t *packet;
chunk_t raw;
fd_set set;
struct pollfd fds[2];
ssize_t len;
int tunfd;
int tunfd, wakefd, nfds = 1, timeout;
bool old, dns_proxy;
timeval_t tv = {
/* check every second if tunfd is still valid */
.tv_sec = 1,
};
FD_ZERO(&set);
this->lock->read_lock(this->lock);
if (this->tunfd < 0)
@@ -161,28 +179,60 @@ static job_requeue_t handle_plain(private_android_service_t *this)
return JOB_REQUEUE_NONE;
}
tunfd = this->tunfd;
FD_SET(tunfd, &set);
wakefd = this->wakefd;
/* cache this while we have the lock */
dns_proxy = this->use_dns_proxy;
this->lock->unlock(this->lock);
fds[0].fd = tunfd;
fds[0].events = POLLIN;
fds[0].revents = 0;
if (wakefd >= 0)
{ /* a replaced or closed tunfd wakes us, so the timeout is only a
* safety net; without the eventfd re-check every second as before */
fds[1].fd = wakefd;
fds[1].events = POLLIN;
fds[1].revents = 0;
nfds = 2;
timeout = 60000;
}
else
{
timeout = 1000;
}
old = thread_cancelability(TRUE);
len = select(tunfd + 1, &set, NULL, NULL, &tv);
len = poll(fds, nfds, timeout);
thread_cancelability(old);
if (len < 0)
{
if (errno == EBADF)
{ /* the TUN device got closed just before calling select(), retry */
{ /* the TUN device got closed just before calling poll(), retry */
return JOB_REQUEUE_FAIR;
}
DBG1(DBG_DMN, "select on TUN device failed: %s", strerror(errno));
DBG1(DBG_DMN, "poll on TUN device failed: %s", strerror(errno));
return JOB_REQUEUE_NONE;
}
else if (len == 0)
{ /* timeout, check again right away */
return JOB_REQUEUE_DIRECT;
}
if (nfds == 2 && (fds[1].revents & POLLIN))
{ /* tunfd was replaced or closed: drain the counter and start over with
* the current fd (pending TUN data stays, poll is level-triggered) */
uint64_t cnt;
if (read(wakefd, &cnt, sizeof(cnt)) < 0 && errno != EAGAIN)
{
DBG1(DBG_DMN, "reading the wake-up fd failed: %s", strerror(errno));
}
return JOB_REQUEUE_DIRECT;
}
if (fds[0].revents & POLLNVAL)
{ /* the fd was closed while polling, retry with the current one */
return JOB_REQUEUE_FAIR;
}
raw = chunk_alloc(this->mtu);
len = read(tunfd, raw.ptr, raw.len);
@@ -375,6 +425,8 @@ static bool setup_tun_device(private_android_service_t *this,
}
this->tunfd = tunfd;
this->lock->unlock(this->lock);
/* a reader polling the previous fd must switch to the new one */
wake_reader(this);
DBG1(DBG_DMN, "successfully created TUN device");
@@ -422,6 +474,7 @@ static bool setup_tun_device_without_dns(private_android_service_t *this)
}
this->tunfd = tunfd;
this->lock->unlock(this->lock);
wake_reader(this);
/* the fd changed, re-bind the userspace router to the new TUN */
direct_engine_set_tun(this, engine_write_packet, this->mtu);
@@ -446,6 +499,8 @@ static void close_tun_device(private_android_service_t *this)
tunfd = this->tunfd;
this->tunfd = -1;
this->lock->unlock(this->lock);
/* let the reader notice tunfd < 0 and quit instead of polling a dead fd */
wake_reader(this);
/* drop the remembered binding too, so a lazily created engine does not
* attach to the dead TUN, and flush the engine if it exists */
@@ -977,6 +1032,10 @@ METHOD(android_service_t, destroy, void,
close_tun_device(this);
this->dns_proxy->destroy(this->dns_proxy);
this->lock->destroy(this->lock);
if (this->wakefd >= 0)
{
close(this->wakefd);
}
this->settings->destroy(this->settings);
free(this);
}
@@ -1005,6 +1064,7 @@ android_service_t *android_service_create(android_creds_t *creds,
.settings = settings,
.creds = creds,
.tunfd = -1,
.wakefd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC),
.mtu = settings->get_int(settings, "global.mtu", ANDROID_DEFAULT_MTU),
);
/* only allow queries for the VPN gateway */
@@ -797,10 +797,9 @@ static void *engine_thread(private_direct_engine_t *this)
{
active = TRUE;
this->udp->reprotect(this->udp);
if (this->tcp_active)
{
this->tcp->reprotect(this->tcp);
}
/* also while lwIP is dormant: this clears the negative cache of
* failed direct connects, which must not survive a roam */
this->tcp->reprotect(this->tcp);
/* the network changed: whatever was dead for direct connects
* may work again - drop the tunnel fallbacks with the rest of
* the failure state (tcp->reprotect clears the neg table) */
@@ -812,8 +811,22 @@ static void *engine_thread(private_direct_engine_t *this)
this->bind_lock->unlock(this->bind_lock);
if (!bound && this->unbound_flushed)
{ /* TUN still gone and already flushed */
{ /* TUN still gone and already flushed: nothing to do until the
* re-bind wakes us, so back off like an idle round instead of
* polling at the active cadence all through a re-authentication */
drain_queue_nobind(this);
if (active)
{
poll_ms = ENGINE_POLL_MS;
}
else
{
poll_ms *= 2;
if (poll_ms > ENGINE_IDLE_POLL_MS)
{
poll_ms = ENGINE_IDLE_POLL_MS;
}
}
continue;
}
@@ -862,6 +875,12 @@ static void *engine_thread(private_direct_engine_t *this)
{
this->tcp->process_poll(this->tcp, fds + 1 + udp_n, tcp_n);
this->tcp->check_timers(this->tcp);
if (!active && this->tcp->flow_count(this->tcp) == 0)
{ /* a full idle round with no TCP flow left: let lwIP go dormant
* again (the next TCP packet wakes it, its timers catch up),
* so the idle cadence stretches back to ENGINE_IDLE_POLL_MS */
this->tcp_active = FALSE;
}
}
{ /* adapt the cadence after the work of this round is known */
@@ -966,6 +966,12 @@ METHOD(direct_tcp_t, check_timers, void,
enumerator->destroy(enumerator);
}
METHOD(direct_tcp_t, flow_count, size_t,
private_direct_tcp_t *this)
{
return this->relays->get_count(this->relays);
}
METHOD(direct_tcp_t, set_mtu, void,
private_direct_tcp_t *this, uint16_t mtu)
{
@@ -1290,6 +1296,7 @@ direct_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine,
.public = {
.handle_packet = _handle_packet,
.check_timers = _check_timers,
.flow_count = _flow_count,
.set_mtu = _set_mtu,
.reprotect = _reprotect,
.fill_poll = _fill_poll,
@@ -0,0 +1,100 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
class ScheduledJobQueueTest
{
@Test
fun firstJobArmsTheAlarm()
{
val q = ScheduledJobQueue(2000)
assertThat(q.add("a", 10_000)).isTrue()
assertThat(q.armedAt).isEqualTo(10_000)
}
@Test
fun laterJobDoesNotRearm()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
assertThat(q.add("b", 50_000)).isFalse()
assertThat(q.armedAt).isEqualTo(10_000)
}
@Test
fun jobJustBeforeTheArmedAlarmReusesIt()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
assertThat(q.add("b", 8_500)).isFalse()
assertThat(q.armedAt).isEqualTo(10_000)
}
@Test
fun clearlyEarlierJobRearms()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
assertThat(q.add("b", 5_000)).isTrue()
assertThat(q.armedAt).isEqualTo(5_000)
}
@Test
fun popDueRunsJobsInOrderIncludingTheCoalescingWindow()
{
val q = ScheduledJobQueue(2000)
q.add("c", 11_900)
q.add("a", 10_000)
q.add("b", 10_500)
q.add("late", 30_000)
assertThat(q.popDue(10_000)).containsExactly("a", "b", "c")
assertThat(q.nextAlarm()).isEqualTo(30_000)
assertThat(q.armedAt).isEqualTo(30_000)
}
@Test
fun earlyOrSpuriousWakeupKeepsTheQueue()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
assertThat(q.popDue(5_000)).isEmpty()
assertThat(q.nextAlarm()).isEqualTo(10_000)
assertThat(q.armedAt).isEqualTo(10_000)
}
@Test
fun emptyQueueLeavesNothingArmed()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
assertThat(q.popDue(10_000)).containsExactly("a")
assertThat(q.nextAlarm()).isEqualTo(ScheduledJobQueue.NONE)
assertThat(q.armedAt).isEqualTo(ScheduledJobQueue.NONE)
assertThat(q.add("b", 20_000)).isTrue()
}
@Test
fun clearForgetsTheArmedAlarm()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
q.clear()
assertThat(q.size).isEqualTo(0)
assertThat(q.add("b", 40_000)).isTrue()
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.strongswan.android.logic.stats.TrafficHistory
class TrafficHistoryTest
{
private val every = TrafficHistory.SAVE_EVERY_MS
@Test
fun cleanHistoryIsNeverWritten()
{
assertThat(TrafficHistory.saveDue(false, 10 * every, 0, true)).isFalse()
}
@Test
fun changedHistoryWaitsForTheInterval()
{
assertThat(TrafficHistory.saveDue(true, every - 1, 0, false)).isFalse()
assertThat(TrafficHistory.saveDue(true, every, 0, false)).isTrue()
}
@Test
fun dayChangeWritesAtOnce()
{
assertThat(TrafficHistory.saveDue(true, 1000, 900, true)).isTrue()
}
}
@@ -242,4 +242,30 @@ class HealthCheckManagerTest
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 3)).isTrue()
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 0)).isFalse()
}
@Test
fun inboundTrafficSkipsProbesInAdaptiveMode()
{
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 10_000, 60_000)).isTrue()
}
@Test
fun noPassiveSkipWithoutInboundOrAfterCap()
{
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 100, 60_000)).isFalse()
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, -1, 60_000)).isFalse()
assertThat(
HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 10_000, HealthCheckManager.PASSIVE_MAX_PROBE_AGE_MS)
).isFalse()
}
@Test
fun noPassiveSkipWhileFailureIsObservedOrNotAdaptive()
{
assertThat(HealthCheckManager.trafficProvesAlive(config, 1, 0, false, 10_000, 1000)).isFalse()
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 2, false, 10_000, 1000)).isFalse()
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, true, 10_000, 1000)).isFalse()
assertThat(HealthCheckManager.trafficProvesAlive(config.copy(adaptiveMode = false), 0, 0, false, 10_000, 1000)).isFalse()
assertThat(HealthCheckManager.trafficProvesAlive(config.copy(aggressiveMode = true), 0, 0, false, 10_000, 1000)).isFalse()
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# SHX VPN для Android
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.18.0 (versionCode 165)**.
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.19.1 (versionCode 169)**.
Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само.
- Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду.
+182
View File
@@ -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-таймауты и энергопотребление.