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)