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
+1 -1
View File
@@ -15,7 +15,7 @@ plugins {
//
// major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing
// digit) without disturbing the mapping, and stays inside the 2_100_000_000 ceiling until major 2100.
val appVersionName = "0.2.2"
val appVersionName = "0.2.3"
fun versionCodeOf(semver: String): Int {
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
@@ -422,16 +422,28 @@ private fun Results(doc: MeasurementDocument) {
.mapNotNull { id -> doc.networks.firstOrNull { it.id == id } }
.joinToString(", ") { it.iface?.takeIf { s -> s.isNotBlank() } ?: it.transport.name.lowercase() }
.ifBlank { "the networks beneath it" }
// Same three-way split as the finding: saying "VPN" when the user just disconnected
// theirs (the wall lingers during teardown) reads as the app being wrong, not the OS.
val (headline, body) = when {
constraints.vpnActive && constraints.perNetworkBlocked ->
"Measured through a VPN" to
("Android does not let apps send on the networks beneath an active VPN, so " +
"$blocked could not be measured — these results describe the tunnel. " +
"Disconnect the VPN and run again to measure the networks themselves.")
constraints.perNetworkBlocked ->
"Some networks could not be measured" to
("Android refused sends on $blocked — the restriction a VPN leaves in place " +
"while it tears down. These networks went unmeasured; wait a few " +
"seconds and run again.")
else ->
"A VPN holds the default route" to
("Default-route results describe the tunnel; per-network measurements " +
"reached the underlying networks.")
}
Card(colors = CardDefaults.cardColors(containerColor = Color(0xFF3A2E12))) {
Column(Modifier.fillMaxWidth().padding(12.dp)) {
Text("Measured through a VPN", color = Color(0xFFFFD08A),
fontWeight = FontWeight.SemiBold)
Text(
"Android does not let apps send on the networks beneath an active VPN, so " +
"$blocked could not be measured — these results describe the tunnel. " +
"Disconnect the VPN and run again to measure the networks themselves.",
fontSize = 12.sp, color = Color(0xFFFFD08A),
)
Text(headline, color = Color(0xFFFFD08A), fontWeight = FontWeight.SemiBold)
Text(body, fontSize = 12.sp, color = Color(0xFFFFD08A))
}
}
}
@@ -396,7 +396,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
// What will this run be prevented from measuring? Decided up front, from one throwaway
// bind per network, so the document can say so instead of leaving it to be inferred from
// per-test `attempted: false` breadcrumbs (measurement-schema.md §3 `constraints`).
runConstraints = app.echo_lot.probe.ConstraintDetector.detect(entries)
runConstraints = app.echo_lot.probe.ConstraintDetector.detect(ctx, entries)
val probes: List<Probe> = listOf(
LinkSnapshotProbe(entries),
@@ -425,10 +425,13 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
)
// Plan the run first: the Shizuku battery is counted alongside the app-tier probes so
// the bar reflects the whole run. Estimates are per-probe (see Probe.estimatedMs).
val shizukuEstimateMs = 8_000L
// the bar reflects the whole run. Estimates prefer what THIS device measured on recent
// runs (Settings EMA); Probe.estimatedMs is only the cold-start seed — a fixed table
// cannot know whether ICMPv6 answers in milliseconds here or waits out its timeout.
fun estimateOf(p: Probe) = settings.learnedDurationMs(p.type) ?: p.estimatedMs
val shizukuEstimateMs = settings.learnedDurationMs(SHIZUKU_DURATION_KEY) ?: 8_000L
val totalSteps = probes.size + 1
var remainingMs = probes.sumOf { it.estimatedMs } + shizukuEstimateMs
var remainingMs = probes.sumOf { estimateOf(it) } + shizukuEstimateMs
state = state.copy(stepsDone = 0, stepsTotal = totalSteps,
etaSeconds = ((remainingMs + 999) / 1000).toInt())
@@ -448,12 +451,14 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
}
)
tests.add(result); collected.add(result)
remainingMs -= p.estimatedMs
settings.recordDurationMs(p.type, (result.endedMonoNs - result.startedMonoNs) / 1_000_000)
remainingMs -= estimateOf(p)
}
// Shizuku shell tier — self-degrades to UNSUPPORTED when Shizuku isn't running. One
// battery, three tests: the raw captures plus the parsed ra_source/arp_watch views.
step("link.ip_monitor (shizuku)", done = probes.size, total = totalSteps, etaMs = shizukuEstimateMs)
val shizukuT0 = System.nanoTime()
val shizukuTests = try {
ShizukuProbe().run(ctx, ids::uuid, ids::monoNs)
} catch (t: Throwable) {
@@ -463,6 +468,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
status = TestStatus.FAILED, error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
))
}
// One key for the whole step: the three tests come out of one shell battery, and the
// bar plans them as one step.
settings.recordDurationMs(SHIZUKU_DURATION_KEY, (System.nanoTime() - shizukuT0) / 1_000_000)
tests.addAll(shizukuTests); collected.addAll(shizukuTests)
// "Shizuku tier ran" is the battery's verdict — the derived tests can be PARTIAL on a
// perfectly healthy shell tier (e.g. an RA-less v4-only link).
@@ -572,6 +580,30 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
val blocked = runConstraints.unmeasuredNetworks
.joinToString(", ") { id -> ifaceOf(networks, id) }
.ifBlank { "the underlying networks" }
// Three distinct situations share this finding code, and naming the wrong one costs
// trust: claiming "a VPN is active" right after the user disconnected theirs is how
// this text was first proven wrong on hardware.
val (title, description) = when {
runConstraints.vpnActive && runConstraints.perNetworkBlocked ->
"A VPN is active — $blocked could not be measured" to
("Android refuses to let apps send on the networks beneath an active " +
"VPN (that is how it prevents traffic leaking around the tunnel), " +
"so every per-network test here measured the tunnel or nothing. " +
"Nothing in this run says anything about $blocked. To measure them, " +
"disconnect the VPN and run again.")
runConstraints.perNetworkBlocked ->
"The OS refused sends on $blocked" to
("Android denied this app permission to send on $blocked (EPERM on " +
"bind). That is the restriction a VPN imposes on the networks " +
"beneath it — a tunnel disconnected moments ago can still leave it " +
"in place while it tears down. Nothing in this run says anything " +
"about $blocked; wait a few seconds and run again.")
else ->
"A VPN holds the default route" to
("Everything using the default route in this run describes the tunnel, " +
"not the network it rides on. Per-network measurements were " +
"permitted and did measure the underlying networks.")
}
out.add(
Finding(
id = ids.uuid(),
@@ -579,12 +611,8 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
category = FindingRegistry.MEASUREMENT_VPN_CONSTRAINED.category,
severity = FindingRegistry.MEASUREMENT_VPN_CONSTRAINED.severity,
confidence = Confidence.HIGH,
title = "A VPN is active — $blocked could not be measured",
description = "Android refuses to let apps send on the networks beneath an " +
"active VPN (that is how it prevents traffic leaking around the tunnel), " +
"so every per-network test here measured the tunnel or nothing. Nothing " +
"in this run says anything about $blocked. To measure them, disconnect " +
"the VPN and run again.",
title = title,
description = description,
evidenceRefs = linkEvidence,
)
)
@@ -905,4 +933,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
etaSeconds = if (etaMs >= 0) ((etaMs + 999) / 1000).toInt() else state.etaSeconds,
)
}
private companion object {
/** Duration-learning key for the Shizuku step, which is three tests but one battery. */
const val SHIZUKU_DURATION_KEY = "shizuku.battery"
}
}
@@ -50,6 +50,35 @@ class Settings(context: Context) {
maxTotalBytes = maxTotalMb.toLong() * 1024 * 1024,
)
// ---- run-duration learning ---------------------------------------------------------
/**
* Learned duration of one test type on THIS device, or null before the first run.
*
* The static Probe.estimatedMs values are only cold-start seeds: real durations depend on
* the phone and the network it stands in (ICMPv6 answers in milliseconds where IPv6 works
* and waits out full timeouts where it does not), so a fixed table is wrong for almost
* everyone almost always. What was measured last time is the only estimate that tracks
* reality.
*/
fun learnedDurationMs(type: String): Long? =
prefs.getLong("$DURATION_PREFIX$type", -1L).takeIf { it > 0 }
/**
* Feeds one measured duration into the estimate — EMA, 70 % old / 30 % new. Heavy enough
* on history that a single odd run (a captive portal stalling DNS) does not whipsaw the
* bar, light enough that a real change (enrolling with a server un-skips three probes)
* converges within a few runs. Recorded whatever the test's status: a probe that skips in
* 2 ms will keep skipping in 2 ms until circumstances change, and then the EMA follows.
*/
fun recordDurationMs(type: String, ms: Long) {
if (ms < 0) return
val key = "$DURATION_PREFIX$type"
val old = prefs.getLong(key, -1L)
val next = if (old <= 0) ms else (old * 7 + ms * 3) / 10
prefs.edit().putLong(key, next).apply()
}
// ---- upload ----------------------------------------------------------------------
/**
@@ -215,5 +244,6 @@ class Settings(context: Context) {
const val PENDING_STATE = "pending_auth_state"
const val ACCOUNT_NAME = "account_name"
const val ACCOUNT_ID = "account_id"
const val DURATION_PREFIX = "duration_ms."
}
}
@@ -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)
}
}