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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user