Compare 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 (address) (push) Skipped
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, printf-builtin) (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-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, 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
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 (address) (push) Skipped
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, printf-builtin) (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-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, 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
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 (address) (push) Skipped
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, printf-builtin) (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-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, 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
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
20 changed files with 657 additions and 91 deletions

No files matched your search

+2 -2
View File
@@ -45,8 +45,8 @@ android {
minSdk = 24
targetSdk = 36
versionCode 166
versionName "2.18.1"
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
@@ -25,10 +25,12 @@ import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.VpnService
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import android.telephony.TelephonyManager
import android.util.Log
import androidx.preference.PreferenceManager
@@ -162,6 +164,9 @@ object ScenarioEngine
private const val TAG = "Scenarios"
private const val FILE_NAME = "scenarios.json"
private const val POLL_MS = 4000L
/* how long a broadcast waits for the VPN state service of a fresh process */
private const val SERVICE_WAIT_MS = 8000L
private const val SERVICE_POLL_MS = 200L
/* the foreground app did not change for a minute: poll half as often */
private const val SLOW_AFTER_POLLS = 15
private const val SLOW_POLL_MS = 8000L
@@ -193,9 +198,20 @@ object ScenarioEngine
_list.value = list
persist(context)
start(context)
org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).scenariosChanged()
evaluate(context)
}
/** Whether an enabled scenario reacts to Wi-Fi, mobile network or roaming changes. */
@JvmStatic
fun needsNetworkEvents(context: Context): Boolean
{
load(context.applicationContext)
return _list.value.any {
it.enabled && (it.trigger == ScenarioTrigger.WIFI || it.trigger == ScenarioTrigger.MOBILE || it.trigger == ScenarioTrigger.ROAMING)
}
}
/** Starts listening (from the application); idempotent. */
@JvmStatic
fun start(context: Context)
@@ -289,6 +305,39 @@ object ScenarioEngine
}
}
/**
* Decides again once the VPN state service exists. A broadcast that starts a
* fresh process arrives before the managers bound the service; evaluating
* then would silently drop the event. Gives up after [SERVICE_WAIT_MS]
* (the next network or alarm event evaluates again) and always calls [done].
*/
@JvmStatic
fun evaluateWhenReady(context: Context, done: Runnable?)
{
val app = context.applicationContext
val deadline = SystemClock.elapsedRealtime() + SERVICE_WAIT_MS
val check = object : Runnable
{
override fun run()
{
if (VpnStateService.getLiveInstance() == null && SystemClock.elapsedRealtime() < deadline)
{
handler.postDelayed(this, SERVICE_POLL_MS)
return
}
try
{
evaluate(app)
}
finally
{
done?.run()
}
}
}
handler.post(check)
}
/** Network, whitelist or alarm: decide again. */
@JvmStatic
fun evaluate(context: Context)
@@ -431,10 +480,12 @@ object ScenarioEngine
{
return
}
/* without a live service nothing can be put back: keep the record so the
* next evaluation (the receiver waits for the service) still restores */
val service = VpnStateService.getLiveInstance() ?: return
val wasOn = prefs.getBoolean(PREF_WAS_ON, false)
val wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
val service = VpnStateService.getLiveInstance() ?: return
DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)")
if (PauseManager.isPaused(context))
{
@@ -562,12 +613,22 @@ object ScenarioEngine
{
c.add(Calendar.DAY_OF_YEAR, 1)
}
/* same choice as PauseManager: exact when the grant allows it */
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
try
{
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
if (exact)
{
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
}
else
{
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
}
}
catch (_: SecurityException)
{
{ /* revoked in between: an inexact alarm is still better than none */
runCatching { am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) }
}
}
@@ -673,6 +734,7 @@ class ScenarioReceiver : BroadcastReceiver()
override fun onReceive(context: Context, intent: Intent?)
{
ScenarioEngine.start(context)
ScenarioEngine.evaluate(context)
val pending = goAsync()
ScenarioEngine.evaluateWhenReady(context) { pending.finish() }
}
}
@@ -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)
{
@@ -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()
}
}
@@ -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()
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# SHX VPN для Android
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.18.1 (versionCode 166)**.
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.19.1 (versionCode 169)**.
Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само.
- Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду.
+41
View File
@@ -139,3 +139,44 @@
- 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-таймауты и энергопотребление.