Compare commits

20 Commits
Author SHA1 Message Date
DenozordecandClaude Opus 5.5 8f21e16277 android: 2.17.1 - Shield-S launcher, shortcut and status bar icon
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 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, 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 3s
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 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
- adaptive launcher icon as vectors: purple background, white shield with
  the S cut out (even-odd), plus a monochrome layer for Android 13+ themed
  icons; profile shortcuts use the same icon
- vector legacy icon for Android 7.x in mipmap-anydpi, old strongSwan PNGs
  removed
- connected status bar icon is the Shield-S silhouette; connecting,
  warning and disconnected states keep their outlined shields

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:17:24 +07:00
DenozordecandClaude Opus 5.5 12a95d1e89 android: 2.17.0 - stability and battery fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:28:46 +07:00
DenozordecandClaude Opus 5.5 66688031cd android: small cleanups - DB close in finally, single profile reload
- HomeViewModel closes the profile database in finally blocks and relies
  on the VPN_PROFILES_CHANGED broadcast instead of reloading twice
- READ_EXTERNAL_STORAGE only up to API 32; drop a dead settings flag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:20:28 +07:00
DenozordecandClaude Opus 5.5 7abac87140 android: run DNS/route diagnostics on their own executor
They can block for 10-30 s on a degraded network and delayed any profile
load (connect, retry, profile switch) queued behind them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:20:28 +07:00
DenozordecandClaude Opus 5.5 1b4fdc83d8 android: tile/widget/log fixes
- tile: query the configured profile once per listening session instead
  of on every state notification
- widget: DISCONNECTING is shown as such, with a Disconnect button
- log viewer: bounded to 5000 lines, tails only while visible, picks up
  a log created or restarted after opening, and no longer yanks the user
  back to the bottom while they scroll up

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:19:33 +07:00
DenozordecandClaude Opus 5.5 ce80c36be1 android: routing - package scans off the main thread, no needless reconnects
- computeExcludedPackages() (full PackageManager scan) runs on Default,
  not Main.immediate, on config changes and package add/remove
- updateConfig() ignores no-op transforms and reconnects when routing is
  switched off too (direct apps kept bypassing the tunnel until then)
- config is volatile and read after its version, so an old config can no
  longer be cached under a new version
- Direct apps loads the app list once per entry, without GET_META_DATA

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:19:33 +07:00
DenozordecandClaude Opus 5.5 07fb02790c android: CharonVpnService - handover race, cheaper network/notification updates
- underlying-network bookkeeping is guarded by one lock and re-applied
  after establish(), so a handover racing with it no longer leaves the
  tunnel attributed to a lost network
- use the capabilities passed to onCapabilitiesChanged (no IPC per
  signal-strength update); mCurrentProfile is volatile
- countdown-only notification re-posts are limited to one per 5 s
- the connected chronometer keeps its start time across re-posts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:17:36 +07:00
DenozordecandClaude Opus 5.5 b0f353af90 android: whitelist - working recheck backoff, fewer probe rounds
- scheduled rechecks are forced: the 15/30 s steps were below FRESH_MS,
  so the unforced recheck returned early and the backoff chain died
- signal-strength-only capability changes are no longer network events
  (each one reset the backoff and launched a check)
- tunnel state transitions force a new round at most every 15 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:17:36 +07:00
DenozordecandClaude Opus 5.5 514b15b1ca android: home/routing UI - poll only while visible, no idle animation
- traffic (1 s) and probe (10 s) loops run only while the Home screen is
  shown and the VPN is connected; no 1 s idle tick otherwise
- the refresh button no longer runs a second exit-IP trace
- the glow pulse is not composed at all while disconnected or with
  animations off (it redrew a transparent circle every frame)
- RTT sparkline appends each probe result once (it duplicated stale ones)
- Routing dead-host poll is lifecycle-aware

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:15:10 +07:00
DenozordecandClaude Opus 5.5 5f8b813eb4 android: updates - honour the charging constraint, real metered check
- drop the in-process 12 h loop: the VPN keeps the process alive, so it
  checked and pre-downloaded on battery, defeating the worker constraint
- the worker now waits for the check and a silent download it starts
- metered is judged by the physical network: the VPN reports itself
  unmetered, so pre-downloads ran over cellular while connected

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:15:10 +07:00
DenozordecandClaude Opus 5.5 6668dcfb56 android: health check - ping the server only when it can matter
- WITH_TUNNEL mode pings the server only once the external targets fail
  (no ping fork + DNS lookup outside the tunnel on every healthy cycle;
  an ICMP-blocking server no longer forces fast polls)
- initial battery-low state from the sticky ACTION_BATTERY_CHANGED
- non-state notifications no longer restart a pending cycle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:12:22 +07:00
DenozordecandClaude Opus 5.5 1eb1dfe317 android: RU list - no reconnect for an unchanged list, failure backoff
- same SHA-256 as the applied list: only bump the timestamp, no parse,
  save or tunnel reconnect (it tore a healthy tunnel down daily)
- a failed download is not retried on every cold start (3 h backoff)
- the saved list is read and parsed off the main thread at startup
- switching the split off in system mode reconnects to drop the routes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:12:22 +07:00
DenozordecandClaude Opus 5.5 cf89cec20e android: watchdog backs off whenever a live process owns recovery
Retrying, connecting or whitelist-blocked sessions kept the 60 s alarm and
the 10 min Doze wakeup for hours although a live process makes the alarm
useless. They now share the connected backoff (5 min / 60 min).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:12:22 +07:00
DenozordecandClaude Opus 5.5 24e536b9b9 android: updates - no stuck Downloading state, thread-safe singleton
After a silent pre-download the state stayed Downloading, so tapping the
"update ready" notification did nothing. It now returns to Available;
an install request without a known update (e.g. after a process restart)
re-checks and installs when found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:09:40 +07:00
DenozordecandClaude Opus 5.5 e6c893219c android: DiagLog thread-safe timestamps; auto-connect survives stop/start
- SimpleDateFormat is now used under the lock; rotation counts bytes
- AutoConnectManager.stop() clears pendingEvaluate, whose reset runnable
  it removes, so later connects are not silently swallowed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:09:40 +07:00
DenozordecandClaude Opus 5.5 01c398f2f7 android: tile/control activity - no bind leaks, no callbacks after destroy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:09:40 +07:00
DenozordecandClaude Opus 5.5 f2e4ffe860 android: VpnStateService - single retry countdown, isolated listeners
- remove pending RETRY_MSG on reset/set: a Retry tap during a backoff
  started a second countdown chain (double-speed ticks, two reconnects)
- notify listeners over a copy with per-listener error handling
- disconnect() tolerates the VPN service not running

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:09:40 +07:00
DenozordecandClaude Opus 5.5 358dcb94b2 android: CharonVpnService - safer foreground and teardown handling
- DISCONNECT (plain startService) no longer promotes to the foreground,
  and startForeground() refusals on Android 12+ are logged, not fatal
- drop the placeholder notification when nothing was running, so the
  service does not stay in the foreground with a stale Disabled state
- onDestroy: shutdownNow/await the profile executor before closing the
  database; profile loads catch errors instead of killing the process
- unbind by bind result, not by mService (binding leaked if destroyed
  before onServiceConnected)
- the connection thread's crash guard can no longer throw itself

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:09:39 +07:00
DenozordecandClaude Opus 5.5 2b40eebf5b android: close TCP probe sockets right after connecting
tcpPing never closed its socket: every health/whitelist/home probe leaked
an fd until GC and the peer's later FIN/RST woke the radio again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:09:39 +07:00
DenozordecandClaude Opus 5.5 793ca1b410 android: native - hold charon_mutex while reading routing engine stats
getRoutingStats/getDeadDirectTargets are polled from the UI once a second;
a concurrent disconnect frees the engine under charon_mutex, so reading it
without the lock was a use-after-free.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:05:09 +07:00
41 changed files with 983 additions and 258 deletions

No files matched your search

