diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/WhiteListManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/WhiteListManager.kt index 22f05fecf..1b339e544 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/WhiteListManager.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/WhiteListManager.kt @@ -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 = _state + /* last classification-relevant capabilities per network (see capabilityKey) */ + private val lastCapabilities = ConcurrentHashMap() + /* 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