app: long runs that watch, and a relay for the port that keeps moving

Long mode starts listeners at t=0 and keeps them running past the
battery: a network-change watcher that finally fills networks[].changes[]
(defined since the schema's first draft, never populated), an RSSI log, a
ping series giving loss and jitter over minutes, and mDNS listening for
the whole window. This is the class of fault a short run cannot see - a
link that drops for four seconds between two probes is reported healthy
by both of them. run.mode records which question was asked, because
silence means different things in the two modes.

The adb relay replaces the retired beacon: AdbRelay watches adbd's own
mDNS with the resolve-once discipline the beacon learned the hard way
(resolving re-arms adbd and pops a notification), a foreground service
keeps it alive with the screen off, and the heartbeat re-posts the cached
endpoint rather than re-resolving. It exists because mDNS does not cross
subnets and the wireless-debug port rotates every few minutes.

Also records why LLDP/CDP cannot follow SSDP into long mode: both are raw
L2 frames, so they need CAP_NET_RAW - root tier, not app, and Shizuku's
shell user does not have it either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 14:43:33 +02:00
co-authored by Claude Opus 5
parent ae63bd7c7f
commit 0071e00003
24 changed files with 1834 additions and 107 deletions
@@ -10,6 +10,17 @@
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!--
The adb relay (AdbRelayService) only. It runs in the foreground because it must keep
watching while the tablet sits unattended with its screen off, and dataSync is the type
that describes it: it carries an observation off the LAN, nothing more. Android 15 caps
dataSync at a few hours a day, which is acceptable for a tool that is switched on for a
debugging session rather than left running forever.
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Only so the relay's ongoing status is visible; the service runs either way. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="false"
@@ -53,6 +64,15 @@
</intent-filter>
</activity>
<!--
Not exported: nothing outside this app has any business starting a relay that reports
where this device can be reached.
-->
<service
android:name=".AdbRelayService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -0,0 +1,136 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.net.wifi.WifiManager
import java.net.Inet4Address
/**
* Watches adbd's own mDNS advertisement on this device and reports the endpoint to the server.
*
* This replaces the retired Python adb-beacon, and exists for one reason: **mDNS does not cross
* subnets**. A developer on another network cannot see `_adb-tls-connect._tcp` at all, while the
* wireless-debug port rotates every few minutes — so the port has to be carried out of the LAN by
* something sitting inside it. That is this. A tablet parked on the test network relays; the
* developer reads the endpoint back from the server.
*
* Two hard-won rules from the beacon, both load-bearing:
*
* - **Resolve each service instance exactly ONCE.** Resolving adbd's advertisement makes adbd
* re-arm its connection and post a "wireless debugging connected" notification; re-resolving on
* every heartbeat turns that into a stream of them. The guard is cleared only when the service
* is *lost*, which is also what catches rotation: the new advertisement is a new instance, gets
* resolved once, and is reported within seconds.
* - **Do not run this on the OnePlus.** On network churn that device drops and re-publishes its
* advertisement repeatedly, so lost/found cycles keep clearing the guard and each resolve
* re-arms adbd. Guarding reduces but cannot eliminate the noise; the Lenovo tablet is the
* intended host, which is also why relaying is a mode rather than something always on.
*/
class AdbRelay(
private val ctx: Context,
private val onEvent: (String) -> 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<String>()
/** 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"
}
}
@@ -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))
}
}
}
@@ -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<String, CategorySummary>) {
}
}
/** 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)) {}
@@ -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<app.echo_lot.measurement.Network> = 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<app.echo_lot.probe.Collector> = 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<Test> {
val running = activeCollectors
activeCollectors = emptyList()
val out = ArrayList<Test>()
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<Application>()
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<Probe> = 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<app.echo_lot.probe.Collector>(
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<Probe> = 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.
@@ -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"
}
}
@@ -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))
}
}