app: learn probe durations per device; stop claiming VPN after teardown
The ETA table was fixed at compile time, but real durations are a property of this phone and the network it stands in - ICMPv6 answers in milliseconds where IPv6 works and waits out its timeout where it does not. Estimates now prefer an EMA (70/30) of what this device actually measured, seeded by the old constants on first run. VPN detection had two lies in it, both found on hardware: a disconnected tunnel lingers in allNetworks while tearing down, so 'VPN active' is now judged from the ACTIVE network only; and any bind failure counted as 'per-network blocked', so a network dying mid-run flipped a healthy run to INCONCLUSIVE - now only a genuine EPERM refusal counts. Banner and finding split three ways (VPN + blocked / blocked only / VPN only) so the app never names a VPN the user just turned off. App version 0.2.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3214cc877a
commit
c5f3c2e7af
@@ -3,6 +3,11 @@
|
||||
|
||||
package app.echo_lot.probe
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.system.ErrnoException
|
||||
import android.system.OsConstants
|
||||
import app.echo_lot.measurement.Constraints
|
||||
import app.echo_lot.measurement.Transport
|
||||
import java.net.DatagramSocket
|
||||
@@ -20,22 +25,44 @@ import java.net.DatagramSocket
|
||||
*/
|
||||
object ConstraintDetector {
|
||||
|
||||
fun detect(entries: List<NetworkInventory.Entry>): Constraints {
|
||||
val vpnActive = entries.any { it.model.transport == Transport.VPN }
|
||||
val unmeasured = ArrayList<String>()
|
||||
fun detect(ctx: Context, entries: List<NetworkInventory.Entry>): Constraints {
|
||||
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
// "A VPN holds the default route" is judged from the ACTIVE network, not from a VPN
|
||||
// network merely existing in the list: a tunnel that was just disconnected lingers in
|
||||
// allNetworks while it tears down, and counting it kept the app claiming "measured
|
||||
// through a VPN" after the VPN was gone.
|
||||
val vpnActive = runCatching {
|
||||
cm.getNetworkCapabilities(cm.activeNetwork)
|
||||
?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
|
||||
}.getOrDefault(false)
|
||||
|
||||
val refused = ArrayList<String>()
|
||||
for (e in entries) {
|
||||
// The tunnel itself stays bindable — it is the underlying networks the OS walls off.
|
||||
if (e.model.transport == Transport.VPN) continue
|
||||
val bindable = runCatching {
|
||||
val err = try {
|
||||
DatagramSocket().use { s -> e.handle.bindSocket(s) }
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
if (!bindable) unmeasured.add(e.model.id)
|
||||
null
|
||||
} catch (t: Throwable) {
|
||||
t
|
||||
}
|
||||
// Only the OS *refusing* counts as blocked (EPERM: the VPN wall). A network that
|
||||
// happens to die mid-snapshot fails its bind too, but with a different errno, and
|
||||
// calling that "per-network probing blocked" would flip a whole healthy run to
|
||||
// INCONCLUSIVE over one network going away — the probes already record
|
||||
// attempted:false for that case.
|
||||
if (err != null && isPermissionRefusal(err)) refused.add(e.model.id)
|
||||
}
|
||||
return Constraints(
|
||||
vpnActive = vpnActive,
|
||||
perNetworkBlocked = unmeasured.isNotEmpty(),
|
||||
unmeasuredNetworks = unmeasured,
|
||||
perNetworkBlocked = refused.isNotEmpty(),
|
||||
unmeasuredNetworks = refused,
|
||||
)
|
||||
}
|
||||
|
||||
private fun isPermissionRefusal(t: Throwable): Boolean =
|
||||
generateSequence(t) { it.cause }.any {
|
||||
(it is ErrnoException && it.errno == OsConstants.EPERM) ||
|
||||
(it.message?.contains("EPERM") == true)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user