app: pick the discovery listeners, and say when one was not asked

They differ in kind from the rest of the battery: they record what OTHER
devices broadcast about themselves - hostnames, models, printer names -
so which of them run is a choice per run rather than a default to
inherit. Chips beside the mode, shown only for long runs, where a passive
listener means anything.

A listener that was switched off emits a SKIPPED test with its reason
rather than vanishing. Same argument as run.mode: a document where
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.

NetBIOS is the one that defaults off - at app tier it can only report
unsupported, and an on-by-default listener guaranteed to fail teaches
people to ignore the status column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 15:13:44 +02:00
co-authored by Claude Opus 5
parent 9ee7d554a6
commit c414534e03
3 changed files with 156 additions and 7 deletions
@@ -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")
@@ -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<app.echo_lot.probe.Collector> {
val out = ArrayList<app.echo_lot.probe.Collector>(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<Test> {
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
@@ -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 -> ""
}
}