app: learn probe durations per device; stop claiming VPN after teardown
server-release / image (push) Successful in 15s
server-release / release (push) Successful in 31s

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:
mrambossek
2026-08-02 13:41:10 +02:00
co-authored by Claude Opus 5
parent 3214cc877a
commit c5f3c2e7af
5 changed files with 131 additions and 29 deletions
@@ -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)
}
}