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
|
// 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.
|
// 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 {
|
fun versionCodeOf(semver: String): Int {
|
||||||
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
|
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 } }
|
.mapNotNull { id -> doc.networks.firstOrNull { it.id == id } }
|
||||||
.joinToString(", ") { it.iface?.takeIf { s -> s.isNotBlank() } ?: it.transport.name.lowercase() }
|
.joinToString(", ") { it.iface?.takeIf { s -> s.isNotBlank() } ?: it.transport.name.lowercase() }
|
||||||
.ifBlank { "the networks beneath it" }
|
.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))) {
|
Card(colors = CardDefaults.cardColors(containerColor = Color(0xFF3A2E12))) {
|
||||||
Column(Modifier.fillMaxWidth().padding(12.dp)) {
|
Column(Modifier.fillMaxWidth().padding(12.dp)) {
|
||||||
Text("Measured through a VPN", color = Color(0xFFFFD08A),
|
Text(headline, color = Color(0xFFFFD08A), fontWeight = FontWeight.SemiBold)
|
||||||
fontWeight = FontWeight.SemiBold)
|
Text(body, fontSize = 12.sp, color = Color(0xFFFFD08A))
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
// What will this run be prevented from measuring? Decided up front, from one throwaway
|
// 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
|
// 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`).
|
// 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(
|
val probes: List<Probe> = listOf(
|
||||||
LinkSnapshotProbe(entries),
|
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
|
// 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).
|
// the bar reflects the whole run. Estimates prefer what THIS device measured on recent
|
||||||
val shizukuEstimateMs = 8_000L
|
// 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
|
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,
|
state = state.copy(stepsDone = 0, stepsTotal = totalSteps,
|
||||||
etaSeconds = ((remainingMs + 999) / 1000).toInt())
|
etaSeconds = ((remainingMs + 999) / 1000).toInt())
|
||||||
|
|
||||||
@@ -448,12 +451,14 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
tests.add(result); collected.add(result)
|
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
|
// 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.
|
// 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)
|
step("link.ip_monitor (shizuku)", done = probes.size, total = totalSteps, etaMs = shizukuEstimateMs)
|
||||||
|
val shizukuT0 = System.nanoTime()
|
||||||
val shizukuTests = try {
|
val shizukuTests = try {
|
||||||
ShizukuProbe().run(ctx, ids::uuid, ids::monoNs)
|
ShizukuProbe().run(ctx, ids::uuid, ids::monoNs)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
@@ -463,6 +468,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
status = TestStatus.FAILED, error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
|
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)
|
tests.addAll(shizukuTests); collected.addAll(shizukuTests)
|
||||||
// "Shizuku tier ran" is the battery's verdict — the derived tests can be PARTIAL on a
|
// "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).
|
// 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
|
val blocked = runConstraints.unmeasuredNetworks
|
||||||
.joinToString(", ") { id -> ifaceOf(networks, id) }
|
.joinToString(", ") { id -> ifaceOf(networks, id) }
|
||||||
.ifBlank { "the underlying networks" }
|
.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(
|
out.add(
|
||||||
Finding(
|
Finding(
|
||||||
id = ids.uuid(),
|
id = ids.uuid(),
|
||||||
@@ -579,12 +611,8 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
category = FindingRegistry.MEASUREMENT_VPN_CONSTRAINED.category,
|
category = FindingRegistry.MEASUREMENT_VPN_CONSTRAINED.category,
|
||||||
severity = FindingRegistry.MEASUREMENT_VPN_CONSTRAINED.severity,
|
severity = FindingRegistry.MEASUREMENT_VPN_CONSTRAINED.severity,
|
||||||
confidence = Confidence.HIGH,
|
confidence = Confidence.HIGH,
|
||||||
title = "A VPN is active — $blocked could not be measured",
|
title = title,
|
||||||
description = "Android refuses to let apps send on the networks beneath an " +
|
description = description,
|
||||||
"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.",
|
|
||||||
evidenceRefs = linkEvidence,
|
evidenceRefs = linkEvidence,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -905,4 +933,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
etaSeconds = if (etaMs >= 0) ((etaMs + 999) / 1000).toInt() else state.etaSeconds,
|
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,
|
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 ----------------------------------------------------------------------
|
// ---- upload ----------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -215,5 +244,6 @@ class Settings(context: Context) {
|
|||||||
const val PENDING_STATE = "pending_auth_state"
|
const val PENDING_STATE = "pending_auth_state"
|
||||||
const val ACCOUNT_NAME = "account_name"
|
const val ACCOUNT_NAME = "account_name"
|
||||||
const val ACCOUNT_ID = "account_id"
|
const val ACCOUNT_ID = "account_id"
|
||||||
|
const val DURATION_PREFIX = "duration_ms."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
|
|
||||||
package app.echo_lot.probe
|
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.Constraints
|
||||||
import app.echo_lot.measurement.Transport
|
import app.echo_lot.measurement.Transport
|
||||||
import java.net.DatagramSocket
|
import java.net.DatagramSocket
|
||||||
@@ -20,22 +25,44 @@ import java.net.DatagramSocket
|
|||||||
*/
|
*/
|
||||||
object ConstraintDetector {
|
object ConstraintDetector {
|
||||||
|
|
||||||
fun detect(entries: List<NetworkInventory.Entry>): Constraints {
|
fun detect(ctx: Context, entries: List<NetworkInventory.Entry>): Constraints {
|
||||||
val vpnActive = entries.any { it.model.transport == Transport.VPN }
|
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
val unmeasured = ArrayList<String>()
|
// "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) {
|
for (e in entries) {
|
||||||
// The tunnel itself stays bindable — it is the underlying networks the OS walls off.
|
// The tunnel itself stays bindable — it is the underlying networks the OS walls off.
|
||||||
if (e.model.transport == Transport.VPN) continue
|
if (e.model.transport == Transport.VPN) continue
|
||||||
val bindable = runCatching {
|
val err = try {
|
||||||
DatagramSocket().use { s -> e.handle.bindSocket(s) }
|
DatagramSocket().use { s -> e.handle.bindSocket(s) }
|
||||||
true
|
null
|
||||||
}.getOrDefault(false)
|
} catch (t: Throwable) {
|
||||||
if (!bindable) unmeasured.add(e.model.id)
|
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(
|
return Constraints(
|
||||||
vpnActive = vpnActive,
|
vpnActive = vpnActive,
|
||||||
perNetworkBlocked = unmeasured.isNotEmpty(),
|
perNetworkBlocked = refused.isNotEmpty(),
|
||||||
unmeasuredNetworks = unmeasured,
|
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