android: whitelist - working recheck backoff, fewer probe rounds

- scheduled rechecks are forced: the 15/30 s steps were below FRESH_MS,
  so the unforced recheck returned early and the backoff chain died
- signal-strength-only capability changes are no longer network events
  (each one reset the backoff and launched a check)
- tunnel state transitions force a new round at most every 15 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-10 09:17:36 +07:00
1 parent 514b15b1ca
commit b0f353af90
1 file changed
+37 -4
@@ -44,6 +44,7 @@ import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import org.strongswan.android.logic.VpnStateService import org.strongswan.android.logic.VpnStateService
import java.util.concurrent.ConcurrentHashMap
/** Whether the ISP currently blocks direct access to foreign hosts. */ /** Whether the ISP currently blocks direct access to foreign hosts. */
enum class WhiteListStatus enum class WhiteListStatus
@@ -177,6 +178,9 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
private val _state = MutableStateFlow(WhiteListState()) private val _state = MutableStateFlow(WhiteListState())
val state: StateFlow<WhiteListState> = _state 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 */ /* event trigger: the underlying network changed, the ISP may classify differently now */
private val networkCallback = object : ConnectivityManager.NetworkCallback() 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) override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities)
{ { /* fired on every signal-strength change too: only a change that
onNetworkEvent() * 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) override fun onLost(network: Network)
{ {
lastCapabilities.remove(network)
onNetworkEvent() onNetworkEvent()
} }
} }
@@ -238,6 +249,7 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
recheckJob?.cancel() recheckJob?.cancel()
recheckJob = null recheckJob = null
unregisterNetworkCallback() unregisterNetworkCallback()
lastCapabilities.clear()
service?.unregisterListener(this) service?.unregisterListener(this)
try try
{ {
@@ -364,7 +376,10 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
return return
} }
backoffAttempt = 0 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() private fun onNetworkEvent()
@@ -453,7 +468,9 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
recheckJob?.cancel() recheckJob?.cancel()
recheckJob = scope.launch { recheckJob = scope.launch {
delay(delayMs) 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. */ /** Cached results younger than this are not re-probed. */
private const val FRESH_MS = 60_000L 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 * A cached ACTIVE status stops suppressing reconnects once no recheck
* has confirmed it for this long (fail open). Kept above the backoff * has confirmed it for this long (fail open). Kept above the backoff