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 cec71d5..216ce08 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 @@ -259,6 +259,8 @@ class MainActivity : ComponentActivity() { Screen.RUN -> EcholotScreen( state = vm.state, longMinutes = vm.settings.longRunMinutes, + discoveryEnabled = { vm.settings.discoveryEnabled(it) }, + onDiscoveryChange = { id, on -> vm.settings.setDiscoveryEnabled(id, on) }, onRun = { mode -> vm.run(mode) }, onCancel = vm::cancel, onDeveloperOptions = { @@ -336,9 +338,15 @@ private fun statusColor(s: TestStatus): Color = when (s) { } @Composable +// FlowRow: the discovery chips wrap rather than overflow on a narrow phone. Experimental only in +// the sense that its API may gain parameters; the layout itself has been stable for releases. +@OptIn(ExperimentalLayoutApi::class) private fun EcholotScreen( state: UiState, longMinutes: Int, + /** Which discovery listeners are selected, and how to change one. */ + discoveryEnabled: (String) -> Boolean, + onDiscoveryChange: (String, Boolean) -> Unit, onRun: (RunMode) -> Unit, onCancel: () -> Unit, onShizukuAction: () -> Unit, @@ -431,6 +439,40 @@ private fun EcholotScreen( fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + // Discovery listeners, chosen the same way as the mode and only where they mean anything. + // They belong to the window: a passive listener in a quick run would mostly hear silence + // and report an empty network as confidently as a quiet one. + if (mode == RunMode.LONG) { + var discovery by remember { + mutableStateOf(DiscoveryIds.ALL.filter(discoveryEnabled).toSet()) + } + Text( + "Also listen for", + fontSize = 12.sp, fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 4.dp), + ) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (id in DiscoveryIds.ALL) { + val on = id in discovery + FilterChip( + selected = on, + onClick = { + val next = !on + onDiscoveryChange(id, next) + discovery = if (next) discovery + id else discovery - id + }, + enabled = !state.running, + label = { Text(DiscoveryIds.label(id)) }, + ) + } + } + Text( + discovery.joinToString(" · ") { DiscoveryIds.blurb(it) } + .ifBlank { "Nothing extra — just the measurements above." }, + fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { Button(onClick = { onRun(mode) }, enabled = !state.running) { Text(if (state.running) "Running…" else "Run measurement") 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 16c76ba..513a00c 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 @@ -444,6 +444,60 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { * to `networks[]`. Idempotent — the normal path and the cancel path both call it, and only the * first does anything. */ + /** + * The discovery listeners the user selected for this run. + * + * Searches are paced across the window rather than fired at the start, so a device that was + * asleep for the first minute is still asked. + */ + private fun discoveryCollectors(windowMs: Long): List { + val out = ArrayList(4) + if (settings.discoveryEnabled(DiscoveryIds.SSDP)) { + out.add( + app.echo_lot.probe.SsdpCollector( + searchIntervalMs = (windowMs / 5).coerceAtLeast(30_000), + ) + ) + } + if (settings.discoveryEnabled(DiscoveryIds.WSD)) out.add(app.echo_lot.probe.WsdCollector()) + if (settings.discoveryEnabled(DiscoveryIds.LLMNR)) out.add(app.echo_lot.probe.LlmnrCollector()) + if (settings.discoveryEnabled(DiscoveryIds.NETBIOS)) out.add(app.echo_lot.probe.NetbiosCollector()) + return out + } + + /** + * A test recording that a listener was switched off, so its absence is never mistaken for its + * silence. + * + * The same reasoning as `run.mode`: a document in which `local.ssdp_inventory` is simply + * missing cannot tell a reader whether nothing announced itself or nobody was listening, and + * those are opposite conclusions about a network. SKIPPED with a reason is how the canary and + * STUN probes already say "not asked", so it is the shape a consumer already understands. + */ + private fun notSelected(type: String, ids: ProbeIds): Test { + val at = ids.monoNs() + return Test( + id = ids.uuid(), type = type, tier = Tier.APP, + startedMonoNs = at, endedMonoNs = at, + status = TestStatus.SKIPPED, + evidence = kotlinx.serialization.json.JsonObject( + mapOf( + "reason" to kotlinx.serialization.json.JsonPrimitive( + "listener not selected for this run" + ) + ) + ), + ) + } + + /** Registry ids for the selectable listeners, so the skipped-test record uses the real type. */ + private fun discoveryType(id: String): String = when (id) { + DiscoveryIds.SSDP -> TestType.LOCAL_SSDP_INVENTORY + DiscoveryIds.WSD -> TestType.LOCAL_WSD_INVENTORY + DiscoveryIds.LLMNR -> TestType.LOCAL_LLMNR_INVENTORY + else -> TestType.LOCAL_NETBIOS_INVENTORY + } + private suspend fun stopCollectors(): List { val running = activeCollectors activeCollectors = emptyList() @@ -507,13 +561,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { // knowing it will usually report `unsupported` — UDP 137 is privileged, so the // app tier cannot bind it — because a recorded reason beats an absent test, and // the decoder is ready for the Shizuku tier. - app.echo_lot.probe.SsdpCollector( - searchIntervalMs = (windowMs / 5).coerceAtLeast(30_000), - ), - app.echo_lot.probe.LlmnrCollector(), - app.echo_lot.probe.NetbiosCollector(), - app.echo_lot.probe.WsdCollector(), - ) + ) + discoveryCollectors(windowMs) 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 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 a8dbb24..93e3dab 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 @@ -24,6 +24,7 @@ class Settings(context: Context) { private val prefs: SharedPreferences = context.getSharedPreferences("echolot-settings", Context.MODE_PRIVATE) + // ---- archive --------------------------------------------------------------------- var archiveEnabled: Boolean @@ -80,6 +81,28 @@ class Settings(context: Context) { get() = prefs.getBoolean(ADB_RELAY, false) set(v) = prefs.edit().putBoolean(ADB_RELAY, v).apply() + // ---- discovery listeners ------------------------------------------------------------- + + /** + * Which passive discovery listeners a long run starts. + * + * Selectable rather than fixed because they differ from the rest of the battery in kind: they + * record what OTHER devices on the segment broadcast about themselves — hostnames, models, + * printer names — and that is someone's choice to make per run, not a default to inherit. + * They also cost nothing to leave off, since a listener that never starts cannot slow a run + * down. + * + * NetBIOS is off by default alone among them: UDP 137 is privileged, so at app tier it can + * only ever report `unsupported`, and shipping a listener that is guaranteed to fail as an + * on-by-default option would train people to ignore the status column. It stays selectable — + * the reason it reports is worth seeing once, and the tier that can bind it is coming. + */ + fun discoveryEnabled(id: String): Boolean = + prefs.getBoolean("$DISCOVERY_PREFIX$id", id != DiscoveryIds.NETBIOS) + + fun setDiscoveryEnabled(id: String, on: Boolean) = + prefs.edit().putBoolean("$DISCOVERY_PREFIX$id", on).apply() + // ---- run-duration learning --------------------------------------------------------- /** @@ -276,6 +299,42 @@ class Settings(context: Context) { const val ACCOUNT_ID = "account_id" const val DURATION_PREFIX = "duration_ms." const val ADB_RELAY = "adb_relay_enabled" + const val DISCOVERY_PREFIX = "discovery." const val LONG_RUN_MINUTES = "long_run_minutes" } } + +/** + * Ids for the selectable discovery listeners. + * + * Top-level rather than nested in [Settings] because a class gets exactly one companion object and + * that one is already spoken for by the preference keys, which stay private. Plain strings rather + * than an enum: they key a stored preference, so a listener being added or retired must not need a + * migration. + */ +object DiscoveryIds { + const val SSDP = "ssdp" + const val WSD = "wsd" + const val LLMNR = "llmnr" + const val NETBIOS = "netbios" + + /** Display order: device inventory first, then the legacy name-resolution pair. */ + val ALL = listOf(SSDP, WSD, LLMNR, NETBIOS) + + fun label(id: String): String = when (id) { + SSDP -> "SSDP" + WSD -> "WS-Discovery" + LLMNR -> "LLMNR" + NETBIOS -> "NetBIOS" + else -> id + } + + /** Why someone would want this one, in the few words a chip's helper line allows. */ + fun blurb(id: String): String = when (id) { + SSDP -> "UPnP devices announcing themselves" + WSD -> "printers, scanners, cameras" + LLMNR -> "Windows name lookups (and that it is enabled here)" + NETBIOS -> "legacy Windows names — needs a privileged port, so app tier reports why not" + else -> "" + } +}