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:
co-authored by
Claude Opus 5
parent
9ee7d554a6
commit
c414534e03
@@ -259,6 +259,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
Screen.RUN -> EcholotScreen(
|
Screen.RUN -> EcholotScreen(
|
||||||
state = vm.state,
|
state = vm.state,
|
||||||
longMinutes = vm.settings.longRunMinutes,
|
longMinutes = vm.settings.longRunMinutes,
|
||||||
|
discoveryEnabled = { vm.settings.discoveryEnabled(it) },
|
||||||
|
onDiscoveryChange = { id, on -> vm.settings.setDiscoveryEnabled(id, on) },
|
||||||
onRun = { mode -> vm.run(mode) },
|
onRun = { mode -> vm.run(mode) },
|
||||||
onCancel = vm::cancel,
|
onCancel = vm::cancel,
|
||||||
onDeveloperOptions = {
|
onDeveloperOptions = {
|
||||||
@@ -336,9 +338,15 @@ private fun statusColor(s: TestStatus): Color = when (s) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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(
|
private fun EcholotScreen(
|
||||||
state: UiState,
|
state: UiState,
|
||||||
longMinutes: Int,
|
longMinutes: Int,
|
||||||
|
/** Which discovery listeners are selected, and how to change one. */
|
||||||
|
discoveryEnabled: (String) -> Boolean,
|
||||||
|
onDiscoveryChange: (String, Boolean) -> Unit,
|
||||||
onRun: (RunMode) -> Unit,
|
onRun: (RunMode) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onShizukuAction: () -> Unit,
|
onShizukuAction: () -> Unit,
|
||||||
@@ -431,6 +439,40 @@ private fun EcholotScreen(
|
|||||||
fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Button(onClick = { onRun(mode) }, enabled = !state.running) {
|
Button(onClick = { onRun(mode) }, enabled = !state.running) {
|
||||||
Text(if (state.running) "Running…" else "Run measurement")
|
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
|
* to `networks[]`. Idempotent — the normal path and the cancel path both call it, and only the
|
||||||
* first does anything.
|
* 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> {
|
private suspend fun stopCollectors(): List<Test> {
|
||||||
val running = activeCollectors
|
val running = activeCollectors
|
||||||
activeCollectors = emptyList()
|
activeCollectors = emptyList()
|
||||||
@@ -507,13 +561,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
// knowing it will usually report `unsupported` — UDP 137 is privileged, so the
|
// 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
|
// app tier cannot bind it — because a recorded reason beats an absent test, and
|
||||||
// the decoder is ready for the Shizuku tier.
|
// the decoder is ready for the Shizuku tier.
|
||||||
app.echo_lot.probe.SsdpCollector(
|
) + discoveryCollectors(windowMs)
|
||||||
searchIntervalMs = (windowMs / 5).coerceAtLeast(30_000),
|
|
||||||
),
|
|
||||||
app.echo_lot.probe.LlmnrCollector(),
|
|
||||||
app.echo_lot.probe.NetbiosCollector(),
|
|
||||||
app.echo_lot.probe.WsdCollector(),
|
|
||||||
)
|
|
||||||
activeCollectors = collectors
|
activeCollectors = collectors
|
||||||
for (c in collectors) runCatching { c.start(ctx, ids) }
|
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
|
// 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 =
|
private val prefs: SharedPreferences =
|
||||||
context.getSharedPreferences("echolot-settings", Context.MODE_PRIVATE)
|
context.getSharedPreferences("echolot-settings", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
|
||||||
// ---- archive ---------------------------------------------------------------------
|
// ---- archive ---------------------------------------------------------------------
|
||||||
|
|
||||||
var archiveEnabled: Boolean
|
var archiveEnabled: Boolean
|
||||||
@@ -80,6 +81,28 @@ class Settings(context: Context) {
|
|||||||
get() = prefs.getBoolean(ADB_RELAY, false)
|
get() = prefs.getBoolean(ADB_RELAY, false)
|
||||||
set(v) = prefs.edit().putBoolean(ADB_RELAY, v).apply()
|
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 ---------------------------------------------------------
|
// ---- run-duration learning ---------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -276,6 +299,42 @@ class Settings(context: Context) {
|
|||||||
const val ACCOUNT_ID = "account_id"
|
const val ACCOUNT_ID = "account_id"
|
||||||
const val DURATION_PREFIX = "duration_ms."
|
const val DURATION_PREFIX = "duration_ms."
|
||||||
const val ADB_RELAY = "adb_relay_enabled"
|
const val ADB_RELAY = "adb_relay_enabled"
|
||||||
|
const val DISCOVERY_PREFIX = "discovery."
|
||||||
const val LONG_RUN_MINUTES = "long_run_minutes"
|
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 -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user