Compare commits
No files matched your search
@@ -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>
|
||||
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
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()
|
||||
}
|
||||
}
|
||||