android: VpnStateService - single retry countdown, isolated listeners

- remove pending RETRY_MSG on reset/set: a Retry tap during a backoff
  started a second countdown chain (double-speed ticks, two reconnects)
- notify listeners over a copy with per-listener error handling
- disconnect() tolerates the VPN service not running

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-10 09:09:40 +07:00
1 parent 358dcb94b2
commit f2e4ffe860
1 file changed
+39 -14
@@ -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;
}