diff --git a/docs/build-status.md b/docs/build-status.md
index 7fa418a..66f7a9b 100644
--- a/docs/build-status.md
+++ b/docs/build-status.md
@@ -1379,6 +1379,26 @@ poisons `/releases/latest` for the string-comparing updater the moment anyone ta
The stale `server-v0.9.2` release (same code lineage, wrong number, created during the confusion)
remains in Gitea but is harmless now that v0.11.3 outranks it as latest.
+## LLDP and CDP are root-tier, and that is a hard boundary (2026-08-02)
+
+Asked for alongside SSDP in long mode; they belong to a different tier and no amount of app-side
+cleverness moves them. LLDP is an EtherType `0x88CC` frame to `01:80:C2:00:00:0E`; CDP is an
+LLC/SNAP frame to `01:00:0C:CC:CC:CC`. Neither is IP, so neither is ever delivered to a socket an
+app can open — receiving them needs `AF_PACKET` with `CAP_NET_RAW`, which is root. Shizuku does
+not bridge this either: the ADB shell user (uid 2000) has no `CAP_NET_RAW`, and stock devices do
+not ship `tcpdump`. Android's unprivileged ICMP sockets are what make `icmp.ping4` work without
+root; there is no equivalent back door for raw L2 receive.
+
+Worth building in the root module when it lands, because the payoff is large: LLDP names the
+switch, the port and the VLAN a device is attached to, which is the best available answer to
+"where in this building am I actually plugged in", and CDP does the same on Cisco gear. Until
+then they are recorded as absent capabilities rather than left to look unimplemented.
+
+What IS reachable at app tier, and what long mode now listens for instead: SSDP (passive NOTIFY
+plus periodic M-SEARCH), LLMNR, NetBIOS-NS and WS-Discovery — all IP multicast/broadcast, all
+sockets an app may open. The security reading matters as much as the inventory: LLMNR and
+NetBIOS-NS being live on a segment is a finding in itself, since both are trivially spoofable.
+
## Design note: what BLE between two devices is actually for (2026-08-02, not built)
Two or more phones running Echolot, talking over Bluetooth LE. The schema already anticipates
diff --git a/docs/findings-registry.md b/docs/findings-registry.md
index d2382c0..0d77b2c 100644
--- a/docs/findings-registry.md
+++ b/docs/findings-registry.md
@@ -50,6 +50,13 @@ because it looks authoritative.
| `connectivity.downstream_reorder` | low | Downstream packets arrive in a different order than they were sent. | — |
| `connectivity.captive_portal` | medium | A captive portal is intercepting connectivity checks. | — |
| `connectivity.no_internet` | high | Android's own connectivity checks fail on this network. | — |
+| `connectivity.link_flapping` | medium | A network dropped and came back one or more times during the run. | A momentary probe failure: the drop was watched happening, not inferred from silence. |
+
+`connectivity.link_flapping` is only reachable from a **long run** (`run.mode: "long"`,
+measurement-schema §3). It is derived from `networks[].changes[]` rather than from any test's
+evidence, because no one-shot probe can produce it: the probes before and after a four-second drop
+both succeed. The emitter escalates to *high* from three completed drop-and-return cycles, and
+requires the cycle to complete — a network switched off partway through a run is not flapping.
### mtu
diff --git a/docs/measurement-schema.md b/docs/measurement-schema.md
index 0fbe79c..2c02bff 100644
--- a/docs/measurement-schema.md
+++ b/docs/measurement-schema.md
@@ -38,6 +38,7 @@ Export encoding: UTF-8 JSON, gzip for files (`.echolot.json.gz`), share intent u
{
"id": "0198c5f2-...-uuidv7",
"trigger": "manual | scheduled | monitor | peer",
+ "mode": "short | long",
"started_at": "2026-07-29T14:03:21.114Z",
"ended_at": "2026-07-29T14:07:44.902Z",
"clock": {
@@ -63,6 +64,27 @@ Export encoding: UTF-8 JSON, gzip for files (`.echolot.json.gz`), share intent u
`tiers` records what was *available*; each test records what it *used*.
+`mode` records how long the run watched, and it exists because **it changes what a reader may
+conclude from absence**. A `short` run is a sequence of one-shot probes — each looks at the network
+for a second or two and moves on — which characterises the network's *configuration* well and is
+structurally blind to anything intermittent. A `long` run starts continuous listeners at t=0, runs
+the same battery beside them, and keeps sampling until its window closes; the window's length is
+recorded in the `params` of the tests the listeners produce, not here.
+
+The consequence is asymmetric and matters more than the field looks. A finding is worth the same in
+either mode: a drop that was observed, was observed. Silence is not. "No link changes were seen" is
+evidence of a stable link after five minutes of watching and is evidence of nothing at all after a
+thirty-second run, in which a link could drop and return between two consecutive probes without
+leaving a mark anywhere in the document. Consumers — a diff between two runs, a dashboard counting
+how often a fault occurs, a person reading one report — must therefore not treat the absence of a
+time-dependent finding in a `short` run as its refutation, and must not compare the two modes as if
+they had asked the same question. `connectivity.link_flapping` is the first finding that only a
+`long` run can reach; `networks[].changes[]` (§4) is likewise populated only by a long run's
+listener, and an empty `changes[]` in a short run means "not watched", never "nothing happened".
+
+Absent `mode` means `short`: it was added after the first documents were written, and every one of
+them was a battery of one-shot probes.
+
`constraints` records what was *prevented*. A constrained run is neither a failed run nor a normal
one, and the distinction has to survive into the data: a run taken through a VPN has the same shape
and the same green verdict as a clean run of a healthy network, so without this a reader — or a
diff --git a/echolot-app/app/src/main/AndroidManifest.xml b/echolot-app/app/src/main/AndroidManifest.xml
index 87da644..0030503 100644
--- a/echolot-app/app/src/main/AndroidManifest.xml
+++ b/echolot-app/app/src/main/AndroidManifest.xml
@@ -10,6 +10,17 @@
+
+
+
+
+
+
+
+
Unit,
+) {
+ private val nsd = ctx.getSystemService(NsdManager::class.java)
+
+ /** Instances already resolved, by service name — the re-arm guard described above. */
+ private val resolved = HashSet()
+
+ /** Last endpoint reported, so the heartbeat re-posts from cache instead of re-resolving. */
+ @Volatile var lastEndpoint: Endpoint? = null
+ private set
+
+ data class Endpoint(val host: String, val port: Int, val serviceName: String)
+
+ private var listener: NsdManager.DiscoveryListener? = null
+
+ fun start() {
+ if (nsd == null) {
+ onEvent("mDNS unavailable on this device")
+ return
+ }
+ if (listener != null) return
+ val l = object : NsdManager.DiscoveryListener {
+ override fun onStartDiscoveryFailed(type: String?, code: Int) {
+ onEvent("discovery failed to start (code $code)")
+ }
+ override fun onStopDiscoveryFailed(type: String?, code: Int) {}
+ override fun onDiscoveryStarted(type: String?) {
+ onEvent("watching for adbd on this network")
+ }
+ override fun onDiscoveryStopped(type: String?) {}
+
+ override fun onServiceFound(info: NsdServiceInfo?) {
+ val name = info?.serviceName ?: return
+ // The guard: one resolve per instance, ever. adbd re-arms on every resolve.
+ if (!resolved.add(name)) return
+ resolve(info)
+ }
+
+ override fun onServiceLost(info: NsdServiceInfo?) {
+ // Rotation: the old instance is gone, so allow the replacement to be resolved.
+ info?.serviceName?.let { resolved.remove(it) }
+ }
+ }
+ listener = l
+ runCatching { nsd.discoverServices(ADB_SERVICE, NsdManager.PROTOCOL_DNS_SD, l) }
+ .onFailure { onEvent("could not start discovery: ${it.message}") }
+ }
+
+ fun stop() {
+ listener?.let { l -> runCatching { nsd?.stopServiceDiscovery(l) } }
+ listener = null
+ resolved.clear()
+ }
+
+ @Suppress("DEPRECATION") // the callback-based resolve is the one that exists across our minSdk
+ private fun resolve(info: NsdServiceInfo) {
+ val cb = object : NsdManager.ResolveListener {
+ override fun onResolveFailed(i: NsdServiceInfo?, code: Int) {
+ // Let a failed instance be retried: the guard exists to stop *successful*
+ // re-resolution, not to give up on a transient failure.
+ i?.serviceName?.let { resolved.remove(it) }
+ onEvent("resolve failed (code $code)")
+ }
+
+ override fun onServiceResolved(i: NsdServiceInfo?) {
+ val host = i?.host?.hostAddress ?: return
+ // adbd advertises on every address it listens on, including link-local v6. The
+ // reachable one from a developer's subnet is the routable v4 address, and it is
+ // also the only one worth relaying — a link-local address means nothing off-link.
+ if (i.host !is Inet4Address) return
+ // Only this device's own advertisement: on a shared network several phones may
+ // have wireless debugging on, and relaying a neighbour's port would send a
+ // developer to the wrong device.
+ if (host != localIp()) return
+ lastEndpoint = Endpoint(host, i.port, i.serviceName ?: "adb")
+ onEvent("found adbd at $host:${i.port}")
+ }
+ }
+ runCatching { nsd?.resolveService(info, cb) }
+ .onFailure {
+ resolved.remove(info.serviceName)
+ onEvent("resolve threw: ${it.message}")
+ }
+ }
+
+ /** This device's own IPv4 address on the wifi it is relaying from. */
+ private fun localIp(): String? = runCatching {
+ val wifi = ctx.getSystemService(WifiManager::class.java) ?: return null
+ @Suppress("DEPRECATION")
+ val ip = wifi.connectionInfo.ipAddress
+ if (ip == 0) return null
+ @Suppress("DEPRECATION")
+ String.format(
+ "%d.%d.%d.%d",
+ ip and 0xff, ip shr 8 and 0xff, ip shr 16 and 0xff, ip shr 24 and 0xff,
+ )
+ }.getOrNull()
+
+ private companion object {
+ const val ADB_SERVICE = "_adb-tls-connect._tcp"
+ }
+}
diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/AdbRelayService.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/AdbRelayService.kt
new file mode 100644
index 0000000..5f2584d
--- /dev/null
+++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/AdbRelayService.kt
@@ -0,0 +1,156 @@
+// SPDX-FileCopyrightText: 2026 Echolot contributors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app.echo_lot.app
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import app.echo_lot.protocol.ControlClient
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Keeps [AdbRelay] running and posts what it finds to the enrolled server.
+ *
+ * A foreground service because the whole point is to be useful while nobody is looking at the
+ * tablet: a background process is frozen within minutes of the screen going off, and a relay that
+ * stops relaying the moment it is left alone would be worse than none — it would be trusted right
+ * up until the moment it went quiet.
+ *
+ * The heartbeat re-posts the CACHED endpoint and never re-resolves. Resolving adbd's advertisement
+ * makes adbd re-arm its connection and raise a "wireless debugging connected" notification, so a
+ * heartbeat that re-resolved would turn a background convenience into a stream of notifications on
+ * a device sitting on a shelf. Rotation is still caught, because losing the old advertisement
+ * clears the resolve guard in [AdbRelay] and the replacement is resolved once, within seconds.
+ */
+class AdbRelayService : Service() {
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private var relay: AdbRelay? = null
+
+ @Volatile private var status: String = "starting"
+ @Volatile private var lastPosted: String? = null
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ startForeground(NOTIFICATION_ID, notification("starting"))
+ val settings = Settings(this)
+ val r = AdbRelay(this) { msg ->
+ status = msg
+ notify(msg)
+ }
+ relay = r
+ r.start()
+
+ scope.launch {
+ while (true) {
+ val ep = r.lastEndpoint
+ if (ep != null) {
+ val wire = "${ep.host}:${ep.port}"
+ // Re-post on a heartbeat even when unchanged: the server stamps a received-at
+ // time, and a developer needs to tell "this endpoint is current" from "this
+ // endpoint is what the tablet saw before it went out of range".
+ val result = post(settings, ep)
+ status = if (result == null) {
+ lastPosted = wire
+ "reported $wire"
+ } else {
+ "found $wire, but reporting failed: $result"
+ }
+ notify(status)
+ }
+ delay(HEARTBEAT_MS)
+ }
+ }
+ }
+
+ /** Posts one endpoint; returns null on success or a short reason on failure. */
+ private suspend fun post(settings: Settings, ep: AdbRelay.Endpoint): String? =
+ withContext(Dispatchers.IO) {
+ if (!settings.serverConfigured) return@withContext "no server enrolled"
+ runCatching {
+ ControlClient(settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER)
+ .reportAdbEndpoint(
+ credential = settings.serverCredential,
+ host = ep.host,
+ port = ep.port,
+ deviceName = Build.MODEL,
+ note = "echolot relay",
+ )
+ null
+ }.getOrElse { it.message?.take(120) ?: it.javaClass.simpleName }
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ // Restarted by the system if it is killed: a relay that quietly does not come back after
+ // a low-memory kill is the failure mode this exists to avoid.
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ relay?.stop()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ private fun notify(text: String) {
+ val nm = getSystemService(NotificationManager::class.java)
+ nm?.notify(NOTIFICATION_ID, notification(text))
+ }
+
+ private fun notification(text: String): Notification {
+ val nm = getSystemService(NotificationManager::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ // LOW: this is a status line for a tool the user deliberately started, not news.
+ val ch = NotificationChannel(CHANNEL, "adb relay", NotificationManager.IMPORTANCE_LOW)
+ ch.description = "Reports this device's wireless-debug endpoint to the Echolot server"
+ nm?.createNotificationChannel(ch)
+ }
+ val open = android.app.PendingIntent.getActivity(
+ this, 0, Intent(this, MainActivity::class.java),
+ android.app.PendingIntent.FLAG_IMMUTABLE,
+ )
+ return Notification.Builder(this, CHANNEL)
+ .setContentTitle("Echolot adb relay")
+ .setContentText(text)
+ .setSmallIcon(android.R.drawable.stat_sys_download_done)
+ .setOngoing(true)
+ .setContentIntent(open)
+ .build()
+ }
+
+ companion object {
+ private const val CHANNEL = "adb-relay"
+ private const val NOTIFICATION_ID = 4711
+
+ /**
+ * Two minutes. The port rotates on roughly that cadence, and the freshness of the answer
+ * is the whole product — but this only re-posts a cached value, so it costs one small
+ * HTTPS request and never touches mDNS.
+ */
+ private const val HEARTBEAT_MS = 120_000L
+
+ fun start(ctx: Context) {
+ val i = Intent(ctx, AdbRelayService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i)
+ else ctx.startService(i)
+ }
+
+ fun stop(ctx: Context) {
+ ctx.stopService(Intent(ctx, AdbRelayService::class.java))
+ }
+ }
+}
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 23d68a2..cec71d5 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
@@ -63,6 +63,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestRuntimePermissions()
+ resumeRelayIfEnabled()
liveIntent.value = intent
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
@@ -149,6 +150,16 @@ class MainActivity : ComponentActivity() {
screen = Screen.SETTINGS
}
}
+ // A long run samples for minutes, and Android starts throttling timers and
+ // network access within moments of the screen going off — so a run left to
+ // itself would measure the device's power management rather than the network,
+ // and would do it silently. Held only while a run is in flight, and released
+ // on the way out.
+ val view = androidx.compose.ui.platform.LocalView.current
+ androidx.compose.runtime.DisposableEffect(vm.state.running) {
+ view.keepScreenOn = vm.state.running
+ onDispose { view.keepScreenOn = false }
+ }
// Shizuku can be started, stopped or authorised in its own app, where nothing
// calls back into this process. Asking again each time this screen comes
// forward is what makes the banner right after the user has been away to fix
@@ -163,8 +174,13 @@ class MainActivity : ComponentActivity() {
lifecycleOwner.lifecycle.addObserver(obs)
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
}
+ // Autorun stays a quick run: it is an unattended batch job driven over adb, and
+ // an automation that silently held the device for five minutes would be a
+ // surprise. `--es mode long` asks for the other one explicitly.
+ val autorunMode =
+ if (intent?.getStringExtra("mode") == "long") RunMode.LONG else RunMode.SHORT
androidx.compose.runtime.LaunchedEffect(autorun) {
- if (autorun) vm.run(devUpload = true)
+ if (autorun) vm.run(autorunMode, devUpload = true)
}
// In autorun the app is a batch job: once the run is done AND the upload
// succeeded, show the result briefly, then close so the device is left as it
@@ -215,6 +231,10 @@ class MainActivity : ComponentActivity() {
onEnroll = vm::enroll,
serverStatus = vm.state.archiveStatus,
enrollStatus = vm.state.enrollStatus,
+ onRelayChange = { on ->
+ if (on) AdbRelayService.start(this@MainActivity)
+ else AdbRelayService.stop(this@MainActivity)
+ },
onBack = { screen = Screen.RUN },
)
Screen.HISTORY -> HistoryScreen(
@@ -238,7 +258,8 @@ class MainActivity : ComponentActivity() {
)
Screen.RUN -> EcholotScreen(
state = vm.state,
- onRun = { vm.run() },
+ longMinutes = vm.settings.longRunMinutes,
+ onRun = { mode -> vm.run(mode) },
onCancel = vm::cancel,
onDeveloperOptions = {
runCatching {
@@ -275,11 +296,29 @@ class MainActivity : ComponentActivity() {
private fun requestRuntimePermissions() {
val perms = mutableListOf(Manifest.permission.ACCESS_FINE_LOCATION)
+ // Only so the relay's ongoing notification is visible. The service runs either way, but a
+ // foreground service the user cannot see is worse than one they can dismiss knowingly.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ perms.add(Manifest.permission.POST_NOTIFICATIONS)
+ }
val missing = perms.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isNotEmpty()) permissionLauncher.launch(missing.toTypedArray())
}
+
+ /**
+ * Restarts the relay if it was left on.
+ *
+ * A relay that silently fails to come back after a reboot or a process kill is worse than one
+ * that was never enabled: it is trusted right up to the moment it goes quiet, and the symptom
+ * is a stale endpoint that sends a developer to a port nothing is listening on.
+ */
+ private fun resumeRelayIfEnabled() {
+ if (Settings(this).adbRelayEnabled) {
+ runCatching { AdbRelayService.start(this) }
+ }
+ }
}
private fun verdictColor(v: Verdict): Color = when (v) {
@@ -299,7 +338,8 @@ private fun statusColor(s: TestStatus): Color = when (s) {
@Composable
private fun EcholotScreen(
state: UiState,
- onRun: () -> Unit,
+ longMinutes: Int,
+ onRun: (RunMode) -> Unit,
onCancel: () -> Unit,
onShizukuAction: () -> Unit,
onDeveloperOptions: () -> Unit,
@@ -363,8 +403,36 @@ private fun EcholotScreen(
}
}
+ // The choice is made before the run, not after, because it is a choice about how long the
+ // user is willing to stand still — and because the two modes answer different questions.
+ var mode by remember { mutableStateOf(RunMode.SHORT) }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ FilterChip(
+ selected = mode == RunMode.SHORT,
+ onClick = { mode = RunMode.SHORT },
+ enabled = !state.running,
+ label = { Text("Quick") },
+ )
+ FilterChip(
+ selected = mode == RunMode.LONG,
+ onClick = { mode = RunMode.LONG },
+ enabled = !state.running,
+ label = { Text("Long ($longMinutes min)") },
+ )
+ }
+ Text(
+ if (mode == RunMode.SHORT) {
+ "About 30 seconds. Describes how the network is configured right now."
+ } else {
+ "Listens for $longMinutes minutes while it measures. Finds what a quick run " +
+ "structurally cannot: links that drop and come back, signal that decays, " +
+ "loss that arrives in bursts."
+ },
+ fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
- Button(onClick = onRun, enabled = !state.running) {
+ Button(onClick = { onRun(mode) }, enabled = !state.running) {
Text(if (state.running) "Running…" else "Run measurement")
}
if (state.running) {
@@ -385,25 +453,55 @@ private fun EcholotScreen(
if (state.running) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
- val frac = if (state.stepsTotal > 0)
- state.stepsDone.toFloat() / state.stepsTotal else 0f
+ // In a long run the window is the run: once the battery is done, four minutes of
+ // listening remain, and a bar driven by the step count would sit at 100 % through
+ // all of it — which reads as an app that has hung, not one that is working.
+ val listening = state.windowTotalS > 0
+ val frac = when {
+ listening -> state.windowElapsedS.toFloat() / state.windowTotalS
+ state.stepsTotal > 0 -> state.stepsDone.toFloat() / state.stepsTotal
+ else -> 0f
+ }
LinearProgressIndicator(
- progress = { frac },
+ progress = { frac.coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth(),
)
+ if (listening) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ "listening ${clock(state.windowElapsedS)} of ${clock(state.windowTotalS)}",
+ fontSize = 12.sp, fontWeight = FontWeight.Medium,
+ )
+ Text(
+ "${clock(state.windowTotalS - state.windowElapsedS)} left",
+ fontSize = 12.sp, modifier = Modifier.weight(1f),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ // Still shown during a long run's listening phase, where it stops at the last
+ // test: the battery's progress is real information, it is simply not the whole
+ // run any more.
Text(
if (state.stepsTotal > 0)
- "test ${state.stepsDone + 1} of ${state.stepsTotal}" else "starting",
+ "test ${(state.stepsDone + 1).coerceAtMost(state.stepsTotal)} of ${state.stepsTotal}"
+ else "starting",
fontSize = 12.sp, fontWeight = FontWeight.Medium,
)
Text(state.currentStep ?: "…", fontSize = 12.sp,
fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
- if (state.etaSeconds > 0) {
+ if (!listening && state.etaSeconds > 0) {
Text("~${state.etaSeconds}s left", fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
+ if (listening) {
+ Text(
+ "Cancelling keeps what has been collected so far.",
+ fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
}
}
@@ -453,6 +551,17 @@ private fun Results(doc: MeasurementDocument) {
Card(colors = CardDefaults.cardColors(containerColor = verdictColor(summary.overall))) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text("Overall: ${summary.overall}", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp)
+ // Which question this document answers. A green light from a quick run does not
+ // mean the same thing as a green light from a long one, and the report should not
+ // let the two look identical.
+ Text(
+ if (doc.run.mode == RunMode.LONG) {
+ "long run — the network was watched continuously as well as probed"
+ } else {
+ "quick run — a snapshot; nothing here rules out an intermittent fault"
+ },
+ color = Color.White, fontSize = 12.sp,
+ )
}
}
FlowCategories(summary.categories)
@@ -558,6 +667,12 @@ private fun FlowCategories(categories: Map) {
}
}
+/** m:ss — minutes are how a five-minute wait is read; "247s left" is a number to convert. */
+private fun clock(seconds: Int): String {
+ val s = seconds.coerceAtLeast(0)
+ return "${s / 60}:${"%02d".format(s % 60)}"
+}
+
@Composable
private fun Dot(color: Color) {
Surface(color = color, shape = RoundedCornerShape(50), modifier = Modifier.size(12.dp)) {}
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 95396e7..8c26637 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
@@ -23,6 +23,8 @@ import app.echo_lot.probe.RouterIdentityProbe
import app.echo_lot.shizuku.ShizukuAvailability
import app.echo_lot.shizuku.ShizukuProbe
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Instant
@@ -38,6 +40,17 @@ data class UiState(
val stepsDone: Int = 0,
val stepsTotal: Int = 0,
val etaSeconds: Int = 0,
+ /** Which mode the run in progress (or the last one) used. */
+ val mode: RunMode = RunMode.SHORT,
+ /**
+ * The listening window, in seconds. Zero for a short run.
+ *
+ * A long run's step count stops being the honest progress measure the moment the battery is
+ * done and four minutes of listening remain: the bar would sit at 100 % while the run carried
+ * on, which reads as a hung app. Over a window, elapsed-of-total is the truth.
+ */
+ val windowElapsedS: Int = 0,
+ val windowTotalS: Int = 0,
/** Where the finished run went: archived locally, uploaded, or neither (and why). */
val archiveStatus: String? = null,
/** History, newest first. Refreshed after every run and whenever the history screen opens. */
@@ -122,6 +135,17 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
override fun onCleared() {
stopShizukuObserver()
+ // A NetworkCallback outlives the object that registered it: the system holds the reference,
+ // so a ViewModel dying mid-run with listeners still up leaks one for the life of the
+ // process. viewModelScope is already cancelled by now, hence a detached scope purely to
+ // hang up — its results are discarded, only the unregistration matters.
+ val leftovers = activeCollectors
+ activeCollectors = emptyList()
+ if (leftovers.isNotEmpty()) {
+ kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
+ leftovers.forEach { runCatching { it.stop() } }
+ }
+ }
super.onCleared()
}
// Results collected so far. A cancelled run must still be able to show what it measured.
@@ -131,6 +155,18 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
private var runNetworks: List = emptyList()
private var runShizukuOk = false
private var runConstraints = Constraints()
+ private var runMode: RunMode = RunMode.SHORT
+
+ /**
+ * The listeners of the run in flight.
+ *
+ * Held on the ViewModel rather than inside `measure()` because [cancel] has to be able to reach
+ * them: the run job is dead by then, and what the listeners gathered up to that moment is the
+ * most valuable part of a long run that was cut short. They own their own coroutine scopes for
+ * the same reason — cancelling the run must stop the sampling without discarding the samples.
+ */
+ private var activeCollectors: List = emptyList()
+ private var changeCollector: app.echo_lot.probe.NetworkChangeCollector? = null
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
private class RunIds : ProbeIds {
@@ -146,14 +182,17 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
* from the user-facing upload so a debugging convenience can never be mistaken for, or
* silently satisfy, the consent-gated one.
*/
- fun run(devUpload: Boolean = false) {
+ fun run(mode: RunMode = RunMode.SHORT, devUpload: Boolean = false) {
if (state.running) return
collected.clear()
runConstraints = Constraints()
+ runMode = mode
+ val windowS = if (mode == RunMode.LONG) settings.longRunMinutes * 60 else 0
state = state.copy(running = true, currentStep = "starting", document = null,
- uploadStatus = null, archiveStatus = null)
+ uploadStatus = null, archiveStatus = null,
+ mode = mode, windowElapsedS = 0, windowTotalS = windowS)
runJob = viewModelScope.launch {
- val doc = withContext(Dispatchers.IO) { measure() }
+ val doc = withContext(Dispatchers.IO) { measure(mode) }
step("archiving")
val archived = withContext(Dispatchers.IO) { store.archive(doc) }
@@ -184,6 +223,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
state = UiState(
running = false, currentStep = null, document = doc,
uploadStatus = status, archiveStatus = archiveStatus,
+ mode = mode,
history = withContext(Dispatchers.IO) { store.list() },
)
}
@@ -371,23 +411,63 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
/**
* Stops an in-flight run and shows what was measured so far. Deliberately does NOT upload:
* a partial run is for the person looking at the screen, not for the record.
+ *
+ * Cancelling a long run must still hand back what the listeners heard — two minutes of watching
+ * is worth reporting, and throwing it away because the user did not wait for the third would be
+ * the worst possible answer to "I've seen enough". The harvest runs on [viewModelScope] rather
+ * than in the (now cancelled) run job, and the listeners' own scopes are what kept their data
+ * alive long enough to collect.
*/
fun cancel() {
- if (!state.running) return
+ if (!state.running || cancelling) return
+ cancelling = true
runJob?.cancel()
- val doc = buildDocument(collected.toList())
- state = UiState(
- running = false, currentStep = null, document = doc,
- uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
- archiveStatus = "partial run — not archived",
- history = state.history,
- )
+ state = state.copy(currentStep = "stopping listeners")
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) { stopCollectors() }
+ val doc = buildDocument(collected.toList())
+ cancelling = false
+ state = UiState(
+ running = false, currentStep = null, document = doc,
+ uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
+ archiveStatus = "partial run — not archived",
+ mode = runMode,
+ history = state.history,
+ )
+ }
}
- private suspend fun measure(): MeasurementDocument {
+ private var cancelling = false
+
+ /**
+ * Stops every listener, folds its Test into the run, and attaches the observed network changes
+ * to `networks[]`. Idempotent — the normal path and the cancel path both call it, and only the
+ * first does anything.
+ */
+ private suspend fun stopCollectors(): List {
+ val running = activeCollectors
+ activeCollectors = emptyList()
+ val out = ArrayList()
+ for (c in running) {
+ val t = runCatching { c.stop() }.getOrNull() ?: continue
+ out.add(t)
+ collected.add(t)
+ }
+ changeCollector?.let { watcher ->
+ changeCollector = null
+ val byNetwork = runCatching { watcher.changesByNetwork() }.getOrDefault(emptyMap())
+ if (byNetwork.isNotEmpty()) {
+ runNetworks = runNetworks.map { n -> n.copy(changes = byNetwork[n.id] ?: n.changes) }
+ }
+ }
+ return out
+ }
+
+ private suspend fun measure(mode: RunMode): MeasurementDocument = coroutineScope {
val ctx = getApplication()
val ids = RunIds().also { runIds = it }
val startWall = Instant.now().toString().also { runStartWall = it }
+ val windowMs = if (mode == RunMode.LONG) settings.longRunMinutes * 60_000L else 0L
step("reading networks")
val entries = NetworkInventory.snapshot(ctx)
@@ -398,7 +478,43 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
// per-test `attempted: false` breadcrumbs (measurement-schema.md §3 `constraints`).
runConstraints = app.echo_lot.probe.ConstraintDetector.detect(ctx, entries)
- val probes: List = listOf(
+ // Listeners first, at t=0, so the battery itself runs *inside* the observed window: a link
+ // that drops while the DNS probe is timing out is then recorded as a drop rather than
+ // guessed at from a failure.
+ var ticker: kotlinx.coroutines.Job? = null
+ if (mode == RunMode.LONG) {
+ step("starting listeners")
+ val watcher = app.echo_lot.probe.NetworkChangeCollector(entries).also { changeCollector = it }
+ val collectors = listOf(
+ watcher,
+ app.echo_lot.probe.WifiSignalCollector(entries),
+ app.echo_lot.probe.PingSeriesCollector(),
+ // mDNS is a listener wearing a probe's clothes, so in long mode it listens for the
+ // window instead of blocking the battery for it. Two seconds short of the window,
+ // so it finishes just before everything is stopped rather than just after.
+ app.echo_lot.probe.ProbeCollector(
+ app.echo_lot.probe.MdnsInventoryProbe(
+ listenMs = (windowMs - 2_000).coerceAtLeast(10_000)
+ )
+ ),
+ )
+ activeCollectors = collectors
+ for (c in collectors) runCatching { c.start(ctx, ids) }
+ // One ticker for the whole run: the battery does not report progress by the second, and
+ // without this the elapsed/remaining line would freeze for as long as the slowest probe.
+ ticker = launch {
+ while (isActive) {
+ val elapsedS = (ids.monoNs() / 1_000_000_000L).toInt()
+ state = state.copy(
+ windowElapsedS = elapsedS.coerceAtMost((windowMs / 1000).toInt()),
+ windowTotalS = (windowMs / 1000).toInt(),
+ )
+ kotlinx.coroutines.delay(1000)
+ }
+ }
+ }
+
+ val probes: List = listOfNotNull(
LinkSnapshotProbe(entries),
RouterIdentityProbe(entries),
IcmpProbe(entries, v6 = false),
@@ -406,7 +522,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
// Folded from the prober after hardware validation: errqueue traceroute (no root,
// no JNI) and the mDNS service inventory / VLAN-leakage detector.
app.echo_lot.probe.TracerouteProbe(),
- app.echo_lot.probe.MdnsInventoryProbe(),
+ // Absent from a long run's battery: it runs there as a collector for the whole window
+ // instead, and running it in both places would query the same services twice.
+ if (mode == RunMode.LONG) null else app.echo_lot.probe.MdnsInventoryProbe(),
CaptivePortalProbe(entries),
// Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to
// the reference deployment until profiles/enrollment land in the UI.
@@ -479,7 +597,24 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
(it.status == TestStatus.OK || it.status == TestStatus.PARTIAL)
}
- return buildDocument(tests)
+ if (mode == RunMode.LONG) {
+ // The battery finishing is not the run finishing. Everything the long mode exists for
+ // happens in the minutes after this point, so the run waits the window out — in one
+ // second steps, because `delay` is what makes a five-minute wait cancellable.
+ while (true) {
+ val elapsedMs = ids.monoNs() / 1_000_000
+ val remainingS = ((windowMs - elapsedMs + 999) / 1000).toInt()
+ if (remainingS <= 0) break
+ step("listening · ${remainingS}s left", done = totalSteps, total = totalSteps, etaMs = windowMs - elapsedMs)
+ kotlinx.coroutines.delay(minOf(1000L, windowMs - elapsedMs))
+ }
+ step("collecting listeners")
+ tests.addAll(stopCollectors())
+ }
+ // Before the enclosing coroutineScope waits for its children, or the run would never end.
+ ticker?.cancel()
+
+ buildDocument(tests)
}
/** Assembles a document from whatever tests are in hand — used for both full and cancelled runs. */
@@ -487,7 +622,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
val findings = deriveFindings(tests, runNetworks)
return MeasurementDocument(
run = Run(
- id = runIds.uuid(), trigger = Trigger.MANUAL, startedAt = runStartWall,
+ id = runIds.uuid(), trigger = Trigger.MANUAL, mode = runMode, startedAt = runStartWall,
endedAt = Instant.now().toString(),
clock = Clock(monoOriginWall = runStartWall),
app = AppInfo(
@@ -618,6 +753,46 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
)
)
}
+ // The one finding only a long run can reach. Derived from networks[].changes[] rather than
+ // from the watcher's evidence, because the changes are the schema's own record of what
+ // happened (§4) and re-parsing the test would be a second, divergent reading of it. A short
+ // run has an empty changes[] and therefore never gets here — which is correct, not a gap:
+ // it did not watch, so it has nothing to say either way.
+ val watchTest = tests.firstOrNull {
+ it.type == TestType.LINK_IP_MONITOR && it.tier == Tier.APP
+ }
+ if (watchTest != null) {
+ for (n in networks) {
+ val cycles = NetworkChanges.flapCyclesOf(n.changes)
+ if (cycles < 1) continue
+ val where = n.iface?.takeIf { it.isNotBlank() } ?: "this network"
+ out.add(
+ Finding(
+ id = ids.uuid(),
+ code = FindingRegistry.LINK_FLAPPING.code,
+ category = FindingRegistry.LINK_FLAPPING.category,
+ // Escalated on repetition: once is a hiccup worth knowing about, three
+ // times in one window is the reason someone's calls keep dropping.
+ severity = if (cycles >= 3) Severity.HIGH else FindingRegistry.LINK_FLAPPING.severity,
+ confidence = Confidence.HIGH,
+ title = if (cycles == 1) "$where dropped and came back during the run"
+ else "$where dropped and came back $cycles times during the run",
+ description = "A listener watched this link for the whole measurement and " +
+ "saw it go away and return " +
+ (if (cycles == 1) "once" else "$cycles times") + ". Every one-shot " +
+ "test in this run may still have passed — the probes on either side of " +
+ "a gap succeed — so this is the kind of fault a quick measurement " +
+ "cannot find. Connections in flight are dropped each time it happens: " +
+ "calls end, downloads stall, and anything long-lived reconnects. On " +
+ "wifi the usual causes are a weak or contended channel, band steering, " +
+ "or an access point restarting; on cellular, handovers at the edge of " +
+ "coverage. The timeline of every change is in networks[].changes[].",
+ evidenceRefs = listOf(EvidenceRef(watchTest.id)),
+ )
+ )
+ }
+ }
+
val shapes = V6Analysis.classify(networks)
// Named per interface: on a phone several networks are up at once, and "IPv6 is broken" is
// useless when wifi is the broken one and cellular is fine.
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 fd06b9b..a8dbb24 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,36 @@ class Settings(context: Context) {
maxTotalBytes = maxTotalMb.toLong() * 1024 * 1024,
)
+ // ---- measurement -------------------------------------------------------------------
+
+ /**
+ * How long a long run listens, in minutes. Offered as 1 / 5 / 15.
+ *
+ * 5 is the default because it is the shortest window in which the things long mode exists to
+ * catch — a link that flaps, a signal that decays as someone walks around, loss that comes in
+ * bursts — have a fair chance of happening at least twice. One minute is for checking that the
+ * mode works at all; fifteen is for chasing something already suspected.
+ *
+ * Clamped rather than trusted: a zero-minute long run would produce a document claiming a
+ * window it never watched, which is the one thing `run.mode` exists to prevent.
+ */
+ var longRunMinutes: Int
+ get() = prefs.getInt(LONG_RUN_MINUTES, 5).coerceIn(1, 60)
+ set(v) = prefs.edit().putInt(LONG_RUN_MINUTES, v.coerceIn(1, 60)).apply()
+
+ // ---- dev relay -----------------------------------------------------------------------
+
+ /**
+ * Whether this device relays adbd's wireless-debug endpoint to the enrolled server.
+ *
+ * Off by default and never implied by anything else: it publishes where this device can be
+ * reached for debugging, which is a decision rather than a side effect. Intended for a spare
+ * device parked on a test network — see AdbRelay for why the OnePlus is a poor host for it.
+ */
+ var adbRelayEnabled: Boolean
+ get() = prefs.getBoolean(ADB_RELAY, false)
+ set(v) = prefs.edit().putBoolean(ADB_RELAY, v).apply()
+
// ---- run-duration learning ---------------------------------------------------------
/**
@@ -245,5 +275,7 @@ class Settings(context: Context) {
const val ACCOUNT_NAME = "account_name"
const val ACCOUNT_ID = "account_id"
const val DURATION_PREFIX = "duration_ms."
+ const val ADB_RELAY = "adb_relay_enabled"
+ const val LONG_RUN_MINUTES = "long_run_minutes"
}
}
diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt
index 57b3091..7281861 100644
--- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt
+++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt
@@ -59,6 +59,8 @@ fun SettingsScreen(
onEnroll: (String) -> Unit,
serverStatus: String?,
enrollStatus: String?,
+ /** Starts or stops the adb relay service; the toggle only records the preference. */
+ onRelayChange: (Boolean) -> Unit,
onBack: () -> Unit,
) {
// SharedPreferences is not observable, so mirror each value into Compose state and write
@@ -67,9 +69,11 @@ fun SettingsScreen(
var maxRuns by remember { mutableStateOf(settings.maxRuns.toString()) }
var maxAgeDays by remember { mutableStateOf(settings.maxAgeDays.toString()) }
var maxTotalMb by remember { mutableStateOf(settings.maxTotalMb.toString()) }
+ var longMinutes by remember { mutableStateOf(settings.longRunMinutes) }
var autoUpload by remember { mutableStateOf(settings.autoUpload) }
var privacy by remember { mutableStateOf(settings.privacyLevel) }
var stableSalt by remember { mutableStateOf(settings.stableSalt) }
+ var relayOn by remember { mutableStateOf(settings.adbRelayEnabled) }
var enrollLink by remember { mutableStateOf("") }
// The public name, which is what the operator handed out and what a person recognises. The
// endpoint actually dialled is shown beneath it when the two differ, rather than hidden — a
@@ -97,6 +101,39 @@ fun SettingsScreen(
Text("Settings", style = MaterialTheme.typography.titleLarge)
}
+ // ---- measurement ----
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Long runs", style = MaterialTheme.typography.titleMedium)
+ Text(
+ "How long a long run keeps listening. The measurements themselves take about " +
+ "30 seconds either way; the rest of the window is spent watching for " +
+ "things that only happen sometimes.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ for (minutes in listOf(1, 5, 15)) {
+ FilterChip(
+ selected = longMinutes == minutes,
+ onClick = { longMinutes = minutes; settings.longRunMinutes = minutes },
+ label = { Text("$minutes min") },
+ )
+ }
+ }
+ Text(
+ when (longMinutes) {
+ 1 -> "Barely longer than a quick run — enough to confirm the listeners " +
+ "work, rarely enough to catch anything intermittent."
+ 15 -> "For a fault you already suspect and have to prove. Keep the screen " +
+ "on and the device where the problem happens."
+ else -> "Long enough for a link that drops every couple of minutes to do " +
+ "it at least once, short enough to wait out."
+ },
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+
// ---- archive ----
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -348,6 +385,39 @@ fun SettingsScreen(
)
}
}
+
+ // ---- dev relay ------------------------------------------------------------------
+ //
+ // Last, and deliberately plain: this is scaffolding for driving a test device, not a
+ // measurement. It publishes where this device can be reached over adb, which is why it
+ // is off until someone decides otherwise.
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Developer relay", style = MaterialTheme.typography.titleMedium)
+ Toggle(
+ label = "Relay this device's adb endpoint",
+ detail = "Watches adbd's own mDNS announcement and reports host:port to the " +
+ "enrolled server, so a developer on another network can reach this " +
+ "device — mDNS does not cross subnets, and the port rotates every few " +
+ "minutes. Runs in the foreground with a notification while on.",
+ checked = relayOn,
+ enabled = settings.serverConfigured,
+ onChange = { on ->
+ relayOn = on
+ settings.adbRelayEnabled = on
+ onRelayChange(on)
+ },
+ )
+ if (!settings.serverConfigured) {
+ Text(
+ "Needs an enrolled server: the report is authenticated with this " +
+ "device's credential.",
+ style = MaterialTheme.typography.bodySmall,
+ color = LocalContentColor.current.copy(alpha = 0.7f),
+ )
+ }
+ }
+ }
Spacer(Modifier.height(24.dp))
}
}
diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Document.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Document.kt
index 5be9c2b..904469f 100644
--- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Document.kt
+++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Document.kt
@@ -28,6 +28,7 @@ data class MeasurementDocument(
data class Run(
val id: String, // UUIDv7
val trigger: Trigger,
+ val mode: RunMode = RunMode.SHORT,
@SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only
@SerialName("ended_at") val endedAt: String? = null,
val clock: Clock,
@@ -66,6 +67,27 @@ data class Constraints(
val constrained: Boolean get() = vpnActive || perNetworkBlocked
}
+/**
+ * How long the run watched the network — and therefore what its silence is worth.
+ *
+ * A [SHORT] run is a sequence of one-shot probes: each looks at the network for a second or two and
+ * moves on. That is enough to characterise a network's *configuration*, and it is structurally
+ * incapable of seeing anything intermittent. A wifi link that drops for four seconds every two
+ * minutes, a resolver that stalls under load, an AP that roams — none of these leave a trace in
+ * thirty seconds of probing unless the run happened to coincide with one.
+ *
+ * A [LONG] run starts continuous listeners at t=0, runs the same battery beside them, and keeps
+ * sampling until the window closes. It answers a different question, so a reader must not treat the
+ * two alike: **the mode is what licenses an argument from absence**. "No drops were observed" means
+ * something after five minutes of watching and nothing at all after a thirty-second run, and
+ * without this field the two documents are indistinguishable.
+ */
+@Serializable
+enum class RunMode {
+ @SerialName("short") SHORT,
+ @SerialName("long") LONG,
+}
+
@Serializable
enum class Trigger {
@SerialName("manual") MANUAL,
diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt
index 311fea1..acd8004 100644
--- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt
+++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt
@@ -103,6 +103,26 @@ object FindingRegistry {
"Android's own connectivity checks fail on this network.",
)
+ /**
+ * The finding a short run cannot make.
+ *
+ * Every one-shot probe describes the network during its own two seconds. A link that drops and
+ * returns between two of them leaves no trace anywhere in the document — the probes before and
+ * after both succeed, and the run reports a healthy network. Only a listener that watches the
+ * whole window sees the gap, which is why this is emitted from `networks[].changes[]` (§4)
+ * rather than from any test's evidence.
+ *
+ * MEDIUM by default and escalated by the emitter on repeat: one drop in five minutes is worth
+ * knowing about, three is the difference between "the wifi hiccuped" and "this link is why
+ * calls keep dropping". Deliberately claims a *completed* cycle — lost and then regained — so
+ * it never fires for a network that was simply turned off partway through the run.
+ */
+ val LINK_FLAPPING = FindingSpec(
+ "connectivity.link_flapping", Category.CONNECTIVITY, Severity.MEDIUM,
+ "A network dropped and came back one or more times during the run.",
+ rulesOut = "A momentary probe failure: the drop was watched happening, not inferred from silence.",
+ )
+
// ---- mtu -------------------------------------------------------------------------
val MTU_REDUCED_DOWNSTREAM = FindingSpec(
@@ -305,7 +325,7 @@ object FindingRegistry {
/** Every registered finding, in declaration order. */
val all: List = listOf(
UDP_UNREACHABLE, UDP_UNREACHABLE_UPSTREAM, UDP_LOSS, LOSS_UPSTREAM, LOSS_DOWNSTREAM,
- DOWNSTREAM_BLOCKED, DOWNSTREAM_REORDER, CAPTIVE_PORTAL, NO_INTERNET,
+ DOWNSTREAM_BLOCKED, DOWNSTREAM_REORDER, CAPTIVE_PORTAL, NO_INTERNET, LINK_FLAPPING,
MTU_REDUCED_DOWNSTREAM, MTU_DOWNSTREAM_BLACKHOLE, FRAGMENTS_BLOCKED,
FRAGMENT_REORDER_SENSITIVE,
NAT_UDP_REBINDING, NAT_SYMMETRIC,
diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt
index 9bfc46e..9049e7b 100644
--- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt
+++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt
@@ -144,3 +144,38 @@ data class NetworkChange(
val kind: String, // lost | gained | link_changed
val detail: JsonObject? = null,
)
+
+/**
+ * What a network's `changes[]` add up to.
+ *
+ * Lives beside the type rather than in the collector that produces it because two independent
+ * consumers ask the same question — the watcher, computing its metrics, and the run engine,
+ * deciding whether to emit `connectivity.link_flapping` — and a document whose metric and finding
+ * disagreed about how many times the link dropped would be worse than one reporting neither.
+ */
+object NetworkChanges {
+
+ const val LOST = "lost"
+ const val GAINED = "gained"
+ const val LINK_CHANGED = "link_changed"
+
+ /**
+ * Completed drop-and-return cycles: a `lost` with a later `gained` on the same network.
+ *
+ * A cycle has to *complete*. A link that goes away at minute four and is still gone when the
+ * window closes was not flapping — it was switched off, or the device was carried out of
+ * range, and calling that the same fault would put a phone in a lift beside a failing access
+ * point.
+ */
+ fun flapCycles(kinds: List): Int {
+ var cycles = 0
+ var down = false
+ for (k in kinds) {
+ if (k == LOST) down = true
+ else if (k == GAINED && down) { cycles++; down = false }
+ }
+ return cycles
+ }
+
+ fun flapCyclesOf(changes: List): Int = flapCycles(changes.map { it.kind })
+}
diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt
index d8e1d7a..8f8b5b3 100644
--- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt
+++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt
@@ -133,6 +133,16 @@ object TestType {
const val LOCAL_MDNS_INVENTORY = "local.mdns_inventory"
const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory"
const val LOCAL_LLMNR_INVENTORY = "local.llmnr_inventory"
+ /**
+ * WS-Discovery (UDP 3702) and NetBIOS name service (UDP 137). Registry additions, v1.2.
+ *
+ * Both are passive: the traffic is broadcast to the segment whether or not anyone asks, so
+ * listening is the whole measurement. They earn their own ids rather than folding into
+ * [LOCAL_SSDP_INVENTORY] because what they imply differs — WS-Discovery inventories printers
+ * and cameras, while NetBIOS/LLMNR chatter is a security finding in its own right.
+ */
+ const val LOCAL_WSD_INVENTORY = "local.wsd_inventory"
+ const val LOCAL_NETBIOS_INVENTORY = "local.netbios_inventory"
const val LOCAL_GATEWAY_SERVICES = "local.gateway_services"
const val LOCAL_NTP = "local.ntp"
// peer
diff --git a/echolot-app/core-measurement/src/test/kotlin/app/echo_lot/measurement/NetworkChangesTest.kt b/echolot-app/core-measurement/src/test/kotlin/app/echo_lot/measurement/NetworkChangesTest.kt
new file mode 100644
index 0000000..8eca01f
--- /dev/null
+++ b/echolot-app/core-measurement/src/test/kotlin/app/echo_lot/measurement/NetworkChangesTest.kt
@@ -0,0 +1,65 @@
+// SPDX-FileCopyrightText: 2026 Echolot contributors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app.echo_lot.measurement
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * Pins the counting behind `connectivity.link_flapping`.
+ *
+ * The finding claims a link went away and came back, and its severity escalates on repetition, so
+ * this is arithmetic a person reading a report will act on. The cases that matter are the ones
+ * where the naive count is wrong: a link still down when the window closed, and a run that started
+ * while the link was already gone.
+ */
+class NetworkChangesTest {
+
+ @Test
+ fun aQuietWindowHasNoCycles() {
+ assertEquals(0, NetworkChanges.flapCycles(emptyList()))
+ assertEquals(0, NetworkChanges.flapCycles(listOf("link_changed", "link_changed")))
+ }
+
+ @Test
+ fun oneDropAndReturnIsOneCycle() {
+ assertEquals(1, NetworkChanges.flapCycles(listOf("lost", "gained")))
+ assertEquals(
+ 1,
+ NetworkChanges.flapCycles(listOf("link_changed", "lost", "link_changed", "gained")),
+ )
+ }
+
+ @Test
+ fun repeatedDropsCountSeparately() {
+ assertEquals(3, NetworkChanges.flapCycles(listOf("lost", "gained", "lost", "gained", "lost", "gained")))
+ }
+
+ // A link that is still down when the run ends was not flapping — it was switched off, or the
+ // device left its range. Counting that as a cycle would put a phone in a lift beside a failing
+ // access point.
+ @Test
+ fun aDropThatNeverReturnsIsNotACycle() {
+ assertEquals(0, NetworkChanges.flapCycles(listOf("lost")))
+ assertEquals(1, NetworkChanges.flapCycles(listOf("lost", "gained", "lost")))
+ }
+
+ // The mirror case: the window opened while the network was already gone, so its return is the
+ // first thing seen. Nothing was watched dropping, so nothing is claimed.
+ @Test
+ fun aReturnWithNoObservedDropIsNotACycle() {
+ assertEquals(0, NetworkChanges.flapCycles(listOf("gained")))
+ assertEquals(0, NetworkChanges.flapCycles(listOf("gained", "link_changed")))
+ }
+
+ @Test
+ fun theChangeOverloadAgreesWithTheKindsOverload() {
+ val changes = listOf(
+ NetworkChange(atMonoNs = 1, kind = NetworkChanges.LOST),
+ NetworkChange(atMonoNs = 2, kind = NetworkChanges.GAINED),
+ NetworkChange(atMonoNs = 3, kind = NetworkChanges.LINK_CHANGED),
+ )
+ assertEquals(1, NetworkChanges.flapCyclesOf(changes))
+ }
+}
diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/Collector.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/Collector.kt
new file mode 100644
index 0000000..533b0b8
--- /dev/null
+++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/Collector.kt
@@ -0,0 +1,78 @@
+// SPDX-FileCopyrightText: 2026 Echolot contributors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app.echo_lot.probe
+
+import android.content.Context
+import app.echo_lot.measurement.Test
+import app.echo_lot.measurement.TestError
+import app.echo_lot.measurement.TestStatus
+import app.echo_lot.measurement.Tier
+
+/**
+ * A measurement that watches, rather than one that asks — the long-run counterpart to [Probe].
+ *
+ * The difference is not duration but what is observable at all. A [Probe] describes the network
+ * during its own two seconds, so anything intermittent is invisible to the whole battery unless it
+ * happens to coincide with a probe: a wifi link that drops for four seconds every two minutes is
+ * reported as perfectly healthy by every one-shot test, both before and after the gap. A collector
+ * starts at t=0, keeps sampling while the battery runs beside it and after it finishes, and yields
+ * one [Test] when the window closes.
+ *
+ * Contract, and all of it is load-bearing for a run that can be cancelled at any second:
+ * - [start] must return promptly, having launched whatever it needs on its own scope. The battery
+ * runs concurrently and must not wait for a listener.
+ * - [stop] must be callable after a failed [start], must never throw, and must return whatever was
+ * gathered so far. A cancelled long run still owes the user the two minutes it did watch.
+ * - Neither may throw to the caller; a collector that could not register its listener reports that
+ * as an `unsupported` Test, which is a result rather than an absence.
+ */
+interface Collector {
+ /** A TestType registry id — collectors do not get their own namespace. */
+ val type: String
+ val tier: Tier get() = Tier.APP
+
+ suspend fun start(ctx: Context, ids: ProbeIds)
+
+ suspend fun stop(): Test
+}
+
+/**
+ * Shared plumbing: the ids and the [TestBuilder] a collector needs in [Collector.stop], captured in
+ * [Collector.start] before anything that can fail.
+ *
+ * Assigned first thing on purpose. A collector whose registration throws still has to produce a
+ * Test saying so, and it cannot do that without a UUID source and a start timestamp — so acquiring
+ * them is never allowed to be the step that failed.
+ */
+abstract class BaseCollector : Collector {
+
+ protected var ids: ProbeIds? = null
+ private set
+ private var builder: TestBuilder? = null
+
+ /** Call at the top of [Collector.start], before any platform call. */
+ protected fun begin(ids: ProbeIds, networkRef: String? = null) {
+ this.ids = ids
+ builder = TestBuilder(type, tier, ids, networkRef = networkRef)
+ }
+
+ /**
+ * Builds this collector's Test, or — when [begin] never ran, so the collector was stopped
+ * without ever being started — a `skipped` one saying exactly that. It reports rather than
+ * throws for the same reason probes do: the caller is assembling a document, and an exception
+ * there costs every other collector's data too.
+ */
+ protected fun build(
+ status: TestStatus,
+ evidence: kotlinx.serialization.json.JsonObject? = null,
+ metrics: kotlinx.serialization.json.JsonObject? = null,
+ error: TestError? = null,
+ params: kotlinx.serialization.json.JsonObject? = null,
+ ): Test = builder?.build(status, evidence, metrics, error, params)
+ ?: Test(
+ id = "00000000-0000-7000-8000-000000000000", type = type, tier = tier,
+ startedMonoNs = 0, endedMonoNs = 0, status = TestStatus.SKIPPED,
+ error = TestError("not_started", "the collector was stopped before it was started"),
+ )
+}
diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/IcmpEcho.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/IcmpEcho.kt
new file mode 100644
index 0000000..39b4429
--- /dev/null
+++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/IcmpEcho.kt
@@ -0,0 +1,113 @@
+// SPDX-FileCopyrightText: 2026 Echolot contributors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app.echo_lot.probe
+
+import android.net.Network
+import android.system.Os
+import android.system.OsConstants
+import android.system.StructTimeval
+import java.io.FileDescriptor
+import java.net.InetAddress
+import java.nio.ByteBuffer
+import java.util.Locale
+
+/**
+ * One echo exchange over the unprivileged ICMP datagram socket.
+ *
+ * Extracted from [IcmpProbe] when the long-run [PingSeriesCollector] needed the same exchange a
+ * few hundred times instead of once. The two differ only in how often they call this; a second
+ * copy of the checksum and the sent/not-sent bookkeeping would only be a second place for them to
+ * drift apart.
+ *
+ * Works without root because Android ships an open `ping_group_range` — validated on hardware by
+ * the prober, and the reason this probe family exists at app tier at all.
+ */
+internal object IcmpEcho {
+
+ /**
+ * One attempt's result.
+ *
+ * [attempted] separates "we sent an echo request and heard nothing" from "we never got as far
+ * as sending one". Both leave [ok] false, and collapsing them is how a probe ends up asserting
+ * something about a network it never touched: binding to a non-default network can fail with
+ * EPERM, and reporting that as ICMP silence blames the network for the app's own inability to
+ * use the interface. For a series it is also the difference between a lost packet and a socket
+ * that was never usable — one is loss, the other is not.
+ */
+ data class Result(
+ val ok: Boolean,
+ val attempted: Boolean,
+ val detail: String,
+ val rttMs: Double?,
+ )
+
+ fun ping(
+ network: Network?,
+ target: String,
+ v6: Boolean,
+ timeoutMs: Int,
+ seq: Int = 1,
+ ): Result {
+ var fd: FileDescriptor? = null
+ var sent = false
+ return try {
+ val proto = if (v6) OsConstants.IPPROTO_ICMPV6 else OsConstants.IPPROTO_ICMP
+ val family = if (v6) OsConstants.AF_INET6 else OsConstants.AF_INET
+ fd = Os.socket(family, OsConstants.SOCK_DGRAM, proto)
+ // Everything up to and including sendto is setup. A failure here means the test did
+ // not run on this network — not that the network stayed silent.
+ network?.bindSocket(fd)
+ Os.setsockoptTimeval(
+ fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO,
+ StructTimeval.fromMillis(timeoutMs.toLong()),
+ )
+ val addr = network?.getByName(target) ?: InetAddress.getByName(target)
+
+ val ident = (Os.getpid() and 0xFFFF)
+ val packet = buildEchoRequest(v6, ident.toShort(), seq.toShort())
+ val t0 = System.nanoTime()
+ Os.sendto(fd, packet, 0, packet.size, 0, addr, 0)
+ sent = true
+ val buf = ByteBuffer.allocate(1500)
+ val received = Os.recvfrom(fd, buf, 0, null)
+ val rttMs = (System.nanoTime() - t0) / 1_000_000.0
+ val replyType = if (received > 0) buf.get(0).toInt() and 0xFF else -1
+ val ok = replyType == (if (v6) 129 else 0)
+ Result(
+ ok, true,
+ "reply type=$replyType rtt_ms=${"%.1f".format(Locale.ROOT, rttMs)} bytes=$received",
+ if (ok) rttMs else null,
+ )
+ } catch (e: Throwable) {
+ // A timeout after a successful send is a real "no reply"; anything before it is not.
+ Result(false, sent, "error: ${e.message ?: e.javaClass.simpleName}", null)
+ } finally {
+ fd?.let { runCatching { Os.close(it) } }
+ }
+ }
+
+ private fun buildEchoRequest(v6: Boolean, ident: Short, seq: Short): ByteArray {
+ val type = if (v6) 128 else 8
+ val payload = "echolot".toByteArray()
+ val pkt = ByteBuffer.allocate(8 + payload.size)
+ pkt.put(type.toByte()); pkt.put(0); pkt.putShort(0)
+ pkt.putShort(ident); pkt.putShort(seq); pkt.put(payload)
+ val bytes = pkt.array()
+ // The v6 checksum is computed by the kernel over a pseudo-header the socket owns; filling
+ // it in here would be wrong, not merely redundant.
+ if (!v6) {
+ val cs = checksum(bytes)
+ bytes[2] = (cs.toInt() shr 8).toByte(); bytes[3] = (cs.toInt() and 0xFF).toByte()
+ }
+ return bytes
+ }
+
+ private fun checksum(b: ByteArray): Short {
+ var sum = 0; var i = 0
+ while (i < b.size - 1) { sum += ((b[i].toInt() and 0xFF) shl 8) or (b[i + 1].toInt() and 0xFF); i += 2 }
+ if (i < b.size) sum += (b[i].toInt() and 0xFF) shl 8
+ while (sum shr 16 != 0) sum = (sum and 0xFFFF) + (sum shr 16)
+ return sum.inv().toShort()
+ }
+}
diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/IcmpProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/IcmpProbe.kt
index f2b87fb..b468f35 100644
--- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/IcmpProbe.kt
+++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/IcmpProbe.kt
@@ -5,9 +5,6 @@ package app.echo_lot.probe
import android.content.Context
import android.net.Network
-import android.system.Os
-import android.system.OsConstants
-import android.system.StructTimeval
import app.echo_lot.measurement.Test
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
@@ -17,10 +14,6 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
-import java.io.FileDescriptor
-import java.net.InetAddress
-import java.nio.ByteBuffer
-import java.util.Locale
/**
* icmp.ping4 / icmp.ping6 via the unprivileged ICMP datagram socket, per active network
@@ -40,7 +33,7 @@ class IcmpProbe(
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
val b = TestBuilder(type, tier, ids)
- val perNetwork = LinkedHashMap>()
+ val perNetwork = LinkedHashMap>()
var anyOk = false
val rtts = ArrayList()
@@ -82,75 +75,9 @@ class IcmpProbe(
b.build(status, evidence = evidence, metrics = metrics)
}
- /**
- * One network's result.
- *
- * [attempted] separates "we sent an echo request and heard nothing" from "we never got as far
- * as sending one". Both leave [ok] false, and collapsing them is how a probe ends up asserting
- * something about a network it never touched: binding to a non-default network can fail with
- * EPERM, and reporting that as ICMPv6 silence blames the carrier for the app's own inability
- * to use the interface.
- */
- private data class Attempt(
- val ok: Boolean,
- val attempted: Boolean,
- val detail: String,
- val rttMs: Double?,
- )
-
- private fun attempt(network: Network?): Attempt {
- var fd: FileDescriptor? = null
- var sent = false
- return try {
- val proto = if (v6) OsConstants.IPPROTO_ICMPV6 else OsConstants.IPPROTO_ICMP
- val family = if (v6) OsConstants.AF_INET6 else OsConstants.AF_INET
- fd = Os.socket(family, OsConstants.SOCK_DGRAM, proto)
- // Everything up to and including sendto is setup. A failure here means the test did
- // not run on this network — not that the network stayed silent.
- network?.bindSocket(fd)
- Os.setsockoptTimeval(fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO, StructTimeval.fromMillis(3000))
- val addr = network?.getByName(target) ?: InetAddress.getByName(target)
-
- val ident = (Os.getpid() and 0xFFFF)
- val packet = buildEchoRequest(v6, ident.toShort(), 1)
- val t0 = System.nanoTime()
- Os.sendto(fd, packet, 0, packet.size, 0, addr, 0)
- sent = true
- val buf = ByteBuffer.allocate(1500)
- val received = Os.recvfrom(fd, buf, 0, null)
- val rttMs = (System.nanoTime() - t0) / 1_000_000.0
- val replyType = if (received > 0) buf.get(0).toInt() and 0xFF else -1
- val ok = replyType == (if (v6) 129 else 0)
- Attempt(ok, true, "reply type=$replyType rtt_ms=${"%.1f".format(Locale.ROOT, rttMs)} bytes=$received", if (ok) rttMs else null)
- } catch (e: Throwable) {
- // A timeout after a successful send is a real "no reply"; anything before it is not.
- Attempt(false, sent, "error: ${e.message ?: e.javaClass.simpleName}", null)
- } finally {
- fd?.let { runCatching { Os.close(it) } }
- }
- }
-
- private fun buildEchoRequest(v6: Boolean, ident: Short, seq: Short): ByteArray {
- val type = if (v6) 128 else 8
- val payload = "echolot".toByteArray()
- val pkt = ByteBuffer.allocate(8 + payload.size)
- pkt.put(type.toByte()); pkt.put(0); pkt.putShort(0)
- pkt.putShort(ident); pkt.putShort(seq); pkt.put(payload)
- val bytes = pkt.array()
- if (!v6) {
- val cs = checksum(bytes)
- bytes[2] = (cs.toInt() shr 8).toByte(); bytes[3] = (cs.toInt() and 0xFF).toByte()
- }
- return bytes
- }
-
- private fun checksum(b: ByteArray): Short {
- var sum = 0; var i = 0
- while (i < b.size - 1) { sum += ((b[i].toInt() and 0xFF) shl 8) or (b[i + 1].toInt() and 0xFF); i += 2 }
- if (i < b.size) sum += (b[i].toInt() and 0xFF) shl 8
- while (sum shr 16 != 0) sum = (sum and 0xFFFF) + (sum shr 16)
- return sum.inv().toShort()
- }
+ /** The echo exchange itself lives in [IcmpEcho], shared with the long-run ping series. */
+ private fun attempt(network: Network?): IcmpEcho.Result =
+ IcmpEcho.ping(network, target, v6, timeoutMs = 3000)
private companion object {
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/MdnsInventoryProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/MdnsInventoryProbe.kt
index 94c30c7..3821010 100644
--- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/MdnsInventoryProbe.kt
+++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/MdnsInventoryProbe.kt
@@ -30,11 +30,16 @@ import java.util.Collections
* found live services — NsdManager's meta-query support is unreliable across builds, so the
* concrete types are the measurement and the meta-query result is itself evidence.
* - 4 s of listening missed services that 10 s catches; mDNS answers straggle.
+ *
+ * That last lesson is why [listenMs] is a parameter. 10 s is the short-mode default because it is
+ * the shortest window that was not demonstrably lossy; a long run hands it the whole measurement
+ * window, since the curve does not stop at ten seconds — devices announce on their own schedule,
+ * and a printer that is asleep answers when something else wakes it.
*/
-class MdnsInventoryProbe : Probe {
+class MdnsInventoryProbe(private val listenMs: Long = 10_000) : Probe {
override val type = TestType.LOCAL_MDNS_INVENTORY
override val tier = Tier.APP
- override val estimatedMs = 10_500L
+ override val estimatedMs = listenMs + 500
/** Meta-query + common concrete types (HTTP covers HA/printers/NAS; googlecast is ubiquitous). */
private val queries = listOf(
@@ -75,7 +80,7 @@ class MdnsInventoryProbe : Probe {
Triple(label, type, r)
}
try {
- delay(10_000)
+ delay(listenMs)
var total = 0
var anyStarted = false
val evidence = buildJsonObject {
@@ -95,10 +100,13 @@ class MdnsInventoryProbe : Probe {
}
}
val metrics = buildJsonObject { put("services_found", total) }
+ // How long it listened belongs in params: "4 services" means something different after
+ // ten seconds than after five minutes, and the number alone cannot say which it was.
+ val params = buildJsonObject { put("listen_ms", listenMs) }
// Zero services on a started discovery is a legitimate result (an empty or properly
// isolated network), not a failure — only discovery refusing to start is one.
b.build(if (anyStarted) TestStatus.OK else TestStatus.FAILED,
- evidence = evidence, metrics = metrics)
+ evidence = evidence, metrics = metrics, params = params)
} finally {
recorders.forEach { (_, _, r) -> runCatching { nsd.stopServiceDiscovery(r) } }
runCatching { lock?.release() }
diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetworkChangeCollector.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetworkChangeCollector.kt
new file mode 100644
index 0000000..64ad4d7
--- /dev/null
+++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetworkChangeCollector.kt
@@ -0,0 +1,274 @@
+// SPDX-FileCopyrightText: 2026 Echolot contributors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app.echo_lot.probe
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.LinkProperties
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import app.echo_lot.measurement.NetworkChange
+import app.echo_lot.measurement.NetworkChanges
+import app.echo_lot.measurement.Test
+import app.echo_lot.measurement.TestError
+import app.echo_lot.measurement.TestStatus
+import app.echo_lot.measurement.TestType
+import app.echo_lot.measurement.Tier
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import kotlinx.serialization.json.putJsonArray
+import kotlinx.serialization.json.addJsonObject
+import java.util.Collections
+
+/**
+ * Watches every network for the whole run and fills `networks[].changes[]` (measurement-schema §4).
+ *
+ * That array has been in the schema since the first draft and has never been populated, because
+ * nothing in a battery of one-shot probes is in a position to fill it. It is the single most
+ * valuable thing a long run adds: a wifi link that drops and returns mid-window is invisible to
+ * every probe — the ones before and after the gap both succeed — and it is exactly the fault people
+ * open a network diagnostic to chase.
+ *
+ * Reported as [TestType.LINK_IP_MONITOR] at app tier. The registry lists that type as Shizuku's
+ * (`ip monitor`), and this is deliberately the same observation from a tier that does not need it:
+ * link state as it changes over time. A document may therefore carry two `link.ip_monitor` tests,
+ * told apart by `tier` — which is what `tier` is for.
+ */
+class NetworkChangeCollector(
+ private val entries: List,
+) : BaseCollector() {
+
+ override val type = TestType.LINK_IP_MONITOR
+ override val tier = Tier.APP
+
+ private data class Change(
+ val atMonoNs: Long,
+ val kind: String,
+ val event: String,
+ val iface: String,
+ val detail: String,
+ )
+
+ private val changes: MutableList = Collections.synchronizedList(mutableListOf())
+ private var cm: ConnectivityManager? = null
+ private var callback: ConnectivityManager.NetworkCallback? = null
+ private var registerError: String? = null
+
+ /**
+ * Last seen state per network, so only real changes are recorded.
+ *
+ * Both capability and link-property callbacks fire constantly on a live device — signal
+ * strength alone re-delivers capabilities every few seconds — and a five-minute window of that
+ * would bury the four events that matter under several hundred that do not. The first callback
+ * after a network appears is the baseline, not a change.
+ */
+ private val lastCaps = HashMap()
+ private val lastLink = HashMap()
+ /** Interface name per network handle, remembered because `onLost` can no longer look it up. */
+ private val ifaceOf = HashMap()
+
+ /**
+ * The networks that were already up when the window opened.
+ *
+ * `registerNetworkCallback` replays `onAvailable` for every matching network the instant it is
+ * registered, so without this every run would open with three "the wifi appeared" events that
+ * describe the registration and not the network. A link that drops and returns comes back as a
+ * new handle, which is not in this set, so real re-appearances are still recorded.
+ */
+ private val seeded = HashSet()
+
+ override suspend fun start(ctx: Context, ids: ProbeIds) {
+ begin(ids)
+ val manager = ctx.getSystemService(ConnectivityManager::class.java)
+ if (manager == null) {
+ registerError = "ConnectivityManager unavailable"
+ return
+ }
+ cm = manager
+ // Seed the interface names from the snapshot the run already took, so a network that is
+ // lost without ever having delivered a callback here is still attributable.
+ for (e in entries) {
+ e.model.iface?.let { ifaceOf[key(e.handle)] = it }
+ seeded.add(key(e.handle))
+ }
+
+ val cb = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ val iface = resolveIface(network)
+ if (key(network) in seeded) return
+ record(ids, NetworkChanges.GAINED, "available", iface, "network became available")
+ }
+
+ override fun onLost(network: Network) {
+ val iface = ifaceOf[key(network)] ?: "(unknown)"
+ record(ids, NetworkChanges.LOST, "lost", iface, "network went away")
+ // Dropped so a returning link re-baselines instead of reporting every property it
+ // ever had as a change the moment it comes back.
+ lastCaps.remove(key(network)); lastLink.remove(key(network))
+ }
+
+ override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
+ val iface = resolveIface(network)
+ val print = capsFingerprint(caps)
+ val previous = lastCaps.put(key(network), print)
+ if (previous == null || previous == print) return
+ record(ids, NetworkChanges.LINK_CHANGED, "capabilities", iface, "$previous → $print")
+ }
+
+ override fun onLinkPropertiesChanged(network: Network, lp: LinkProperties) {
+ lp.interfaceName?.let { ifaceOf[key(network)] = it }
+ val print = linkFingerprint(lp)
+ val previous = lastLink.put(key(network), print)
+ if (previous == null || previous == print) return
+ record(ids, NetworkChanges.LINK_CHANGED, "link_properties", lp.interfaceName ?: "(unknown)",
+ describeLinkDelta(previous, print))
+ }
+
+ override fun onLosing(network: Network, maxMsToLive: Int) {
+ val iface = ifaceOf[key(network)] ?: "(unknown)"
+ record(ids, NetworkChanges.LINK_CHANGED, "losing", iface, "about to be torn down in ${maxMsToLive} ms")
+ }
+ }
+ callback = cb
+ // clearCapabilities(), or the default request only matches INTERNET + NOT_RESTRICTED and
+ // the carrier's IMS/MMS networks — and, more importantly, a network in the middle of
+ // failing validation — never appear. The transports are named explicitly so this does not
+ // also follow whatever internal networks a vendor keeps in the list.
+ val request = NetworkRequest.Builder()
+ .clearCapabilities()
+ .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+ .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
+ .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
+ .addTransportType(NetworkCapabilities.TRANSPORT_VPN)
+ .build()
+ runCatching { manager.registerNetworkCallback(request, cb) }
+ .onFailure { registerError = it.message ?: it.javaClass.simpleName; callback = null }
+ }
+
+ override suspend fun stop(): Test {
+ callback?.let { cb -> runCatching { cm?.unregisterNetworkCallback(cb) } }
+ callback = null
+ val snapshot = synchronized(changes) { changes.toList() }
+
+ if (registerError != null) {
+ return build(
+ TestStatus.UNSUPPORTED,
+ error = TestError("callback_unavailable", registerError),
+ )
+ }
+
+ val evidence: JsonObject = buildJsonObject {
+ putJsonArray("changes") {
+ for (c in snapshot) addJsonObject {
+ put("at_mono_ns", c.atMonoNs)
+ put("kind", c.kind)
+ put("event", c.event)
+ put("interface", c.iface)
+ put("detail", c.detail)
+ }
+ }
+ }
+ val perIface = snapshot.groupBy { it.iface }
+ val metrics: JsonObject = buildJsonObject {
+ put("changes_total", snapshot.size)
+ put("networks_lost", snapshot.count { it.kind == NetworkChanges.LOST })
+ put("networks_gained", snapshot.count { it.event == "available" })
+ put("link_changes", snapshot.count { it.kind == NetworkChanges.LINK_CHANGED })
+ // The number a reader actually wants: how many times a link went away and came back.
+ // Counted by the same function the flapping finding uses, so the metric and the
+ // finding can never tell different stories about one window.
+ put("flap_cycles", perIface.values.sumOf { NetworkChanges.flapCycles(it.map { c -> c.kind }) })
+ }
+ // Zero changes over the window is a real, useful result — a stable network — so it is OK
+ // rather than a failure. The window that produced it is what makes that mean anything, and
+ // it is recorded in run.mode plus the sibling collectors' params.
+ return build(TestStatus.OK, evidence = evidence, metrics = metrics)
+ }
+
+ /**
+ * The changes belonging to each network in `networks[]`, keyed by its model id.
+ *
+ * Matched by interface name rather than by Android's `Network` handle, because a link that
+ * drops and returns comes back as a *different* handle with the same interface — and the
+ * flapping case is precisely the one this must not lose. Changes on an interface that was not
+ * in the run's initial snapshot stay in the test evidence but have no `networks[]` entry to
+ * hang from.
+ */
+ fun changesByNetwork(): Map> {
+ val byIface = entries.mapNotNull { e -> e.model.iface?.let { it to e.model.id } }.toMap()
+ val out = LinkedHashMap>()
+ for (c in synchronized(changes) { changes.toList() }) {
+ val id = byIface[c.iface] ?: continue
+ out.getOrPut(id) { mutableListOf() }.add(
+ NetworkChange(
+ atMonoNs = c.atMonoNs,
+ kind = c.kind,
+ detail = buildJsonObject {
+ put("event", c.event)
+ put("interface", c.iface)
+ put("detail", c.detail)
+ },
+ )
+ )
+ }
+ return out
+ }
+
+ private fun record(ids: ProbeIds, kind: String, event: String, iface: String, detail: String) {
+ changes.add(Change(ids.monoNs(), kind, event, iface, detail))
+ }
+
+ private fun resolveIface(network: Network): String {
+ val known = ifaceOf[key(network)]
+ if (known != null) return known
+ val name = runCatching { cm?.getLinkProperties(network)?.interfaceName }.getOrNull()
+ if (name != null) ifaceOf[key(network)] = name
+ return name ?: "(unknown)"
+ }
+
+ private companion object {
+ /** Android's own network id, stable for the life of one Network object. */
+ private fun key(n: Network): String = n.toString()
+
+ /**
+ * Only the capabilities whose change means something diagnostically.
+ *
+ * Bandwidth estimates and signal strength are deliberately excluded: they change every few
+ * seconds on a moving device, and including them turns a change log into a sampling log.
+ * VALIDATED and CAPTIVE_PORTAL are the two that matter most — they are the moment Android
+ * decides a network does or does not carry the internet.
+ */
+ private fun capsFingerprint(c: NetworkCapabilities): String = buildString {
+ fun flag(name: String, cap: Int) {
+ if (runCatching { c.hasCapability(cap) }.getOrDefault(false)) append(name).append(' ')
+ }
+ flag("internet", NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ flag("validated", NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+ flag("captive", NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)
+ flag("not-metered", NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
+ flag("not-suspended", NET_CAPABILITY_NOT_SUSPENDED)
+ flag("not-restricted", NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
+ }.trim().ifEmpty { "(none)" }
+
+ /** NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED, API 28+ (@SystemApi constant). */
+ private const val NET_CAPABILITY_NOT_SUSPENDED = 21
+
+ private fun linkFingerprint(lp: LinkProperties): String {
+ val addrs = lp.linkAddresses.map { it.toString() }.sorted().joinToString(",")
+ val routes = lp.routes.map { it.toString() }.sorted().joinToString(",")
+ val dns = lp.dnsServers.mapNotNull { it.hostAddress }.sorted().joinToString(",")
+ return "mtu=${lp.mtu}|addr=$addrs|route=$routes|dns=$dns"
+ }
+
+ /** Names which part of the link changed, so the detail is readable without a diff tool. */
+ private fun describeLinkDelta(before: String, after: String): String {
+ val b = before.split('|'); val a = after.split('|')
+ val changed = b.indices.filter { it < a.size && b[it] != a[it] }
+ .map { a[it].substringBefore('=') }
+ return if (changed.isEmpty()) "changed" else "changed: ${changed.joinToString(", ")}"
+ }
+ }
+}
diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/PingSeriesCollector.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/PingSeriesCollector.kt
new file mode 100644
index 0000000..cfbe9d3
--- /dev/null
+++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/PingSeriesCollector.kt
@@ -0,0 +1,158 @@
+// SPDX-FileCopyrightText: 2026 Echolot contributors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app.echo_lot.probe
+
+import android.content.Context
+import app.echo_lot.measurement.Test
+import app.echo_lot.measurement.TestStatus
+import app.echo_lot.measurement.TestType
+import app.echo_lot.measurement.Tier
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import kotlinx.serialization.json.putJsonArray
+import kotlinx.serialization.json.add
+import java.util.Collections
+
+/**
+ * icmp.ping4 sampled across the window — loss and jitter over minutes instead of one packet.
+ *
+ * The battery's [IcmpProbe] answers "does this network reply at all", which one echo can settle.
+ * It cannot answer "how often does it not", and that is the complaint people actually have:
+ * 2 % loss is invisible to a single ping and ruins a video call. A series over five minutes also
+ * catches loss that comes in bursts, which an average taken over ten packets in one second cannot
+ * distinguish from a clean link.
+ *
+ * Emitted as its own `icmp.ping4` test alongside the battery's. `params` carries the window and
+ * the interval precisely so the two are never mistaken for each other — a reader seeing 300 sent
+ * packets in one and 1 in the other must be able to tell which is which without guessing.
+ *
+ * Sustained loss here deliberately emits **no finding**. Every loss code in the registry is about
+ * the server path — `connectivity.udp_loss` and its directional siblings all say "UDP", and they
+ * mean the probe protocol's traffic, whose direction the server can attest to. ICMP echo to a
+ * public address is a different measurement with a different set of benign explanations (rate
+ * limiting at the target is the obvious one), and borrowing a code that claims otherwise would put
+ * two unrelated things under one dashboard entry — the exact failure the registry exists to
+ * prevent. The metrics say what was seen; a code for it can be added when it has been defined.
+ */
+class PingSeriesCollector(
+ private val target: String = "1.1.1.1",
+ private val intervalMs: Long = 2_000,
+ /** Deliberately below [intervalMs]: a reply that arrives after the next probe was due is lost
+ * for any practical purpose, and waiting for it would make the series drift out of cadence. */
+ private val timeoutMs: Int = 1_500,
+) : BaseCollector() {
+
+ override val type = TestType.ICMP_PING4
+ override val tier = Tier.APP
+
+ private val txMonoNs: MutableList = Collections.synchronizedList(mutableListOf())
+ private val rttMs: MutableList = Collections.synchronizedList(mutableListOf())
+ private var notSent = 0
+ private var scope: CoroutineScope? = null
+ private var startedAtMonoNs = 0L
+
+ override suspend fun start(ctx: Context, ids: ProbeIds) {
+ begin(ids)
+ startedAtMonoNs = ids.monoNs()
+ // The default network, and only the default network: this measures what the device's own
+ // traffic experiences over the window. Per-network binding is the battery's job, and doing
+ // it here would multiply the packet rate by the number of interfaces for no new answer.
+ val s = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it }
+ s.launch {
+ var seq = 1
+ while (isActive) {
+ val t0 = ids.monoNs()
+ val r = IcmpEcho.ping(null, target, v6 = false, timeoutMs = timeoutMs, seq = seq)
+ if (r.attempted) {
+ txMonoNs.add(t0)
+ rttMs.add(r.rttMs)
+ } else {
+ // Never left the device — a socket or bind failure is not packet loss, and
+ // counting it as loss would blame the network for the app's own trouble.
+ notSent++
+ }
+ seq = (seq + 1) and 0xFFFF
+ delay(intervalMs)
+ }
+ }
+ }
+
+ override suspend fun stop(): Test {
+ scope?.coroutineContext?.get(Job)?.cancel()
+ scope = null
+
+ val tx = synchronized(txMonoNs) { txMonoNs.toList() }
+ val rtt = synchronized(rttMs) { rttMs.toList() }
+ val received = rtt.filterNotNull()
+
+ val evidence: JsonObject = buildJsonObject {
+ putJsonArray("seq") { for (i in tx.indices) add(i) }
+ putJsonArray("t_tx_ns") { for (v in tx) add(v) }
+ // null at an index is a lost probe, per the §6.2 columnar convention.
+ putJsonArray("rtt_ms") {
+ for (v in rtt) add(v?.let { JsonPrimitive(round1(it)) } ?: JsonNull)
+ }
+ }
+ val metrics: JsonObject = buildJsonObject {
+ put("sent", tx.size)
+ put("received", received.size)
+ put("not_sent", notSent)
+ if (tx.isNotEmpty()) {
+ put("loss_pct", round1((tx.size - received.size) * 100.0 / tx.size))
+ }
+ if (received.isNotEmpty()) {
+ put("rtt_ms_min", round1(received.min()))
+ put("rtt_ms_avg", round1(received.average()))
+ put("rtt_ms_max", round1(received.max()))
+ put("jitter_ms", round1(meanDeviation(received)))
+ }
+ }
+ val status = when {
+ tx.isEmpty() -> TestStatus.UNSUPPORTED
+ received.isEmpty() -> TestStatus.FAILED
+ received.size < tx.size -> TestStatus.PARTIAL
+ else -> TestStatus.OK
+ }
+ return build(status, evidence = evidence, metrics = metrics, params = params())
+ }
+
+ private fun params(): JsonObject = buildJsonObject {
+ // What separates this from the battery's single ping, and what a reader needs to reproduce
+ // it. Without these two numbers "300 packets, 2 % loss" is a rate nobody can interpret.
+ put("mode", "series")
+ put("target", target)
+ put("interval_ms", intervalMs)
+ put("timeout_ms", timeoutMs)
+ put("started_mono_ns", startedAtMonoNs)
+ put("network", "default")
+ }
+
+ private companion object {
+ fun round1(v: Double) = Math.round(v * 10.0) / 10.0
+
+ /**
+ * Mean deviation between consecutive round trips — jitter as a stream experiences it.
+ *
+ * Not the spread around the average: a link that alternates 20 ms / 200 ms and one that
+ * drifts slowly from 20 ms to 200 ms have the same standard deviation, and only the first
+ * one breaks a call.
+ */
+ fun meanDeviation(values: List): Double {
+ if (values.size < 2) return 0.0
+ var sum = 0.0
+ for (i in 1 until values.size) sum += kotlin.math.abs(values[i] - values[i - 1])
+ return sum / (values.size - 1)
+ }
+ }
+}
diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/ProbeCollector.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/ProbeCollector.kt
new file mode 100644
index 0000000..4bee342
--- /dev/null
+++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/ProbeCollector.kt
@@ -0,0 +1,69 @@
+// SPDX-FileCopyrightText: 2026 Echolot contributors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app.echo_lot.probe
+
+import android.content.Context
+import app.echo_lot.measurement.Test
+import app.echo_lot.measurement.TestError
+import app.echo_lot.measurement.TestStatus
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.withTimeoutOrNull
+
+/**
+ * Runs an ordinary [Probe] beside the battery instead of inside it.
+ *
+ * Some probes are already listeners with a fixed window — [MdnsInventoryProbe] does nothing but
+ * wait for answers — and in a long run their window should be the run's window. Running them in
+ * the sequential battery would then stall every probe behind them for five minutes, which is a
+ * scheduling problem and not a measurement one, so the fix is to move them rather than to shorten
+ * them.
+ *
+ * Durations are deliberately *not* fed back into the estimate learning: a probe that listens for
+ * the whole window would teach the short-mode progress bar that mDNS discovery takes five minutes.
+ */
+class ProbeCollector(private val probe: Probe) : BaseCollector() {
+
+ override val type get() = probe.type
+ override val tier get() = probe.tier
+
+ private var scope: CoroutineScope? = null
+ private var running: Deferred? = null
+
+ override suspend fun start(ctx: Context, ids: ProbeIds) {
+ begin(ids)
+ val s = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it }
+ running = s.async { probe.run(ctx, ids) }
+ }
+
+ override suspend fun stop(): Test {
+ val job = running
+ running = null
+ val finished = job?.let {
+ // A short grace, not a long one. A probe timed to the window has already finished by
+ // the time this is called, so the normal path returns instantly; the grace only covers
+ // it being slightly late. It is deliberately kept to a second and a half because the
+ // other caller is the Cancel button, where every millisecond spent waiting for a
+ // listener that will not finish is a millisecond the user watches nothing happen.
+ withTimeoutOrNull(GRACE_MS) { runCatching { it.await() }.getOrNull() }
+ }
+ scope?.coroutineContext?.get(Job)?.cancel()
+ scope = null
+ return finished ?: build(
+ TestStatus.PARTIAL,
+ error = TestError(
+ "window_closed",
+ "the run's window ended before this listener finished",
+ ),
+ )
+ }
+
+ private companion object {
+ const val GRACE_MS = 1_500L
+ }
+}
diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/WifiSignalCollector.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/WifiSignalCollector.kt
new file mode 100644
index 0000000..cf7997f
--- /dev/null
+++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/WifiSignalCollector.kt
@@ -0,0 +1,165 @@
+// SPDX-FileCopyrightText: 2026 Echolot contributors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app.echo_lot.probe
+
+import android.content.Context
+import android.net.wifi.WifiManager
+import app.echo_lot.measurement.Test
+import app.echo_lot.measurement.TestError
+import app.echo_lot.measurement.TestStatus
+import app.echo_lot.measurement.TestType
+import app.echo_lot.measurement.Tier
+import app.echo_lot.measurement.Transport
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import kotlinx.serialization.json.putJsonArray
+import kotlinx.serialization.json.add
+import java.util.Collections
+
+/**
+ * wifi.signal_log — RSSI, link speed and frequency sampled across the whole window.
+ *
+ * One reading of the signal strength says almost nothing: -67 dBm is fine, and -67 dBm that was
+ * -45 dBm ninety seconds ago is somebody walking away from the AP, or an AP whose power is being
+ * managed, or a band steer about to happen. The series is the measurement; the snapshot in
+ * `networks[].wifi` is only its first sample.
+ *
+ * Evidence is columnar (measurement-schema §6.2 conventions): parallel arrays keep a five-minute
+ * log at 2 s intervals in a few kB.
+ */
+class WifiSignalCollector(
+ private val entries: List,
+ private val intervalMs: Long = 2_000,
+) : BaseCollector() {
+
+ override val type = TestType.WIFI_SIGNAL_LOG
+ override val tier = Tier.APP
+
+ private val atMonoNs: MutableList = Collections.synchronizedList(mutableListOf())
+ private val rssi: MutableList = Collections.synchronizedList(mutableListOf())
+ private val speed: MutableList = Collections.synchronizedList(mutableListOf())
+ private val freq: MutableList = Collections.synchronizedList(mutableListOf())
+ /** Only the count of distinct BSSIDs leaves this class — a roam is the fact worth reporting,
+ * and the addresses themselves are neighbours' hardware identifiers. */
+ private val bssids = Collections.synchronizedSet(HashSet())
+
+ private var scope: CoroutineScope? = null
+ private var unsupported: String? = null
+ private var startedAtMonoNs = 0L
+
+ override suspend fun start(ctx: Context, ids: ProbeIds) {
+ // network_ref up front: this samples the wifi link, and a signal log with nothing to
+ // attach it to is a series of numbers about an unnamed thing.
+ val wifiNet = entries.firstOrNull { it.model.transport == Transport.WIFI }
+ begin(ids, networkRef = wifiNet?.model?.id)
+ startedAtMonoNs = ids.monoNs()
+
+ val wifi = ctx.applicationContext.getSystemService(WifiManager::class.java)
+ if (wifi == null) {
+ unsupported = "WifiManager unavailable"
+ return
+ }
+ if (wifiNet == null) {
+ unsupported = "no wifi network is connected"
+ return
+ }
+ // Own scope, not the caller's: the run job is cancelled the instant the user taps Cancel,
+ // and the samples taken up to that point are exactly what a cancelled long run still owes
+ // them. stop() ends this scope.
+ val s = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it }
+ s.launch {
+ while (isActive) {
+ sample(ids, wifi)
+ delay(intervalMs)
+ }
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun sample(ids: ProbeIds, wifi: WifiManager) {
+ // WifiManager.getConnectionInfo is deprecated in favour of the NetworkCallback's
+ // TransportInfo, which delivers a WifiInfo only when the capabilities change — i.e. at the
+ // platform's cadence, not ours, and with no way to ask for a sample. For a fixed-interval
+ // log the deprecated call is the one that answers the question, and it still works.
+ val info = runCatching { wifi.connectionInfo }.getOrNull() ?: return
+ val r = info.rssi
+ // -127 and 0 are the "no reading" sentinels; recording them would drag every average down
+ // and invent a signal cliff that never happened.
+ if (r == 0 || r <= -127) return
+ atMonoNs.add(ids.monoNs())
+ rssi.add(r)
+ speed.add(info.linkSpeed)
+ freq.add(runCatching { info.frequency }.getOrDefault(0))
+ runCatching { info.bssid }.getOrNull()
+ ?.takeIf { it.isNotBlank() && it != "02:00:00:00:00:00" }
+ ?.let { bssids.add(it) }
+ }
+
+ override suspend fun stop(): Test {
+ scope?.coroutineContext?.get(Job)?.cancel()
+ scope = null
+
+ unsupported?.let {
+ return build(
+ TestStatus.UNSUPPORTED,
+ params = params(),
+ error = TestError("no_wifi", it),
+ )
+ }
+
+ val t = synchronized(atMonoNs) { atMonoNs.toList() }
+ val r = synchronized(rssi) { rssi.toList() }
+ val sp = synchronized(speed) { speed.toList() }
+ val f = synchronized(freq) { freq.toList() }
+
+ val evidence: JsonObject = buildJsonObject {
+ putJsonArray("at_mono_ns") { for (v in t) add(v) }
+ putJsonArray("rssi_dbm") { for (v in r) add(v) }
+ putJsonArray("link_speed_mbps") { for (v in sp) add(v) }
+ putJsonArray("frequency_mhz") { for (v in f) add(v) }
+ }
+ val metrics: JsonObject = buildJsonObject {
+ put("samples", r.size)
+ if (r.isNotEmpty()) {
+ put("rssi_dbm_min", r.min())
+ put("rssi_dbm_avg", round1(r.average()))
+ put("rssi_dbm_max", r.max())
+ put("rssi_dbm_range", r.max() - r.min())
+ }
+ sp.filter { it > 0 }.let { valid ->
+ if (valid.isNotEmpty()) {
+ put("link_speed_mbps_min", valid.min())
+ put("link_speed_mbps_avg", round1(valid.average()))
+ put("link_speed_mbps_max", valid.max())
+ }
+ }
+ // Distinct BSSIDs minus the one we started on: how often the phone changed AP without
+ // the network ever going down — invisible to any one-shot probe, and a common cause of
+ // "the call drops when I walk into the kitchen".
+ put("roams", (bssids.size - 1).coerceAtLeast(0))
+ }
+ return build(
+ if (r.isEmpty()) TestStatus.PARTIAL else TestStatus.OK,
+ evidence = evidence, metrics = metrics, params = params(),
+ )
+ }
+
+ private fun params(): JsonObject = buildJsonObject {
+ put("interval_ms", intervalMs)
+ put("started_mono_ns", startedAtMonoNs)
+ put("source", "WifiManager.connectionInfo")
+ }
+
+ private companion object {
+ fun round1(v: Double) = Math.round(v * 10.0) / 10.0
+ }
+}
diff --git a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt
index 083eb62..bfb8747 100644
--- a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt
+++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt
@@ -209,6 +209,36 @@ class ControlClient(
}
}
+ /**
+ * Reports where adbd's wireless-debug listener can be reached on this device's LAN.
+ *
+ * Dev scaffolding, not a measurement: it exists because mDNS does not cross subnets, so a
+ * developer working from a different network cannot discover the port that rotates every few
+ * minutes. A device sitting on the test LAN can see it and say so. Deliberately not part of
+ * the probe protocol's capability set — the server documents it as a dev relay.
+ */
+ fun reportAdbEndpoint(
+ credential: String,
+ host: String,
+ port: Int,
+ deviceName: String? = null,
+ note: String? = null,
+ ): String {
+ val conn = open("/v1/devtools/adb-endpoint", "POST", credential)
+ val fields = buildString {
+ append("""{"host":${jstr(host)},"port":$port""")
+ deviceName?.let { append(""","device_name":${jstr(it)}""") }
+ note?.let { append(""","note":${jstr(it)}""") }
+ append("}")
+ }
+ writeJson(conn, fields)
+ val body = body(conn)
+ check(conn.responseCode in 200..299) {
+ "adb endpoint report failed: ${conn.responseCode} $body"
+ }
+ return body
+ }
+
/** Lists this device's runs stored on the server. */
fun listRuns(credential: String): String {
val conn = open("/v1/runs", "GET", credential)