+2 -2
View File
@@ -45,8 +45,8 @@ android {
minSdk = 24
targetSdk = 36
versionCode 162
versionName "2.16.0"
versionCode 164
versionName "2.17.1"
/* Base URL of the update storage, trailing slash included */
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
@@ -39,7 +39,8 @@
<!-- required to claim update ownership (setRequestUpdateOwnership) so our
own updates keep being applied silently on Android 14+ -->
<uses-permission android:name="android.permission.ENFORCE_UPDATE_OWNERSHIP" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- Required to reliably schedule NAT keepalives/rekeyings via the AlarmManager.
@@ -94,6 +94,7 @@ import java.util.SortedSet;
import java.util.TreeSet;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
@@ -156,8 +157,13 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
private VpnProfileDataSource mDataSource;
/* profile lookups are SQLite queries and must not run on the main thread */
private final ExecutorService mProfileExecutor = Executors.newSingleThreadExecutor();
/* DNS/route diagnostics and private DNS lookups can block for many seconds
* on a bad network: keep them off the profile executor, or a connect
* queued behind them is delayed by that much */
private final ExecutorService mDiagExecutor = Executors.newSingleThreadExecutor();
private Thread mConnectionHandler;
private VpnProfile mCurrentProfile;
/* read by the ConnectivityThread (UnderlyingNetworkTracker) too */
private volatile VpnProfile mCurrentProfile;
private volatile String mCurrentCertificateAlias;
private volatile String mCurrentUserCertificateAlias;
private VpnProfile mNextProfile;
@@ -165,10 +171,17 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
private volatile boolean mTerminate;
private volatile boolean mIsDisconnecting;
private volatile boolean mShowNotification;
/* start of the current CONNECTED session for the notification chronometer */
private volatile long mConnectedSince;
/* rate limit for countdown-only notification updates (main thread only) */
private static final long NOTIFICATION_MIN_INTERVAL_MS = 5000;
private String mLastNotifiedKey;
private long mLastNotifiedAt;
private final BuilderAdapter mBuilderAdapter = new BuilderAdapter();
private UnderlyingNetworkTracker mUnderlyingTracker;
private Handler mHandler;
private VpnStateService mService;
private boolean mBound;
private final Object mServiceLock = new Object();
private final ServiceConnection mServiceConnection = new ServiceConnection()
{
@@ -217,8 +230,14 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
* first millisecond, and it avoids the system killing a fresh process
* when the profile fails to load before addNotification() runs. The
* state-specific notification replaces this one (via stateChanged()). */
mShowNotification = true;
startForeground(VPN_STATE_NOTIFICATION_ID, buildNotification(false));
boolean disconnect = intent != null && DISCONNECT_ACTION.equals(intent.getAction());
if (!disconnect)
{ /* DISCONNECT arrives via plain startService(): promoting to the
* foreground from the background is not allowed there (Android 12+)
* and would crash, and there is nothing to keep alive anyway */
mShowNotification = true;
startForegroundSafe();
}
if (intent != null)
{
if (VPN_SERVICE_ACTION.equals(intent.getAction()))
@@ -282,7 +301,16 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
return null;
}
return mDataSource.getVpnProfile(uuid);
try
{
return mDataSource.getVpnProfile(uuid);
}
catch (RuntimeException e)
{ /* e.g. the service is being destroyed and closed the database;
* an uncaught exception on the executor would kill the process */
DiagLog.i(TAG, "loading profile failed: " + e);
return null;
}
}
/**
@@ -391,8 +419,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
/* use a separate thread as main thread for charon */
mConnectionHandler = new Thread(this);
/* the thread is started when the service is bound */
bindService(new Intent(this, VpnStateService.class),
mServiceConnection, Service.BIND_AUTO_CREATE);
mBound = bindService(new Intent(this, VpnStateService.class),
mServiceConnection, Service.BIND_AUTO_CREATE);
createNotificationChannel();
}
@@ -418,12 +446,29 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
e.printStackTrace();
}
if (mService != null)
synchronized (mServiceLock)
{
mService.unregisterListener(this);
if (mService != null)
{
mService.unregisterListener(this);
}
}
if (mBound)
{ /* unbind even if onServiceConnected() never ran, or the binding leaks */
unbindService(mServiceConnection);
mBound = false;
}
mDiagExecutor.shutdownNow();
/* queued profile loads must not run against the closed database */
mProfileExecutor.shutdownNow();
try
{
mProfileExecutor.awaitTermination(2, TimeUnit.SECONDS);
}
catch (InterruptedException e)
{
Thread.currentThread().interrupt();
}
mProfileExecutor.shutdown();
mDataSource.close();
}
@@ -461,6 +506,11 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
if (mNextProfile == null)
{
setState(State.DISABLED);
/* nothing was running (e.g. DISCONNECT while already
* disconnected, or the profile failed to load): drop the
* placeholder notification so the service is not kept
* in the foreground with a stale "Disabled" state */
removeNotification();
if (mTerminate)
{
break;
@@ -537,7 +587,25 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
* and let the regular retry machinery handle it. */
Log.e(TAG, "Exception in the connection thread", ex);
DiagLog.i(TAG, "exception in connection thread: " + ex);
stopCurrentConnection();
try
{
stopCurrentConnection();
}
catch (Exception again)
{ /* the exception may have come from stopCurrentConnection()
* itself (e.g. a broken next profile): drop that profile
* so this guard cannot throw again and kill the thread */
DiagLog.i(TAG, "cleanup after exception failed: " + again);
mNextProfile = null;
if (mCurrentProfile != null)
{
SimpleFetcher.disable();
deinitializeCharon();
sUserspacePushActive = false;
}
removeNotification();
mBuilderAdapter.closeBlocking();
}
setError(ErrorState.GENERIC_ERROR);
setState(State.DISABLED);
mCurrentProfile = null;
@@ -593,11 +661,28 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
public void run()
{
mShowNotification = true;
startForeground(VPN_STATE_NOTIFICATION_ID, buildNotification(false));
startForegroundSafe();
}
});
}
/**
* Promote the service to the foreground. Never throws: on Android 12+ the
* system may refuse the promotion while the app is in the background, which
* must not crash the process.
*/
private void startForegroundSafe()
{
try
{
startForeground(VPN_STATE_NOTIFICATION_ID, buildNotification(false));
}
catch (RuntimeException e)
{
DiagLog.i(TAG, "startForeground refused: " + e);
}
}
/**
* Remove the permanent notification.
*/
@@ -669,6 +754,10 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
.setVisibility(publicVersion ? NotificationCompat.VISIBILITY_PUBLIC
: NotificationCompat.VISIBILITY_PRIVATE);
int s = R.string.state_disabled;
if (state != State.CONNECTED || error != ErrorState.NO_ERROR)
{
mConnectedSince = 0;
}
if (error != ErrorState.NO_ERROR)
{
s = mService.getErrorText();
@@ -715,6 +804,13 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
s = R.string.state_connected;
builder.setColor(ContextCompat.getColor(this, R.color.success_text));
builder.setUsesChronometer(true);
/* without an explicit start every re-post would reset the
* chronometer to 0:00 */
if (mConnectedSince == 0)
{
mConnectedSince = System.currentTimeMillis();
}
builder.setWhen(mConnectedSince);
add_action = true;
break;
case DISCONNECTING:
@@ -758,6 +854,17 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
if (mShowNotification)
{
/* the retry countdown notifies every 1-5 s; each re-post builds two
* notifications and several PendingIntents (IPC). Countdown-only
* updates are rate-limited, real state/error changes post at once */
String key = (mService != null ? mService.getState() + "/" + mService.getErrorState() : "");
long now = SystemClock.elapsedRealtime();
if (key.equals(mLastNotifiedKey) && now - mLastNotifiedAt < NOTIFICATION_MIN_INTERVAL_MS)
{
return;
}
mLastNotifiedKey = key;
mLastNotifiedAt = now;
NotificationManager manager = (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE);
manager.notify(VPN_STATE_NOTIFICATION_ID, buildNotification(false));
}
@@ -873,7 +980,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
scheduleRouteDiagnostics();
/* feed the userspace router for this connection (also picks
* up private DNS changes since the last push) */
mProfileExecutor.execute(() -> {
mDiagExecutor.execute(() -> {
getPrivateDnsServers();
pushUserspaceRoutingData(this);
});
@@ -915,7 +1022,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
return;
}
mProfileExecutor.execute(() -> {
mDiagExecutor.execute(() -> {
try
{
logDnsDiagnostics();
@@ -1165,7 +1272,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
return;
}
mProfileExecutor.execute(() -> {
mDiagExecutor.execute(() -> {
try
{
logRouteDiagnostics();
@@ -1591,7 +1698,9 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
private ConnectivityManager mConnectivityManager;
private boolean mRegistered;
/* the set of networks last reported to the system, to skip redundant
* setUnderlyingNetworks() calls (the callbacks can be chatty) */
* setUnderlyingNetworks() calls (the callbacks can be chatty).
* Guarded by mNetworks: written by the ConnectivityThread and by
* charon's thread around establish() */
private Network[] mApplied;
private final ConnectivityManager.NetworkCallback mCallback = new ConnectivityManager.NetworkCallback()
@@ -1599,19 +1708,20 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
@Override
public void onAvailable(Network network)
{
update(network);
update(network, mConnectivityManager.getNetworkCapabilities(network));
}
@Override
public void onCapabilitiesChanged(Network network, NetworkCapabilities networkCapabilities)
{
update(network);
{ /* use the capabilities passed in: querying them again is an IPC
* on every signal-strength update */
update(network, networkCapabilities);
}
@Override
public void onLost(Network network)
{
update(network);
update(network, null);
}
};
@@ -1661,8 +1771,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
synchronized (mNetworks)
{
mNetworks.clear();
mApplied = null;
}
mApplied = null;
}
private boolean isUsableUnderlying(NetworkCapabilities capabilities)
@@ -1673,48 +1783,49 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN);
}
private void update(Network network)
private void update(Network network, NetworkCapabilities capabilities)
{
Network[] current;
synchronized (mNetworks)
{
mNetworks.remove(network);
if (isUsableUnderlying(mConnectivityManager.getNetworkCapabilities(network)))
if (isUsableUnderlying(capabilities))
{
mNetworks.add(network);
}
current = mNetworks.toArray(new Network[0]);
}
if (!sameNetworks(current, mApplied))
{
if (sameNetworks(current, mApplied))
{
return;
}
mApplied = current;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
{
StringBuilder handles = new StringBuilder("underlying networks changed:");
for (Network underlying : current)
{
StringBuilder handles = new StringBuilder("underlying networks changed:");
for (Network underlying : current)
{
handles.append(" #").append(underlying.getNetworkHandle());
}
DiagLog.i(TAG, handles.toString());
handles.append(" #").append(underlying.getNetworkHandle());
}
if (mCurrentProfile != null &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1)
{ /* reflect roaming in the system's VPN routing right away
* instead of waiting for the next establish(). Passing
* null falls back to the system default, matching the
* establish-time behavior; the call is a no-op for the
* system if no tunnel is currently up. */
setUnderlyingNetworks(current.length > 0 ? current : null);
}
/* let the state service react to the change (e.g. retry
* immediately instead of waiting out a backoff that was sized
* for the previous network) */
synchronized (mServiceLock)
DiagLog.i(TAG, handles.toString());
}
if (mCurrentProfile != null &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1)
{ /* reflect roaming in the system's VPN routing right away
* instead of waiting for the next establish(). Passing
* null falls back to the system default, matching the
* establish-time behavior; the call is a no-op for the
* system if no tunnel is currently up. */
setUnderlyingNetworks(current.length > 0 ? current : null);
}
/* let the state service react to the change (e.g. retry
* immediately instead of waiting out a backoff that was sized
* for the previous network) */
synchronized (mServiceLock)
{
if (mService != null)
{
if (mService != null)
{
mService.onUnderlyingNetworksChanged();
}
mService.onUnderlyingNetworksChanged();
}
}
}
@@ -1764,24 +1875,61 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
return mNetworks.isEmpty() ? null : mNetworks.toArray(new Network[0]);
}
}
/**
* Snapshot for a builder that is about to establish(), remembered as
* applied so the tracker doesn't immediately repeat it.
*/
Network[] snapshotForEstablish()
{
synchronized (mNetworks)
{
Network[] current = mNetworks.isEmpty() ? null : mNetworks.toArray(new Network[0]);
mApplied = current;
return current;
}
}
/**
* After establish(): a network change that raced with it was applied
* via setUnderlyingNetworks() before the tunnel existed (a no-op), so
* the new tunnel may carry a stale set - re-apply the current one.
*/
void afterEstablish(Network[] used)
{
Network[] current;
synchronized (mNetworks)
{
current = mNetworks.toArray(new Network[0]);
if (sameNetworks(current, used))
{
return;
}
mApplied = current;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1)
{
DiagLog.i(TAG, "underlying networks changed during establish, re-applying");
setUnderlyingNetworks(current.length > 0 ? current : null);
}
}
}
/**
* Report the physical networks below the tunnel to the builder (API 22+).
*/
private void applyUnderlyingNetworks(VpnService.Builder builder)
private Network[] applyUnderlyingNetworks(VpnService.Builder builder)
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1)
{
Network[] networks = mUnderlyingTracker.getNetworks();
Network[] networks = mUnderlyingTracker.snapshotForEstablish();
if (networks != null)
{
builder.setUnderlyingNetworks(networks);
}
/* remember what was reported so the tracker doesn't immediately
* repeat the call via setUnderlyingNetworks() */
mUnderlyingTracker.mApplied = networks;
return networks;
}
return null;
}
public class BuilderAdapter
@@ -1920,10 +2068,11 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
try
{
mCache.applyData(mBuilder);
applyUnderlyingNetworks(mBuilder);
Network[] underlying = applyUnderlyingNetworks(mBuilder);
fd = mBuilder.establish();
if (fd != null)
{
mUnderlyingTracker.afterEstablish(underlying);
closeBlocking();
}
}
@@ -1980,8 +2129,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
Builder builder = createBuilder(mProfile.getName());
mEstablishedCache.applyData(builder, false);
applyUnderlyingNetworks(builder);
Network[] underlying = applyUnderlyingNetworks(builder);
fd = builder.establish();
if (fd != null)
{
mUnderlyingTracker.afterEstablish(underlying);
}
}
catch (Exception ex)
{
@@ -62,9 +62,10 @@ object DiagLog
val base = dir ?: return
try
{
val line = "${format.format(Date())} $tag: $message\n"
synchronized(this)
{
/* SimpleDateFormat is not thread-safe: format under the lock */
val line = "${format.format(Date())} $tag: $message\n"
if (written > MAX_SIZE)
{
rotate(base)
@@ -79,7 +80,7 @@ object DiagLog
}
w.write(line)
w.flush()
written += line.length + 1
written += line.toByteArray(Charsets.UTF_8).size
}
}
catch (_: Exception)
@@ -41,6 +41,7 @@ import org.strongswan.android.ui.VpnProfileControlActivity;
import java.lang.ref.WeakReference;
import java.util.Collections;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
@@ -199,6 +200,27 @@ public class VpnStateService extends Service
mListeners.remove(listener);
}
/**
* Notify all registered listeners about a state change. Iterates over a
* copy (a listener may unregister itself from its callback) and isolates
* the listeners from each other: one failing listener must neither skip
* the others nor crash the main thread.
*/
private void notifyStateListeners()
{
for (VpnStateListener listener : new ArrayList<>(mListeners))
{
try
{
listener.stateChanged();
}
catch (RuntimeException e)
{
DiagLog.i(TAG, "state listener failed: " + e);
}
}
}
/**
* Get the current VPN profile.
*
@@ -373,7 +395,15 @@ public class VpnStateService extends Service
Context context = getApplicationContext();
Intent intent = new Intent(context, CharonVpnService.class);
intent.setAction(CharonVpnService.DISCONNECT_ACTION);
context.startService(intent);
try
{
context.startService(intent);
}
catch (IllegalStateException e)
{ /* background start of a service that is not running (Android 8+):
* without the service there is no tunnel to tear down */
DiagLog.i(TAG, "disconnect: VPN service not running (" + e.getMessage() + ")");
}
}
/**
@@ -589,10 +619,7 @@ public class VpnStateService extends Service
{
if (change.call())
{ /* otherwise there is no need to notify the listeners */
for (VpnStateListener listener : mListeners)
{
listener.stateChanged();
}
notifyStateListeners();
/* the home screen widget mirrors the state */
VpnProfile widgetProfile = mProfile;
ShxWidgetState.publish(VpnStateService.this, mState, getErrorState(),
@@ -602,7 +629,7 @@ public class VpnStateService extends Service
}
catch (Exception e)
{
e.printStackTrace();
DiagLog.i(TAG, "state change failed: " + e);
}
}
});
@@ -766,6 +793,9 @@ public class VpnStateService extends Service
mRetryTimeout = mRetryIn = mTimeoutProvider.getTimeout(error);
mRetryFrozen = false;
DiagLog.i(TAG, "error " + error + ", retry in " + (mRetryTimeout / 1000) + "s");
/* never run two countdown chains in parallel (double-speed countdown,
* double notification updates and two reconnects at zero) */
mHandler.removeMessages(RETRY_MSG);
if (mRetryTimeout <= 0)
{
return;
@@ -778,6 +808,7 @@ public class VpnStateService extends Service
*/
private void resetRetryTimer()
{
mHandler.removeMessages(RETRY_MSG);
mRetryTimeout = 0;
mRetryIn = 0;
mRetryFrozen = false;
@@ -858,10 +889,7 @@ public class VpnStateService extends Service
/* calculate next interval before notifying listeners */
long next = SystemClock.uptimeMillis() + retryStep(service.mRetryIn);
for (VpnStateListener listener : service.mListeners)
{
listener.stateChanged();
}
service.notifyStateListeners();
sendMessageAtTime(obtainMessage(RETRY_MSG), next);
}
else
@@ -872,10 +900,7 @@ public class VpnStateService extends Service
* ticking every second */
service.mRetryFrozen = true;
service.mRetryIn = RETRY_SUPPRESS_MS;
for (VpnStateListener listener : service.mListeners)
{
listener.stateChanged();
}
service.notifyStateListeners();
sendMessageAtTime(obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + RETRY_SUPPRESS_MS);
return;
}
@@ -150,6 +150,19 @@ public class WatchdogReceiver extends BroadcastReceiver
return Action.RESTART;
}
/**
* Whether a check counts towards the steady-state backoff of a live
* process: connected, retrying/connecting, or held back by the ISP
* whitelist. In all of these the in-process machinery owns recovery and
* the alarm can only guard against the process dying - firing every
* minute (and waking Doze every 10 minutes) for hours of failed retries
* or whitelist blocking only burns battery.
*/
static boolean isAliveSteady(Action action, boolean processAlive, boolean parked)
{
return action == Action.NONE && processAlive && !parked;
}
@Override
public void onReceive(Context context, Intent intent)
{
@@ -164,27 +177,24 @@ public class WatchdogReceiver extends BroadcastReceiver
return;
}
reschedule(context);
if (WhiteListManager.getInstance(context).shouldBlockReconnect())
{ /* the ISP whitelist blocks the server; the in-process retry
* machinery parks itself in that case, so should we */
DiagLog.i(TAG, "whitelist blocks the server, not restarting");
return;
}
/* the ISP whitelist blocks the server; the in-process retry
* machinery parks itself in that case, so should we */
boolean whitelistBlocks = WhiteListManager.getInstance(context).shouldBlockReconnect();
VpnStateService service = VpnStateService.getLiveInstance();
boolean processAlive = service != null;
boolean serviceTracking = processAlive && service.getState() != VpnStateService.State.DISABLED;
boolean wifiPresent = isWifiPresent(context);
boolean autoProfileConfigured = AutoConnectManager.getInstance(context).getCurrentConfig().getProfileUuid() != null;
Action action = decideAction(enabled, uuid != null, autoExpected, false, serviceTracking,
Action action = decideAction(enabled, uuid != null, autoExpected, whitelistBlocks, serviceTracking,
processAlive, wifiPresent, autoProfileConfigured);
boolean connected = serviceTracking && service.getState() == VpnStateService.State.CONNECTED;
boolean parked = uuid == null && autoExpected && processAlive && !serviceTracking && action == Action.NONE;
boolean alive = isAliveSteady(action, processAlive, parked);
if (parked)
{
sParkedChecks++;
sConnectedChecks = 0;
}
else if (connected && action == Action.NONE)
else if (alive)
{
sConnectedChecks++;
sParkedChecks = 0;
@@ -195,8 +205,8 @@ public class WatchdogReceiver extends BroadcastReceiver
sConnectedChecks = 0;
}
boolean parkedSteady = parked && sParkedChecks >= PARKED_BACKOFF_AFTER;
boolean connectedSteady = connected && sConnectedChecks >= CONNECTED_BACKOFF_AFTER;
String cadence = parkedSteady ? "parked" : connectedSteady ? "connected" : null;
boolean connectedSteady = alive && sConnectedChecks >= CONNECTED_BACKOFF_AFTER;
String cadence = parkedSteady ? "parked" : connectedSteady ? "alive" : null;
if (cadence != null)
{ /* steady state: log the transition once, then stay silent - a
* per-fire line only floods diag.log and burns flash I/O */
@@ -210,7 +220,7 @@ public class WatchdogReceiver extends BroadcastReceiver
{
DiagLog.i(TAG, "check: uuid=" + (uuid != null) + " autoExpected=" + autoExpected +
" processAlive=" + processAlive + " tracking=" + serviceTracking +
" wifi=" + wifiPresent + " -> " + action);
" whitelist=" + whitelistBlocks + " wifi=" + wifiPresent + " -> " + action);
}
sLastCadence = cadence;
switch (action)
@@ -145,6 +145,9 @@ class AutoConnectManager private constructor(
return
}
handler.removeCallbacksAndMessages(null)
/* the runnable that clears this flag was just removed with the
* callbacks: clear it here or every later connect is swallowed */
pendingEvaluate.set(false)
service?.unregisterListener(this)
try
{
@@ -94,8 +94,12 @@ object ConnectivityProbe
* throw (e.g. EPERM when the caller is disallowed from that
* network), so it must stay inside the try */
val socket = if (network != null) network.socketFactory.createSocket() else Socket()
socket.tcpNoDelay = true
socket.connect(InetSocketAddress(host, port), timeoutMs)
/* close right away: a leaked socket holds an fd until GC and
* the peer's later FIN/RST wakes the radio a second time */
socket.use {
it.tcpNoDelay = true
it.connect(InetSocketAddress(host, port), timeoutMs)
}
ProbeResult(target = target, ok = true, rttMs = (System.nanoTime() - started) / 1_000_000)
}
catch (_: Exception)
@@ -25,6 +25,8 @@ import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.BatteryManager
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
@@ -116,6 +118,9 @@ class HealthCheckManager private constructor(
private val cycleMutex = Mutex()
private var config = HealthConfig()
private var running = false
/* last state seen by onStateChanged(): VpnStateService also notifies on
* error/IMC/rebind changes, which must not restart a pending cycle */
private var lastState: VpnStateService.State? = null
private var service: VpnStateService? = null
private var cycleJob: Job? = null
private var confirmJob: Job? = null
@@ -230,6 +235,7 @@ class HealthCheckManager private constructor(
{
}
service = null
lastState = null
resetCounters()
}
@@ -239,6 +245,8 @@ class HealthCheckManager private constructor(
if (config.enabled)
{
start()
/* re-arm with the new settings even if the state is unchanged */
lastState = null
onStateChanged()
}
else
@@ -265,6 +273,8 @@ class HealthCheckManager private constructor(
if (updated.enabled)
{
start()
/* re-arm with the new settings even if the state is unchanged */
lastState = null
onStateChanged()
}
else
@@ -284,12 +294,17 @@ class HealthCheckManager private constructor(
{
return
}
when (service?.state)
val state = service?.state
val previous = lastState
lastState = state
when (state)
{
VpnStateService.State.CONNECTED ->
{
/* let the fresh tunnel settle before the first probe */
scheduleCycle(INITIAL_DELAY_MS)
if (previous != VpnStateService.State.CONNECTED)
{ /* let the fresh tunnel settle before the first probe */
scheduleCycle(INITIAL_DELAY_MS)
}
}
VpnStateService.State.DISABLED ->
{
@@ -345,29 +360,27 @@ class HealthCheckManager private constructor(
}
val gateway = svc.profile?.gateway
val targets = config.targets
val checkServer = config.serverCheckMode != ServerCheckMode.OFF
val mode = config.serverCheckMode
val timeout = config.timeoutMs
val results = coroutineScope {
val serverDeferred = if (checkServer)
{
async { ConnectivityProbe.probe(ProbeTarget.SERVER, timeout, context, gateway) }
}
else
{
null
}
val external = targets.map { target ->
val externalResults = coroutineScope {
targets.map { target ->
async { ConnectivityProbe.probe(target, timeout, context, gateway) }
}
(listOfNotNull(serverDeferred) + external).map { it.await() }
}.map { it.await() }
}
val serverResult = results.firstOrNull { it.target == ProbeTarget.SERVER }
val externalResults = results.filter { it.target != ProbeTarget.SERVER }
val externalOk = externalResults.count { it.ok }
val externalTotal = externalResults.size
val serverDown = serverResult?.let { !it.ok }
/* the direct server ping (a fork of /system/bin/ping plus a DNS lookup
* outside the tunnel) only runs when its result can matter: always in
* ALWAYS mode, in WITH_TUNNEL mode only once the tunnel looks dead */
val serverDown = if (shouldProbeServer(mode, externalOk, externalTotal))
{
!ConnectivityProbe.probe(ProbeTarget.SERVER, timeout, context, gateway).ok
}
else
{
null
}
val decision = decide(config, serverDown, externalOk, externalTotal, serverAttempts, tunnelAttempts)
val healthy = serverDown != true && (externalTotal == 0 || externalOk == externalTotal)
@@ -523,9 +536,10 @@ class HealthCheckManager private constructor(
powerState = PowerState(
screenOn = pm?.isInteractive ?: true,
powerSaveMode = pm?.isPowerSaveMode ?: false,
batteryLow = false,
batteryLow = isBatteryLowNow(),
)
/* BATTERY_LOW/OKAY are sticky, so registration immediately reflects the current state */
/* BATTERY_LOW/OKAY are not sticky: the initial state comes from the
* sticky ACTION_BATTERY_CHANGED, later changes from the broadcasts */
val filter = IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
@@ -543,6 +557,29 @@ class HealthCheckManager private constructor(
}
}
private fun isBatteryLowNow(): Boolean
{
return try
{
val sticky = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
?: return false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
{
sticky.getBooleanExtra(BatteryManager.EXTRA_BATTERY_LOW, false)
}
else
{ /* the platform's default low-battery threshold */
val level = sticky.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = sticky.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
level >= 0 && scale > 0 && level * 100 / scale <= 15
}
}
catch (_: Exception)
{
false
}
}
private fun unregisterPowerReceiver()
{
try
@@ -710,6 +747,16 @@ class HealthCheckManager private constructor(
* @param serverAttempts reconnect decisions already taken for the server rule
* @param tunnelAttempts reconnect decisions already taken for the tunnel rules
*/
/** Whether the server probe can influence [decide] this cycle. */
@JvmStatic
fun shouldProbeServer(mode: ServerCheckMode, externalOk: Int, externalTotal: Int): Boolean =
when (mode)
{
ServerCheckMode.OFF -> false
ServerCheckMode.ALWAYS -> true
ServerCheckMode.WITH_TUNNEL -> externalTotal > 0 && externalOk == 0
}
fun decide(
config: HealthConfig,
serverDown: Boolean?,
@@ -44,6 +44,7 @@ import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.strongswan.android.logic.VpnStateService
import java.util.concurrent.ConcurrentHashMap
/** Whether the ISP currently blocks direct access to foreign hosts. */
enum class WhiteListStatus
@@ -177,6 +178,9 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
private val _state = MutableStateFlow(WhiteListState())
val state: StateFlow<WhiteListState> = _state
/* last classification-relevant capabilities per network (see capabilityKey) */
private val lastCapabilities = ConcurrentHashMap<Network, String>()
/* event trigger: the underlying network changed, the ISP may classify differently now */
private val networkCallback = object : ConnectivityManager.NetworkCallback()
{
@@ -186,12 +190,19 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
}
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities)
{
onNetworkEvent()
{ /* fired on every signal-strength change too: only a change that
* can alter the ISP's classification counts as an event (it also
* resets the recheck backoff) */
val key = capabilityKey(capabilities)
if (lastCapabilities.put(network, key) != key)
{
onNetworkEvent()
}
}
override fun onLost(network: Network)
{
lastCapabilities.remove(network)
onNetworkEvent()
}
}
@@ -238,6 +249,7 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
recheckJob?.cancel()
recheckJob = null
unregisterNetworkCallback()
lastCapabilities.clear()
service?.unregisterListener(this)
try
{
@@ -364,7 +376,10 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
return
}
backoffAttempt = 0
scope.launch { runCheck(force = true) }
/* a flapping tunnel (CONNECTING -> DISCONNECTING -> DISABLED -> ...)
* must not trigger a fresh direct probe round on every transition */
val force = SystemClock.elapsedRealtime() - lastCheckElapsed >= TRANSITION_MIN_GAP_MS
scope.launch { runCheck(force = force) }
}
private fun onNetworkEvent()
@@ -453,7 +468,9 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
recheckJob?.cancel()
recheckJob = scope.launch {
delay(delayMs)
runCheck(force = false)
/* forced: the first backoff steps are shorter than FRESH_MS, an
* unforced recheck returned early and ended the chain */
runCheck(force = true)
}
}
@@ -598,6 +615,22 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
/** Cached results younger than this are not re-probed. */
private const val FRESH_MS = 60_000L
/** State transitions force a new round only if the last one is at least this old. */
private const val TRANSITION_MIN_GAP_MS = 15_000L
/** The parts of the capabilities that matter for the classification. */
@JvmStatic
internal fun capabilityKey(caps: NetworkCapabilities): String
{
val transports = intArrayOf(NetworkCapabilities.TRANSPORT_WIFI, NetworkCapabilities.TRANSPORT_CELLULAR,
NetworkCapabilities.TRANSPORT_ETHERNET, NetworkCapabilities.TRANSPORT_VPN)
.filter { caps.hasTransport(it) }
val capabilities = intArrayOf(NetworkCapabilities.NET_CAPABILITY_INTERNET,
NetworkCapabilities.NET_CAPABILITY_VALIDATED, NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
.filter { caps.hasCapability(it) }
return "$transports/$capabilities"
}
/**
* A cached ACTIVE status stops suppressing reconnects once no recheck
* has confirmed it for this long (fail open). Kept above the backoff
@@ -116,6 +116,8 @@ class RoutingManager private constructor(private val context: Context)
private val handler = Handler(Looper.getMainLooper())
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
/* read from the charon and IO threads in computeExcludedPackages() */
@Volatile
private var config = RoutingConfig()
private var service: VpnStateService? = null
private var migratedOnlyProfiles = 0
@@ -158,7 +160,8 @@ class RoutingManager private constructor(private val context: Context)
override fun onReceive(context: Context?, intent: Intent?)
{
invalidateCache()
scope.launch { computeExcludedPackages() }
/* a full PackageManager scan: never on the main thread */
scope.launch(Dispatchers.Default) { computeExcludedPackages() }
}
}
@@ -183,13 +186,23 @@ class RoutingManager private constructor(private val context: Context)
fun updateConfig(transform: (RoutingConfig) -> RoutingConfig)
{
config = transform(config)
saveConfig(config)
val previous = config
val updated = transform(previous)
if (updated == previous)
{ /* e.g. a chip tapped that is already in that state: no reconnect */
return
}
config = updated
saveConfig(updated)
invalidateCache()
publish()
if (config.enabled)
{ /* warm the package cache so the connect path stays fast */
scope.launch { computeExcludedPackages() }
if (updated.enabled)
{ /* warm the package cache (off the main thread) so the connect path stays fast */
scope.launch(Dispatchers.Default) { computeExcludedPackages() }
}
if (updated.enabled || previous.enabled)
{ /* also when routing was just switched off: the excluded apps keep
* bypassing the tunnel until it is re-established */
scheduleReconnect()
}
}
@@ -384,12 +397,15 @@ class RoutingManager private constructor(private val context: Context)
*/
fun computeExcludedPackages(): Set<String>
{
/* version before config: updateConfig() writes config first, so this
* order can at worst cache a newer config under an older version
* (recomputed next time), never an old config under the new one */
val version = configVersion
val current = config
if (!current.enabled)
{
return emptySet()
}
val version = configVersion
packageCache?.let { (cachedVersion, packages) ->
if (cachedVersion == version)
{
@@ -624,7 +640,7 @@ class RoutingManager private constructor(private val context: Context)
/** All installed apps that hold the INTERNET permission. */
fun loadInternetPackages(pm: PackageManager): Set<String> = try
{
pm.getInstalledApplications(PackageManager.GET_META_DATA)
pm.getInstalledApplications(0)
.asSequence()
.filter { pm.checkPermission(android.Manifest.permission.INTERNET, it.packageName) == PackageManager.PERMISSION_GRANTED }
.map { it.packageName }
@@ -155,8 +155,10 @@ class RuIpListManager private constructor(private val context: Context)
* classification set is hot-swapped in native code instead */
CharonVpnService.pushUserspaceRoutingData(context)
}
else if (enabled)
{
else if (enabled || excludedV4.isNotEmpty() || excludedV6.isNotEmpty())
{ /* routes only change when the TUN is re-established - also when
* the split is switched OFF, or the RU ranges keep bypassing the
* tunnel until the next manual reconnect */
scheduleReconnect()
}
}
@@ -183,7 +185,17 @@ class RuIpListManager private constructor(private val context: Context)
return try
{
val (text, sha256) = withContext(Dispatchers.IO) { fetchList() }
val parsed = withContext(Dispatchers.IO) { parse(text) }
if (sha256 == prefs.getString(PREF_SHA256, null) &&
(excludedV4.isNotEmpty() || excludedV6.isNotEmpty()))
{ /* identical list: nothing to parse, save or re-apply - above
* all no reconnect that tears down a healthy tunnel daily */
val now = System.currentTimeMillis()
prefs.edit().putLong(PREF_LAST_UPDATE, now).remove(PREF_LAST_FAIL).apply()
publish(status = RuIpListStatus.UP_TO_DATE, lastUpdatedMs = now)
Log.i(TAG, "RU geoip list unchanged")
return true
}
val parsed = withContext(Dispatchers.Default) { parse(text) }
if (parsed.v4.isEmpty() && parsed.v6.isEmpty())
{
throw IOException("downloaded list contains no CIDRs")
@@ -195,6 +207,7 @@ class RuIpListManager private constructor(private val context: Context)
prefs.edit()
.putLong(PREF_LAST_UPDATE, now)
.putString(PREF_SHA256, sha256)
.remove(PREF_LAST_FAIL)
.apply()
publish(
status = RuIpListStatus.UP_TO_DATE,
@@ -213,6 +226,8 @@ class RuIpListManager private constructor(private val context: Context)
catch (e: Exception)
{
Log.w(TAG, "RU geoip list update failed", e)
/* remembered so a blocked source is not retried on every cold start */
prefs.edit().putLong(PREF_LAST_FAIL, System.currentTimeMillis()).apply()
publish(status = RuIpListStatus.ERROR)
false
}
@@ -224,14 +239,16 @@ class RuIpListManager private constructor(private val context: Context)
/* ------------------------------ internals ------------------------------ */
private fun loadFromDisk()
private suspend fun loadFromDisk()
{
val file = listFile()
if (file.isFile)
{
try
{
val parsed = parse(file.readText())
/* thousands of CIDRs: read and parse off the main thread (this
* runs at every process start) */
val parsed = withContext(Dispatchers.Default) { parse(file.readText()) }
excludedV4 = parsed.v4
excludedV6 = parsed.v6
publish(
@@ -257,8 +274,8 @@ class RuIpListManager private constructor(private val context: Context)
{
return
}
val last = prefs.getLong(PREF_LAST_UPDATE, 0L)
if (System.currentTimeMillis() - last >= Constants.RUSPLIT_CHECK_INTERVAL_MS)
if (isAutoCheckDue(System.currentTimeMillis(), prefs.getLong(PREF_LAST_UPDATE, 0L),
prefs.getLong(PREF_LAST_FAIL, 0L)))
{
scope.launch { updateNow() }
}
@@ -403,6 +420,21 @@ class RuIpListManager private constructor(private val context: Context)
private const val FILE_NAME = "ru.list"
private const val PREF_LAST_UPDATE = "rusplit_last_update"
private const val PREF_SHA256 = "rusplit_sha256"
private const val PREF_LAST_FAIL = "rusplit_last_fail"
/* after a failed download wait this long before the next automatic try */
internal const val FAIL_RETRY_MS = 3 * 60 * 60 * 1000L
/** Whether the automatic refresh is due (pure, unit-testable). */
@JvmStatic
internal fun isAutoCheckDue(now: Long, lastUpdate: Long, lastFail: Long): Boolean
{
if (now - lastUpdate < Constants.RUSPLIT_CHECK_INTERVAL_MS)
{
return false
}
/* clock moved backwards: don't let a "future" failure block forever */
return lastFail <= 0L || lastFail > now || now - lastFail >= FAIL_RETRY_MS
}
private const val RECONNECT_DEBOUNCE_MS = 2000L
private const val CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 30_000
@@ -29,7 +29,12 @@ class UpdateCheckWorker(context: Context, params: WorkerParameters) : CoroutineW
{
override suspend fun doWork(): Result
{
UpdateManager.getInstance(applicationContext).checkIfDue()
/* wait for the check (and a silent pre-download it starts): returning
* early lets the system reclaim the process mid-request, outside the
* worker's charging/network constraints */
val manager = UpdateManager.getInstance(applicationContext)
manager.checkIfDue()?.join()
manager.awaitDownload()
return Result.success()
}
@@ -26,6 +26,7 @@ import android.content.SharedPreferences
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.os.IBinder
import android.util.Log
@@ -41,7 +42,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -51,6 +51,7 @@ import org.strongswan.android.R
import org.strongswan.android.logic.DiagLog
import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.connectivity.UnderlyingNetwork
import org.strongswan.android.ui.MainActivity
import org.strongswan.android.utils.Constants
import java.io.File
@@ -116,12 +117,18 @@ class UpdateManager private constructor(private val context: Context)
private const val UPDATES_NOTIFICATION_ID = 1001
private const val UPDATES_PROGRESS_NOTIFICATION_ID = 1002
@Volatile
private var mInstance: UpdateManager? = null
/* called from the main thread and the WorkManager thread: a second
* instance would run its own check loop and state */
@JvmStatic
fun getInstance(context: Context): UpdateManager
{
return mInstance ?: UpdateManager(context.applicationContext).also { mInstance = it }
return mInstance ?: synchronized(this)
{
mInstance ?: UpdateManager(context.applicationContext).also { mInstance = it }
}
}
/**
@@ -154,6 +161,10 @@ class UpdateManager private constructor(private val context: Context)
private var mCheckJob: Job? = null
private var mDownloadJob: Job? = null
/* the user asked to install (e.g. tapped "update ready") while no update
* was known in this process: install as soon as the check finds it */
@Volatile
private var mInstallPending = false
private var mVpnService: VpnStateService? = null
private val mConnection = object : ServiceConnection
@@ -188,13 +199,16 @@ class UpdateManager private constructor(private val context: Context)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
UpdateCheckWorker.WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
mScope.launch {
while (true)
{
checkIfDue()
delay(Constants.UPDATE_CHECK_INTERVAL_MS)
}
}
/* no in-process timer: the VPN service keeps the process alive, so a
* loop here would check (and pre-download) on battery every 12 h and
* defeat the worker's charging constraint. The worker and the check
* on app resume cover it. */
}
/** Waits for a running silent download (used by the worker). */
suspend fun awaitDownload()
{
mDownloadJob?.join()
}
/**
@@ -202,17 +216,18 @@ class UpdateManager private constructor(private val context: Context)
* last-check timestamp). Called by the periodic worker, the in-process
* loop and on app resume.
*/
fun checkIfDue()
fun checkIfDue(): Job?
{
if (!isAutoCheckEnabled() || mCheckJob?.isActive == true || mDownloadJob?.isActive == true)
{
return
return null
}
val last = mPrefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L)
if (System.currentTimeMillis() - last >= Constants.UPDATE_CHECK_INTERVAL_MS)
{
check()
return check()
}
return null
}
fun isAutoCheckEnabled(): Boolean
@@ -228,29 +243,44 @@ class UpdateManager private constructor(private val context: Context)
check()
}
private fun check()
@Synchronized
private fun check(): Job?
{
if (mCheckJob?.isActive == true || mDownloadJob?.isActive == true)
{
return
return mCheckJob?.takeIf { it.isActive }
}
mCheckJob = mScope.launch {
val job = mScope.launch {
mState.value = UpdateState.Checking
val info = fetchManifest() ?: return@launch
val info = fetchManifest() ?: run {
mInstallPending = false
return@launch
}
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis()).apply()
if (info.versionCode > currentVersionCode())
{
DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " +
"installed ${currentVersionName()} (${currentVersionCode()})")
mState.value = UpdateState.Available(info)
maybeDownloadSilently(info)
if (mInstallPending)
{
mInstallPending = false
downloadAndInstall()
}
else
{
maybeDownloadSilently(info)
}
}
else
{
mInstallPending = false
DiagLog.i(TAG, "update: up to date (${currentVersionName()} ${currentVersionCode()})")
mState.value = UpdateState.UpToDate
}
}
mCheckJob = job
return job
}
/**
@@ -260,6 +290,7 @@ class UpdateManager private constructor(private val context: Context)
* right away; otherwise a notification tells the user it is ready - one
* tap installs it (a single system confirmation on Android < 12)
*/
@Synchronized
private fun maybeDownloadSilently(info: UpdateInfo)
{
if (!isSilentUpdatesEnabled())
@@ -292,7 +323,19 @@ class UpdateManager private constructor(private val context: Context)
else
{
DiagLog.i(TAG, "update: ${info.versionName} downloaded, waiting for the user")
notifyUpdateReady(info)
/* back to Available: Downloading would block downloadAndInstall()
* and leave the settings row stuck at 100% */
mState.value = UpdateState.Available(info)
if (mInstallPending)
{
mInstallPending = false
UpdateInstaller.install(context, apk)
mState.value = UpdateState.Idle
}
else
{
notifyUpdateReady(info)
}
}
}
catch (e: Exception)
@@ -300,6 +343,7 @@ class UpdateManager private constructor(private val context: Context)
Log.e(TAG, "Silent update download failed", e)
DiagLog.i(TAG, "update: silent download failed: ${e.javaClass.simpleName}: ${e.message}")
/* the manual download from the settings screen still works */
mState.value = UpdateState.Available(info)
}
}
}
@@ -311,8 +355,20 @@ class UpdateManager private constructor(private val context: Context)
private fun isNetworkMetered(): Boolean
{
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
return cm?.isActiveNetworkMetered ?: true
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return true
/* the VPN marks itself unmetered (setMetered(false)), so with the tunnel
* up the active network always looks unmetered - judge by the physical
* network below it instead */
val underlying = UnderlyingNetwork.resolve(context) ?: return cm.isActiveNetworkMetered
val caps = try
{
cm.getNetworkCapabilities(underlying)
}
catch (_: Exception)
{
null
} ?: return true
return !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
private fun isVpnDown(): Boolean
@@ -434,11 +490,20 @@ class UpdateManager private constructor(private val context: Context)
* Downloads the update APK (with progress) and installs it as an
* in-place update (the app data is preserved)
*/
@Synchronized
fun downloadAndInstall()
{
val info = (mState.value as? UpdateState.Available)?.info ?: return
if (mDownloadJob?.isActive == true)
{
{ /* a silent download is running: install once it is done */
mInstallPending = true
return
}
val info = (mState.value as? UpdateState.Available)?.info
if (info == null)
{ /* e.g. the "update ready" notification was tapped after the process
* restarted: look the update up again and install it when found */
mInstallPending = true
check()
return
}
mDownloadJob = mScope.launch {
@@ -35,13 +35,18 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -122,8 +127,15 @@ class LogViewModel : ViewModel()
private val _lines = MutableStateFlow<List<String>>(emptyList())
val lines: StateFlow<List<String>> = _lines
/* tail only while the screen is shown (set by LogScreen) */
private val active = MutableStateFlow(false)
private var started = false
fun setActive(value: Boolean)
{
active.value = value
}
fun start(file: File)
{
if (started) return
@@ -131,34 +143,64 @@ class LogViewModel : ViewModel()
viewModelScope.launch(Dispatchers.IO) {
val buffer = ArrayList<String>()
var reader: BufferedReader? = null
var lastLength = 0L
suspend fun flush()
{
if (buffer.isEmpty())
{
return
}
val batch = buffer.toList()
buffer.clear()
withContext(Dispatchers.Main) {
val merged = _lines.value + batch
/* bounded: a long session must not grow the list forever */
_lines.value = if (merged.size > MAX_LINES) merged.takeLast(MAX_LINES) else merged
}
}
try
{
reader = if (file.exists()) BufferedReader(FileReader(file)) else null
while (isActive)
{
val line = reader?.readLine()
if (!active.value)
{
active.first { it }
}
if (reader == null)
{ /* the log may not exist yet (no connection so far) */
if (file.exists())
{
reader = BufferedReader(FileReader(file))
lastLength = 0L
}
else
{
delay(POLL_MS)
continue
}
}
val line = reader.readLine()
if (line == null)
{
if (buffer.isNotEmpty())
{
val batch = buffer.toList()
buffer.clear()
withContext(Dispatchers.Main) {
_lines.value = _lines.value + batch
}
flush()
val length = if (file.exists()) file.length() else -1L
if (length < lastLength)
{ /* deleted or truncated (a new connection starts a fresh log):
* the reader would stay on the old file forever */
try { reader.close() } catch (_: Exception) {}
reader = null
withContext(Dispatchers.Main) { _lines.value = emptyList() }
continue
}
delay(800)
lastLength = length
delay(POLL_MS)
}
else
{
buffer.add(line)
if (buffer.size >= 50)
{
val batch = buffer.toList()
buffer.clear()
withContext(Dispatchers.Main) {
_lines.value = _lines.value + batch
}
flush()
}
}
}
@@ -172,6 +214,12 @@ class LogViewModel : ViewModel()
}
}
}
private companion object
{
const val MAX_LINES = 5000
const val POLL_MS = 800L
}
}
private enum class LogLevelFilter(val labelRes: Int)
@@ -208,17 +256,42 @@ private fun LogScreen(
}
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(Unit) {
viewModel.start(File(context.filesDir, CharonVpnService.LOG_FILE))
}
LaunchedEffect(lifecycle) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.setActive(true)
try
{
awaitCancellation()
}
finally
{
viewModel.setActive(false)
}
}
}
LaunchedEffect(messages) {
messages.collect { snackbarHostState.showSnackbar(context.getString(it)) }
}
/* follow the tail only while the user is at the bottom (or switched the
* filter): scrolling up to read must not be undone by every new batch */
var followedSize by remember { mutableStateOf(0) }
var followedFilter by remember { mutableStateOf(filter) }
LaunchedEffect(lines.size, filter) {
if (lines.isNotEmpty())
{
listState.scrollToItem(lines.lastIndex)
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
if (filter != followedFilter || followedSize == 0 || lastVisible >= followedSize - 2 ||
lines.size < followedSize)
{
listState.scrollToItem(lines.lastIndex)
}
}
followedSize = lines.size
followedFilter = filter
}
SecondaryScaffold(
@@ -79,6 +79,7 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
private Bundle mProfileInfo;
private boolean mWaitingForResult;
private VpnStateService mService;
private boolean mBound;
/* profile lookups are SQLite queries and must not block the main thread */
private final ExecutorService mProfileExecutor = Executors.newSingleThreadExecutor();
private final ServiceConnection mServiceConnection = new ServiceConnection()
@@ -140,8 +141,8 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
{
mWaitingForResult = savedInstanceState.getBoolean(WAITING_FOR_RESULT, false);
}
this.bindService(new Intent(this, VpnStateService.class),
mServiceConnection, Service.BIND_AUTO_CREATE);
mBound = this.bindService(new Intent(this, VpnStateService.class),
mServiceConnection, Service.BIND_AUTO_CREATE);
VpnDialogBus.INSTANCE.setListener(this);
VpnDialogsKt.installVpnDialogHost(this);
}
@@ -159,10 +160,12 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
super.onDestroy();
VpnDialogBus.INSTANCE.setListener(null);
mProfileExecutor.shutdown();
if (mService != null)
if (mBound)
{
this.unbindService(mServiceConnection);
mBound = false;
}
mService = null;
}
/**
@@ -429,6 +432,10 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
mProfileExecutor.execute(() -> {
VpnProfile profile = loadProfile(uuid);
runOnUiThread(() -> {
if (isFinishing() || isDestroyed())
{ /* rotated or finished while the query ran */
return;
}
if (profile != null)
{
startVpnProfile(profile);
@@ -479,7 +486,12 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
final Intent startIntent = intent;
mProfileExecutor.execute(() -> {
VpnProfile profile = loadProfile(uuid);
runOnUiThread(() -> handleDisconnect(startIntent, profile));
runOnUiThread(() -> {
if (!isFinishing() && !isDestroyed())
{
handleDisconnect(startIntent, profile);
}
});
});
}
@@ -45,6 +45,9 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
private boolean mListening;
private VpnProfileDataSource mDataSource;
private VpnStateService mService;
private boolean mBound;
private VpnProfile mDefaultProfile;
private boolean mDefaultProfileLoaded;
private final ServiceConnection mServiceConnection = new ServiceConnection()
{
@Override
@@ -71,8 +74,8 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
super.onCreate();
Context context = getApplicationContext();
context.bindService(new Intent(context, VpnStateService.class),
mServiceConnection, Service.BIND_AUTO_CREATE);
mBound = context.bindService(new Intent(context, VpnStateService.class),
mServiceConnection, Service.BIND_AUTO_CREATE);
mDataSource = new VpnProfileSource(this);
mDataSource.open();
@@ -83,10 +86,14 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
{
super.onDestroy();
if (mService != null)
/* the binding lives on the application context: unbind even if the
* service never connected, or it leaks for the life of the process */
if (mBound)
{
getApplicationContext().unbindService(mServiceConnection);
mBound = false;
}
mService = null;
mDataSource.close();
mDataSource = null;
}
@@ -96,6 +103,8 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
{
super.onStartListening();
mListening = true;
/* the configured profile may have changed while the panel was closed */
mDefaultProfileLoaded = false;
if (mService != null)
{
@@ -118,7 +127,14 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
private VpnProfile getProfile()
{
return mDataSource != null ? mDataSource.getVpnProfile(DefaultVpnProfile.getDefaultProfileUuid(this)) : null;
/* updateTile() runs on every state notification (including retry
* countdown ticks): query the database once per listening session */
if (!mDefaultProfileLoaded)
{
mDefaultProfile = mDataSource != null ? mDataSource.getVpnProfile(DefaultVpnProfile.getDefaultProfileUuid(this)) : null;
mDefaultProfileLoaded = true;
}
return mDefaultProfile;
}
@Override
@@ -89,6 +89,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
@@ -104,6 +105,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@@ -169,15 +171,29 @@ fun HomeScreen(
val deadTargets by viewModel.deadTargets.collectAsStateWithLifecycle()
/* rolling RTT history per target, rendered as sparklines in the availability card */
val probeHistory = remember { mutableStateMapOf<ProbeTarget, SnapshotStateList<ProbeResult?>>() }
/* the map is updated one target at a time as the parallel probes finish:
* only append results that are new, not every entry on every emission */
val lastSeen = remember { HashMap<ProbeTarget, ProbeResult>() }
DisposableEffect(viewModel)
{ /* the view model only polls traffic and probes while Home is shown */
viewModel.setHomeVisible(true)
onDispose { viewModel.setHomeVisible(false) }
}
LaunchedEffect(probes)
{
if (probes.isEmpty())
{
probeHistory.clear()
lastSeen.clear()
}
else
{
probes.forEach { (target, result) ->
if (lastSeen[target] === result)
{
return@forEach
}
lastSeen[target] = result
val list = probeHistory.getOrPut(target) { mutableStateListOf() }
list.add(result)
while (list.size > RTT_HISTORY)
@@ -533,30 +549,43 @@ private fun StatusGlowBackground(color: Color, active: Boolean)
) != 0f
}
val animated by animateColorAsState(color, label = "glow")
val transition = rememberInfiniteTransition(label = "pulse")
/* read inside the draw scope below so the pulse only invalidates drawing,
* instead of recomposing this composable on every frame */
val pulse by transition.animateFloat(
initialValue = 0.30f,
targetValue = 0.55f,
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Reverse),
label = "a",
)
Canvas(Modifier.fillMaxSize())
{
val alpha = if (active) (if (animationsOn) pulse else 0.42f) else 0f
val cx = size.width / 2f
val cy = size.height * 0.16f
drawCircle(
brush = Brush.radialGradient(
colors = listOf(animated.copy(alpha = alpha * 0.30f), Color.Transparent),
center = Offset(cx, cy),
radius = size.minDimension * 0.62f,
),
radius = size.minDimension * 0.62f,
center = Offset(cx, cy),
)
if (!active)
{ /* nothing to draw: no canvas and, above all, no infinite transition
* redrawing a transparent circle every frame while disconnected */
return
}
if (animationsOn)
{
val transition = rememberInfiniteTransition(label = "pulse")
/* read inside the draw scope below so the pulse only invalidates drawing,
* instead of recomposing this composable on every frame */
val pulse by transition.animateFloat(
initialValue = 0.30f,
targetValue = 0.55f,
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Reverse),
label = "a",
)
Canvas(Modifier.fillMaxSize()) { drawGlow(animated, pulse) }
}
else
{
Canvas(Modifier.fillMaxSize()) { drawGlow(animated, 0.42f) }
}
}
private fun DrawScope.drawGlow(color: Color, alpha: Float)
{
val center = Offset(size.width / 2f, size.height * 0.16f)
val radius = size.minDimension * 0.62f
drawCircle(
brush = Brush.radialGradient(
colors = listOf(color.copy(alpha = alpha * 0.30f), Color.Transparent),
center = center,
radius = radius,
),
radius = radius,
center = center,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -36,11 +36,16 @@ import androidx.lifecycle.viewModelScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -131,6 +136,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
private val _messages = MutableSharedFlow<Int>(extraBufferCapacity = 4)
val messages: SharedFlow<Int> = _messages
private val homeVisible = MutableStateFlow(false)
private val _ui = MutableStateFlow(HomeUiState())
val ui: StateFlow<HomeUiState> = _ui
@@ -204,46 +210,66 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
)
reloadProfiles()
WhiteListManager.getInstance(ctx).refreshIfStale()
/* poll traffic only while the app is visible, and off the main thread */
/* poll traffic only while the Home screen is shown (not on the other
* tabs or behind another activity), the VPN is up, and off the main thread */
viewModelScope.launch {
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (isActive)
{
whileHomeConnected {
tickTraffic()
delay(TRAFFIC_POLL_MS)
}
}
}
/* probe availability while the app is visible and the VPN is connected */
/* probe availability under the same conditions */
viewModelScope.launch {
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
var lastProbeAt = 0L
while (isActive)
{
val connected = _ui.value.vpnState == State.CONNECTED
if (connected && SystemClock.elapsedRealtime() - lastProbeAt >= PROBE_INTERVAL_MS)
whileHomeConnected {
val wait = PROBE_INTERVAL_MS - (SystemClock.elapsedRealtime() - lastProbeAt)
if (wait > 0)
{
lastProbeAt = SystemClock.elapsedRealtime()
probeNow()
delay(wait)
}
delay(PROBE_TICK_MS)
lastProbeAt = SystemClock.elapsedRealtime()
probeNow()
}
}
}
}
/**
* Runs [block] repeatedly while the Home screen is visible and the VPN is
* connected; suspends without any polling otherwise. [block] is
* cancelled as soon as either condition goes away.
*/
private suspend fun whileHomeConnected(block: suspend () -> Unit)
{
combine(homeVisible, _ui.map { it.vpnState == State.CONNECTED }) { visible, connected ->
visible && connected
}.distinctUntilChanged().collectLatest { active ->
while (active && currentCoroutineContext().isActive)
{
block()
}
}
}
/** Called by the Home screen while it is (not) shown. */
fun setHomeVisible(visible: Boolean)
{
homeVisible.value = visible
}
/** Immediately reruns the availability probe round (refresh button). */
fun refreshProbes()
{
WhiteListManager.getInstance(getApplication()).checkNow()
viewModelScope.launch {
probeNow()
refreshExit(force = true)
refreshDeadTargets()
probeNow(forceExit = true)
}
}
private suspend fun probeNow()
private suspend fun probeNow(forceExit: Boolean = false)
{
/* direct (outside the tunnel) probes: at most once per TTL, event-driven */
WhiteListManager.getInstance(getApplication()).refreshIfStale()
@@ -263,7 +289,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
addAll(healthConfig.targets)
}
refreshDeadTargets()
viewModelScope.launch { refreshExit(force = false) }
viewModelScope.launch { refreshExit(force = forceExit) }
if (targets.isEmpty())
{
return
@@ -354,12 +380,18 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
viewModelScope.launch(Dispatchers.IO) {
val source = VpnProfileSource(getApplication())
source.open()
source.deleteVpnProfile(profile)
source.close()
try
{
source.deleteVpnProfile(profile)
}
finally
{
source.close()
}
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
intent.putExtra(Constants.VPN_PROFILES_MULTIPLE, arrayOf(profile.getUUID().toString()))
/* the broadcast reaches profilesChanged, which reloads the list */
LocalBroadcastManager.getInstance(getApplication()).sendBroadcast(intent)
reloadProfiles()
}
}
@@ -369,11 +401,16 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
viewModelScope.launch(Dispatchers.IO) {
val source = VpnProfileSource(getApplication())
source.open()
source.insertProfile(profile)
source.close()
try
{
source.insertProfile(profile)
}
finally
{
source.close()
}
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
LocalBroadcastManager.getInstance(getApplication()).sendBroadcast(intent)
reloadProfiles()
}
}
@@ -408,8 +445,14 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
viewModelScope.launch(Dispatchers.IO) {
val source = VpnProfileSource(getApplication())
source.open()
val all = source.allVpnProfiles.sortedBy { it.name?.lowercase() ?: "" }
source.close()
val all = try
{
source.allVpnProfiles.sortedBy { it.name?.lowercase() ?: "" }
}
finally
{
source.close()
}
_ui.update { state ->
val selected = all.firstOrNull { it.getUUID() == state.selected?.getUUID() }
?: all.firstOrNull { it.getUUID() == state.activeProfile?.getUUID() }
@@ -630,7 +673,6 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
companion object
{
private const val TRAFFIC_POLL_MS = 1000L
private const val PROBE_TICK_MS = 1000L
private const val PROBE_INTERVAL_MS = 10_000L
private const val EXIT_REFRESH_MS = 60_000L
private const val EXIT_TIMEOUT_MS = 5000
@@ -60,7 +60,10 @@ import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -161,21 +164,26 @@ private fun RoutingPageContent(
}
}
/* dead direct destinations the engine keeps refusing */
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>())
/* dead direct destinations the engine keeps refusing; polled only while
* the screen is visible (not while the activity is stopped) */
val lifecycle = LocalLifecycleOwner.current.lifecycle
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>(), lifecycle)
{
while (true)
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
{
val raw = try
while (true)
{
withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() }
val raw = try
{
withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() }
}
catch (_: Throwable)
{
null
}
value = HomeViewModel.parseDeadTargets(raw)
delay(5_000)
}
catch (_: Throwable)
{
null
}
value = HomeViewModel.parseDeadTargets(raw)
delay(5_000)
}
}
@@ -240,7 +240,6 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
ShxSection(title = stringResource(R.string.settings_page_appearance))
{
ThemeModeRow()
var addedRow = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
{
var dynamicColors by remember { ShxThemeSettings.load(context); mutableStateOf(ShxThemeSettings.useDynamicColors.value) }
@@ -254,7 +253,6 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
ShxThemeSettings.setUseDynamicColors(context, it)
},
)
addedRow = true
}
}
}
@@ -74,6 +74,8 @@ import org.strongswan.android.ui.compose.theme.ShxSpacing
fun DirectAppsScreen(onBack: () -> Unit, viewModel: DirectAppsViewModel = viewModel())
{
val state by viewModel.ui.collectAsStateWithLifecycle()
/* the only load trigger (also re-entering the screen with a retained
* view model picks up newly installed apps) */
androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.refresh() }
DirectAppsContent(
state = state,
@@ -114,11 +114,6 @@ class DirectAppsViewModel(app: Application) : AndroidViewModel(app)
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DirectAppsUiState())
init
{
refresh()
}
/** (Re)loads the installed apps; the previous list stays visible meanwhile. */
fun refresh()
{
@@ -156,7 +151,7 @@ object DirectApps
fun loadInstalledApps(pm: PackageManager, ownPackage: String): List<InstalledApp>
{
val collator = Collator.getInstance()
return pm.getInstalledApplications(PackageManager.GET_META_DATA)
return pm.getInstalledApplications(0)
.asSequence()
.filter { it.packageName != ownPackage }
.filter { pm.checkPermission(android.Manifest.permission.INTERNET, it.packageName) == PackageManager.PERMISSION_GRANTED }
@@ -148,18 +148,21 @@ private fun WidgetContent(context: Context, snapshot: WidgetSnapshot)
{
val connected = snapshot.state == VpnStateService.State.CONNECTED.name && !snapshot.error
val connecting = snapshot.state == VpnStateService.State.CONNECTING.name && !snapshot.error
/* not "Unprotected" with a Connect button while the tunnel is going down */
val disconnecting = snapshot.state == VpnStateService.State.DISCONNECTING.name && !snapshot.error
val status = context.getString(
when
{
connected -> R.string.status_protected
connecting -> R.string.status_connecting
disconnecting -> R.string.state_disconnecting
else -> R.string.status_unprotected
}
)
val buttonLabel = context.getString(if (connected || connecting) R.string.disconnect else R.string.connect)
val buttonLabel = context.getString(if (connected || connecting || disconnecting) R.string.disconnect else R.string.connect)
/* same entry point as the quick settings tile */
val intent = Intent(context, VpnProfileControlActivity::class.java)
if (connected || connecting)
if (connected || connecting || disconnecting)
{
intent.action = VpnProfileControlActivity.DISCONNECT
}
@@ -1325,16 +1325,21 @@ JNI_METHOD(CharonVpnService, setUserspaceRoutingData, void,
*/
JNI_METHOD(CharonVpnService, getRoutingStats, jlongArray)
{
direct_engine_t *engine = direct_engine_get();
direct_engine_t *engine;
direct_stats_t s = {};
jlong v[22];
jlongArray result;
memset(&s, 0, sizeof(s));
/* polled from the UI: a concurrent disconnect frees the engine under
* charon_mutex, so the pointer may only be used while holding it */
pthread_mutex_lock(&charon_mutex);
engine = direct_engine_get();
if (engine)
{
engine->get_stats(engine, &s);
}
pthread_mutex_unlock(&charon_mutex);
v[0] = (jlong)s.tunnel_pkts;
v[1] = (jlong)s.tunnel_bytes;
v[2] = (jlong)s.direct_pkts;
@@ -1370,7 +1375,7 @@ JNI_METHOD(CharonVpnService, getRoutingStats, jlongArray)
*/
JNI_METHOD(CharonVpnService, getDeadDirectTargets, jobjectArray)
{
direct_engine_t *engine = direct_engine_get();
direct_engine_t *engine;
direct_dead_target_t t[DIRECT_DEAD_MAX];
jobjectArray result;
jclass cls;
@@ -1379,10 +1384,14 @@ JNI_METHOD(CharonVpnService, getDeadDirectTargets, jobjectArray)
/* stack snapshot: zero it so no field can ever leak garbage */
memset(t, 0, sizeof(t));
/* same race against a concurrent disconnect as getRoutingStats() */
pthread_mutex_lock(&charon_mutex);
engine = direct_engine_get();
if (engine)
{
n = engine->get_dead_targets(engine, t, DIRECT_DEAD_MAX);
}
pthread_mutex_unlock(&charon_mutex);
cls = (*env)->FindClass(env, "java/lang/String");
if (!cls)
{
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FF6D4AFF"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<!-- Shield-S mark: white shield with the S cut out (even-odd), also used as the monochrome layer -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:translateX="16.979"
android:translateY="16.979"
android:scaleX="3.085"
android:scaleY="3.085">
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z M15.53,8.213 L15.427,8.057 L15.318,7.906 L15.203,7.762 L15.083,7.623 L14.957,7.491 L14.826,7.365 L14.691,7.245 L14.551,7.132 L14.406,7.025 L14.258,6.925 L14.106,6.832 L13.95,6.746 L13.792,6.666 L13.63,6.594 L13.465,6.528 L13.298,6.47 L13.129,6.418 L12.958,6.373 L12.785,6.335 L12.61,6.305 L12.434,6.281 L12.257,6.264 L12.079,6.253 L11.9,6.25 L11.662,6.256 L11.428,6.273 L11.197,6.301 L10.971,6.34 L10.749,6.39 L10.532,6.452 L10.32,6.525 L10.114,6.609 L9.915,6.704 L9.722,6.811 L9.536,6.929 L9.359,7.059 L9.191,7.201 L9.032,7.353 L8.884,7.518 L8.748,7.693 L8.625,7.878 L8.515,8.074 L8.421,8.279 L8.342,8.492 L8.28,8.712 L8.236,8.937 L8.209,9.167 L8.2,9.4 L8.245,9.922 L8.381,10.415 L8.603,10.861 L8.891,11.241 L9.22,11.554 L9.571,11.806 L9.932,12.011 L10.298,12.18 L10.664,12.323 L11.027,12.448 L11.385,12.561 L11.734,12.666 L12.068,12.768 L12.384,12.868 L12.674,12.97 L12.934,13.075 L13.157,13.181 L13.336,13.286 L13.471,13.386 L13.562,13.476 L13.621,13.556 L13.66,13.637 L13.688,13.743 L13.7,13.9 L13.697,13.975 L13.689,14.047 L13.675,14.114 L13.657,14.179 L13.634,14.242 L13.606,14.304 L13.572,14.364 L13.533,14.424 L13.488,14.483 L13.436,14.541 L13.378,14.598 L13.313,14.654 L13.241,14.708 L13.161,14.759 L13.075,14.809 L12.981,14.854 L12.881,14.897 L12.773,14.935 L12.66,14.968 L12.539,14.997 L12.413,15.019 L12.281,15.036 L12.143,15.046 L12,15.05 L11.893,15.048 L11.79,15.042 L11.689,15.032 L11.59,15.018 L11.495,15.001 L11.402,14.981 L11.311,14.957 L11.224,14.929 L11.138,14.899 L11.056,14.865 L10.975,14.828 L10.897,14.788 L10.822,14.745 L10.748,14.699 L10.677,14.65 L10.608,14.598 L10.54,14.542 L10.475,14.484 L10.413,14.422 L10.352,14.357 L10.293,14.289 L10.237,14.217 L10.182,14.141 L10.13,14.063 L9.903,13.804 L9.607,13.628 L9.271,13.552 L8.929,13.584 L8.613,13.72 L8.354,13.947 L8.178,14.243 L8.102,14.579 L8.134,14.921 L8.27,15.237 L8.372,15.392 L8.48,15.541 L8.594,15.686 L8.713,15.825 L8.838,15.959 L8.968,16.086 L9.104,16.209 L9.244,16.324 L9.39,16.434 L9.54,16.538 L9.694,16.634 L9.853,16.724 L10.015,16.808 L10.182,16.884 L10.351,16.953 L10.525,17.015 L10.701,17.07 L10.88,17.118 L11.061,17.158 L11.245,17.191 L11.431,17.217 L11.619,17.235 L11.809,17.246 L12,17.25 L12.253,17.244 L12.502,17.225 L12.746,17.194 L12.987,17.151 L13.222,17.095 L13.452,17.028 L13.675,16.948 L13.893,16.857 L14.103,16.753 L14.306,16.638 L14.501,16.511 L14.687,16.371 L14.864,16.221 L15.029,16.058 L15.184,15.884 L15.326,15.699 L15.455,15.503 L15.569,15.296 L15.668,15.081 L15.75,14.857 L15.815,14.626 L15.862,14.388 L15.891,14.146 L15.9,13.9 L15.856,13.373 L15.724,12.874 L15.506,12.422 L15.223,12.033 L14.896,11.71 L14.545,11.448 L14.182,11.235 L13.814,11.058 L13.445,10.91 L13.078,10.781 L12.717,10.665 L12.366,10.559 L12.029,10.457 L11.71,10.357 L11.417,10.256 L11.154,10.153 L10.929,10.05 L10.748,9.947 L10.613,9.851 L10.524,9.767 L10.469,9.696 L10.435,9.627 L10.411,9.538 L10.4,9.4 L10.402,9.337 L10.409,9.278 L10.42,9.223 L10.435,9.171 L10.453,9.121 L10.475,9.073 L10.502,9.025 L10.534,8.978 L10.57,8.93 L10.613,8.883 L10.661,8.837 L10.716,8.791 L10.778,8.746 L10.846,8.702 L10.922,8.66 L11.004,8.621 L11.094,8.584 L11.19,8.551 L11.293,8.522 L11.402,8.497 L11.518,8.477 L11.64,8.462 L11.767,8.453 L11.9,8.45 L11.995,8.452 L12.089,8.457 L12.18,8.466 L12.271,8.478 L12.359,8.494 L12.445,8.513 L12.53,8.535 L12.613,8.56 L12.694,8.588 L12.773,8.62 L12.85,8.654 L12.925,8.692 L12.998,8.732 L13.069,8.776 L13.138,8.822 L13.205,8.872 L13.27,8.925 L13.333,8.981 L13.394,9.04 L13.454,9.102 L13.511,9.168 L13.566,9.237 L13.619,9.31 L13.67,9.387 L13.897,9.646 L14.193,9.822 L14.529,9.898 L14.871,9.866 L15.187,9.73 L15.446,9.503 L15.622,9.207 L15.698,8.871 L15.666,8.529Z" />
</group>
</vector>
@@ -1,10 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12,2.5 L19.5,5.2 V11 C19.5,16 16.4,19.8 12,21.3 C7.6,19.8 4.5,16 4.5,11 V5.2 Z"
android:strokeColor="#FFFFFF" android:strokeWidth="2" android:fillColor="#00000000"
android:strokeLineJoin="round"/>
<path android:pathData="M9,11.8 L11.3,14.1 L15.2,9.7"
android:strokeColor="#FFFFFF" android:strokeWidth="2" android:fillColor="#00000000"
android:strokeLineCap="round" android:strokeLineJoin="round"/>
<!-- Shield-S: filled shield with the S cut out (even-odd) - same mark as the launcher icon -->
<path android:fillColor="#FFFFFF" android:fillType="evenOdd"
android:pathData="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z M15.53,8.213 L15.427,8.057 L15.318,7.906 L15.203,7.762 L15.083,7.623 L14.957,7.491 L14.826,7.365 L14.691,7.245 L14.551,7.132 L14.406,7.025 L14.258,6.925 L14.106,6.832 L13.95,6.746 L13.792,6.666 L13.63,6.594 L13.465,6.528 L13.298,6.47 L13.129,6.418 L12.958,6.373 L12.785,6.335 L12.61,6.305 L12.434,6.281 L12.257,6.264 L12.079,6.253 L11.9,6.25 L11.662,6.256 L11.428,6.273 L11.197,6.301 L10.971,6.34 L10.749,6.39 L10.532,6.452 L10.32,6.525 L10.114,6.609 L9.915,6.704 L9.722,6.811 L9.536,6.929 L9.359,7.059 L9.191,7.201 L9.032,7.353 L8.884,7.518 L8.748,7.693 L8.625,7.878 L8.515,8.074 L8.421,8.279 L8.342,8.492 L8.28,8.712 L8.236,8.937 L8.209,9.167 L8.2,9.4 L8.245,9.922 L8.381,10.415 L8.603,10.861 L8.891,11.241 L9.22,11.554 L9.571,11.806 L9.932,12.011 L10.298,12.18 L10.664,12.323 L11.027,12.448 L11.385,12.561 L11.734,12.666 L12.068,12.768 L12.384,12.868 L12.674,12.97 L12.934,13.075 L13.157,13.181 L13.336,13.286 L13.471,13.386 L13.562,13.476 L13.621,13.556 L13.66,13.637 L13.688,13.743 L13.7,13.9 L13.697,13.975 L13.689,14.047 L13.675,14.114 L13.657,14.179 L13.634,14.242 L13.606,14.304 L13.572,14.364 L13.533,14.424 L13.488,14.483 L13.436,14.541 L13.378,14.598 L13.313,14.654 L13.241,14.708 L13.161,14.759 L13.075,14.809 L12.981,14.854 L12.881,14.897 L12.773,14.935 L12.66,14.968 L12.539,14.997 L12.413,15.019 L12.281,15.036 L12.143,15.046 L12,15.05 L11.893,15.048 L11.79,15.042 L11.689,15.032 L11.59,15.018 L11.495,15.001 L11.402,14.981 L11.311,14.957 L11.224,14.929 L11.138,14.899 L11.056,14.865 L10.975,14.828 L10.897,14.788 L10.822,14.745 L10.748,14.699 L10.677,14.65 L10.608,14.598 L10.54,14.542 L10.475,14.484 L10.413,14.422 L10.352,14.357 L10.293,14.289 L10.237,14.217 L10.182,14.141 L10.13,14.063 L9.903,13.804 L9.607,13.628 L9.271,13.552 L8.929,13.584 L8.613,13.72 L8.354,13.947 L8.178,14.243 L8.102,14.579 L8.134,14.921 L8.27,15.237 L8.372,15.392 L8.48,15.541 L8.594,15.686 L8.713,15.825 L8.838,15.959 L8.968,16.086 L9.104,16.209 L9.244,16.324 L9.39,16.434 L9.54,16.538 L9.694,16.634 L9.853,16.724 L10.015,16.808 L10.182,16.884 L10.351,16.953 L10.525,17.015 L10.701,17.07 L10.88,17.118 L11.061,17.158 L11.245,17.191 L11.431,17.217 L11.619,17.235 L11.809,17.246 L12,17.25 L12.253,17.244 L12.502,17.225 L12.746,17.194 L12.987,17.151 L13.222,17.095 L13.452,17.028 L13.675,16.948 L13.893,16.857 L14.103,16.753 L14.306,16.638 L14.501,16.511 L14.687,16.371 L14.864,16.221 L15.029,16.058 L15.184,15.884 L15.326,15.699 L15.455,15.503 L15.569,15.296 L15.668,15.081 L15.75,14.857 L15.815,14.626 L15.862,14.388 L15.891,14.146 L15.9,13.9 L15.856,13.373 L15.724,12.874 L15.506,12.422 L15.223,12.033 L14.896,11.71 L14.545,11.448 L14.182,11.235 L13.814,11.058 L13.445,10.91 L13.078,10.781 L12.717,10.665 L12.366,10.559 L12.029,10.457 L11.71,10.357 L11.417,10.256 L11.154,10.153 L10.929,10.05 L10.748,9.947 L10.613,9.851 L10.524,9.767 L10.469,9.696 L10.435,9.627 L10.411,9.538 L10.4,9.4 L10.402,9.337 L10.409,9.278 L10.42,9.223 L10.435,9.171 L10.453,9.121 L10.475,9.073 L10.502,9.025 L10.534,8.978 L10.57,8.93 L10.613,8.883 L10.661,8.837 L10.716,8.791 L10.778,8.746 L10.846,8.702 L10.922,8.66 L11.004,8.621 L11.094,8.584 L11.19,8.551 L11.293,8.522 L11.402,8.497 L11.518,8.477 L11.64,8.462 L11.767,8.453 L11.9,8.45 L11.995,8.452 L12.089,8.457 L12.18,8.466 L12.271,8.478 L12.359,8.494 L12.445,8.513 L12.53,8.535 L12.613,8.56 L12.694,8.588 L12.773,8.62 L12.85,8.654 L12.925,8.692 L12.998,8.732 L13.069,8.776 L13.138,8.822 L13.205,8.872 L13.27,8.925 L13.333,8.981 L13.394,9.04 L13.454,9.102 L13.511,9.168 L13.566,9.237 L13.619,9.31 L13.67,9.387 L13.897,9.646 L14.193,9.822 L14.529,9.898 L14.871,9.866 L15.187,9.73 L15.446,9.503 L15.622,9.207 L15.698,8.871 L15.666,8.529Z"/>
</vector>
@@ -4,6 +4,8 @@
Copyright (C) secunet Security Networks AG
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
@@ -15,6 +17,7 @@
for more details.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_bg" />
<foreground android:drawable="@mipmap/ic_launcher_fg" />
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -4,6 +4,8 @@
Copyright (C) secunet Security Networks AG
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
@@ -15,6 +17,7 @@
for more details.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_shortcut_bg" />
<foreground android:drawable="@mipmap/ic_shortcut_fg" />
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<!-- Launcher icon for Android 7.x (no adaptive icons): rounded square with the Shield-S mark -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#FF6D4AFF"
android:pathData="M12,1h24a11,11 0 0 1 11,11v24a11,11 0 0 1 -11,11h-24a11,11 0 0 1 -11,-11v-24a11,11 0 0 1 11,-11z" />
<group
android:translateX="2.298"
android:translateY="2.298"
android:scaleX="1.809"
android:scaleY="1.809">
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z M15.53,8.213 L15.427,8.057 L15.318,7.906 L15.203,7.762 L15.083,7.623 L14.957,7.491 L14.826,7.365 L14.691,7.245 L14.551,7.132 L14.406,7.025 L14.258,6.925 L14.106,6.832 L13.95,6.746 L13.792,6.666 L13.63,6.594 L13.465,6.528 L13.298,6.47 L13.129,6.418 L12.958,6.373 L12.785,6.335 L12.61,6.305 L12.434,6.281 L12.257,6.264 L12.079,6.253 L11.9,6.25 L11.662,6.256 L11.428,6.273 L11.197,6.301 L10.971,6.34 L10.749,6.39 L10.532,6.452 L10.32,6.525 L10.114,6.609 L9.915,6.704 L9.722,6.811 L9.536,6.929 L9.359,7.059 L9.191,7.201 L9.032,7.353 L8.884,7.518 L8.748,7.693 L8.625,7.878 L8.515,8.074 L8.421,8.279 L8.342,8.492 L8.28,8.712 L8.236,8.937 L8.209,9.167 L8.2,9.4 L8.245,9.922 L8.381,10.415 L8.603,10.861 L8.891,11.241 L9.22,11.554 L9.571,11.806 L9.932,12.011 L10.298,12.18 L10.664,12.323 L11.027,12.448 L11.385,12.561 L11.734,12.666 L12.068,12.768 L12.384,12.868 L12.674,12.97 L12.934,13.075 L13.157,13.181 L13.336,13.286 L13.471,13.386 L13.562,13.476 L13.621,13.556 L13.66,13.637 L13.688,13.743 L13.7,13.9 L13.697,13.975 L13.689,14.047 L13.675,14.114 L13.657,14.179 L13.634,14.242 L13.606,14.304 L13.572,14.364 L13.533,14.424 L13.488,14.483 L13.436,14.541 L13.378,14.598 L13.313,14.654 L13.241,14.708 L13.161,14.759 L13.075,14.809 L12.981,14.854 L12.881,14.897 L12.773,14.935 L12.66,14.968 L12.539,14.997 L12.413,15.019 L12.281,15.036 L12.143,15.046 L12,15.05 L11.893,15.048 L11.79,15.042 L11.689,15.032 L11.59,15.018 L11.495,15.001 L11.402,14.981 L11.311,14.957 L11.224,14.929 L11.138,14.899 L11.056,14.865 L10.975,14.828 L10.897,14.788 L10.822,14.745 L10.748,14.699 L10.677,14.65 L10.608,14.598 L10.54,14.542 L10.475,14.484 L10.413,14.422 L10.352,14.357 L10.293,14.289 L10.237,14.217 L10.182,14.141 L10.13,14.063 L9.903,13.804 L9.607,13.628 L9.271,13.552 L8.929,13.584 L8.613,13.72 L8.354,13.947 L8.178,14.243 L8.102,14.579 L8.134,14.921 L8.27,15.237 L8.372,15.392 L8.48,15.541 L8.594,15.686 L8.713,15.825 L8.838,15.959 L8.968,16.086 L9.104,16.209 L9.244,16.324 L9.39,16.434 L9.54,16.538 L9.694,16.634 L9.853,16.724 L10.015,16.808 L10.182,16.884 L10.351,16.953 L10.525,17.015 L10.701,17.07 L10.88,17.118 L11.061,17.158 L11.245,17.191 L11.431,17.217 L11.619,17.235 L11.809,17.246 L12,17.25 L12.253,17.244 L12.502,17.225 L12.746,17.194 L12.987,17.151 L13.222,17.095 L13.452,17.028 L13.675,16.948 L13.893,16.857 L14.103,16.753 L14.306,16.638 L14.501,16.511 L14.687,16.371 L14.864,16.221 L15.029,16.058 L15.184,15.884 L15.326,15.699 L15.455,15.503 L15.569,15.296 L15.668,15.081 L15.75,14.857 L15.815,14.626 L15.862,14.388 L15.891,14.146 L15.9,13.9 L15.856,13.373 L15.724,12.874 L15.506,12.422 L15.223,12.033 L14.896,11.71 L14.545,11.448 L14.182,11.235 L13.814,11.058 L13.445,10.91 L13.078,10.781 L12.717,10.665 L12.366,10.559 L12.029,10.457 L11.71,10.357 L11.417,10.256 L11.154,10.153 L10.929,10.05 L10.748,9.947 L10.613,9.851 L10.524,9.767 L10.469,9.696 L10.435,9.627 L10.411,9.538 L10.4,9.4 L10.402,9.337 L10.409,9.278 L10.42,9.223 L10.435,9.171 L10.453,9.121 L10.475,9.073 L10.502,9.025 L10.534,8.978 L10.57,8.93 L10.613,8.883 L10.661,8.837 L10.716,8.791 L10.778,8.746 L10.846,8.702 L10.922,8.66 L11.004,8.621 L11.094,8.584 L11.19,8.551 L11.293,8.522 L11.402,8.497 L11.518,8.477 L11.64,8.462 L11.767,8.453 L11.9,8.45 L11.995,8.452 L12.089,8.457 L12.18,8.466 L12.271,8.478 L12.359,8.494 L12.445,8.513 L12.53,8.535 L12.613,8.56 L12.694,8.588 L12.773,8.62 L12.85,8.654 L12.925,8.692 L12.998,8.732 L13.069,8.776 L13.138,8.822 L13.205,8.872 L13.27,8.925 L13.333,8.981 L13.394,9.04 L13.454,9.102 L13.511,9.168 L13.566,9.237 L13.619,9.31 L13.67,9.387 L13.897,9.646 L14.193,9.822 L14.529,9.898 L14.871,9.866 L15.187,9.73 L15.446,9.503 L15.622,9.207 L15.698,8.871 L15.666,8.529Z" />
</group>
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

@@ -125,4 +125,28 @@ class WatchdogReceiverTest
assertThat(decide(expectedProfileSet = true, autoExpected = true, processAlive = false, wifiPresent = false))
.isEqualTo(Action.RESTART)
}
@Test
fun liveProcessRetryingOrWhitelistedBacksOff()
{
/* retrying/connecting: the service tracks the connection */
val retrying = decide(expectedProfileSet = true, serviceTracking = true, processAlive = true)
assertThat(retrying).isEqualTo(Action.NONE)
assertThat(WatchdogReceiver.isAliveSteady(retrying, true, false)).isTrue()
/* whitelist blocks the server while the process is alive */
val blocked = decide(expectedProfileSet = true, whitelistBlocks = true, processAlive = true)
assertThat(WatchdogReceiver.isAliveSteady(blocked, true, false)).isTrue()
}
@Test
fun deadProcessOrRestartNeverBacksOff()
{
val restart = decide(expectedProfileSet = true, processAlive = false)
assertThat(WatchdogReceiver.isAliveSteady(restart, false, false)).isFalse()
/* whitelist blocks, but no live process: keep the fast cadence */
val blocked = decide(expectedProfileSet = true, whitelistBlocks = true, processAlive = false)
assertThat(WatchdogReceiver.isAliveSteady(blocked, false, false)).isFalse()
/* parked sessions use their own backoff */
assertThat(WatchdogReceiver.isAliveSteady(Action.NONE, true, true)).isFalse()
}
}
@@ -230,4 +230,16 @@ class HealthCheckManagerTest
assertThat(ConnectivityProbe.parsePingRtt("time<1 ms")).isEqualTo(1L)
assertThat(ConnectivityProbe.parsePingRtt("no time here")).isNull()
}
@Test
fun serverIsOnlyPingedWhenItCanMatter()
{
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.OFF, 0, 3)).isFalse()
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.ALWAYS, 3, 3)).isTrue()
/* WITH_TUNNEL: a working tunnel makes the server result irrelevant */
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 3, 3)).isFalse()
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 1, 3)).isFalse()
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 3)).isTrue()
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 0)).isFalse()
}
}
@@ -290,4 +290,20 @@ class RuIpListManagerTest
assertThat(fit).isNotNull()
assertThat(RuIpListManager.subtractAndSplit(ranges4, fit!!.first, 6000)).isNotNull()
}
/* ---------------- auto check ---------------- */
@Test
fun autoCheckWaitsForTheIntervalAndBacksOffAfterFailures()
{
val day = org.strongswan.android.utils.Constants.RUSPLIT_CHECK_INTERVAL_MS
val now = 10 * day
assertThat(RuIpListManager.isAutoCheckDue(now, now - day / 2, 0L)).isFalse()
assertThat(RuIpListManager.isAutoCheckDue(now, now - day, 0L)).isTrue()
/* a recent failure blocks retries on every cold start */
assertThat(RuIpListManager.isAutoCheckDue(now, 0L, now - 60_000)).isFalse()
assertThat(RuIpListManager.isAutoCheckDue(now, 0L, now - RuIpListManager.FAIL_RETRY_MS)).isTrue()
/* a failure stamped in the future (clock change) never blocks */
assertThat(RuIpListManager.isAutoCheckDue(now, 0L, now + 60_000)).isTrue()
}
}