diff --git a/echolot-app/app/build.gradle.kts b/echolot-app/app/build.gradle.kts index d0ccfc9..0e50557 100644 --- a/echolot-app/app/build.gradle.kts +++ b/echolot-app/app/build.gradle.kts @@ -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) diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt index 2fa4b45..36f26ed 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt @@ -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)) } } } diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt index f82004b..b770340 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt @@ -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 = 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" + } } diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt index 4c3b624..fd06b9b 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt @@ -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." } } diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/ConstraintDetector.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/ConstraintDetector.kt index a62c41b..e43b47b 100644 --- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/ConstraintDetector.kt +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/ConstraintDetector.kt @@ -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): Constraints { - val vpnActive = entries.any { it.model.transport == Transport.VPN } - val unmeasured = ArrayList() + fun detect(ctx: Context, entries: List): 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() 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) + } }