app: progress bar with ETA, cancel button, and cutout-safe layout

- Probe.estimatedMs (measured per probe; timeout-bound ones dominate)
  drives a determinate progress bar and "test N of M · ~Xs left",
  including the Shizuku battery in the total.
- Cancel stops the run and shows the partial results as a normal document
  (findings + verdict over what was collected) but never uploads them.
- safeDrawingPadding() on the root column: Android 15 is edge-to-edge by
  default and the title was colliding with the status-bar clock and the
  camera cutout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 09:48:18 +02:00
co-authored by Claude Opus 5
parent 2e34463c0a
commit 217818f7b3
10 changed files with 127 additions and 24 deletions
+12
View File
@@ -495,3 +495,15 @@ The SSDP sweep also inventoried the LAN (Synology DS1522+ DSM 7.3, a Sky ES160 g
raw material for the planned LLDP/mDNS cross-matching by MAC. raw material for the planned LLDP/mDNS cross-matching by MAC.
Fixes from this run: added the confirmed MikroTik OUI 78:9A:18 (+ other RouterBOARD ranges), and Fixes from this run: added the confirmed MikroTik OUI 78:9A:18 (+ other RouterBOARD ranges), and
an elvis-operator bug that printed "no UPnP response" even when UPnP data was present. an elvis-operator bug that printed "no UPnP response" even when UPnP data was present.
### App UX: progress bar + ETA, cancel, and edge-to-edge insets (2026-08-01)
- **Progress + ETA**: `Probe.estimatedMs` (per-probe, from measured on-device durations — the
timeout-bound probes dominate: icmp.ping6 ~7s on a v4-only net, captive-portal ~9s, SSDP ~7s,
STUN ~6s) drives a determinate bar plus "test N of M · ~Xs left". The Shizuku battery is counted
in the total so the bar covers the whole run.
- **Cancel**: stops an in-flight run and shows what was measured so far, assembled into a normal
document (findings + verdict over the partial set). Deliberately **does not upload** — a partial
run is for the person looking at the screen, not for the record.
- **Insets/cutout**: Android 15 draws edge-to-edge by default, so the title was running under the
status-bar clock and the camera cutout. The root column now uses `safeDrawingPadding()`, which
covers status bar, navigation bar and display cutout.
@@ -63,6 +63,7 @@ class MainActivity : ComponentActivity() {
EcholotScreen( EcholotScreen(
state = vm.state, state = vm.state,
onRun = { vm.run() }, onRun = { vm.run() },
onCancel = vm::cancel,
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) }, onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
) )
} }
@@ -94,9 +95,21 @@ private fun statusColor(s: TestStatus): Color = when (s) {
} }
@Composable @Composable
private fun EcholotScreen(state: UiState, onRun: () -> Unit, onExport: (MeasurementDocument) -> Unit) { private fun EcholotScreen(
state: UiState,
onRun: () -> Unit,
onCancel: () -> Unit,
onExport: (MeasurementDocument) -> Unit,
) {
Column( Column(
Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()), Modifier
.fillMaxSize()
// Android 15 draws edge-to-edge by default: without this the title runs under the
// status-bar clock and the camera cutout. safeDrawing covers status/navigation bars
// AND the display cutout, so text never lands where it can't be read.
.safeDrawingPadding()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold) Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
@@ -106,6 +119,9 @@ private fun EcholotScreen(state: UiState, onRun: () -> Unit, onExport: (Measurem
Button(onClick = onRun, enabled = !state.running) { Button(onClick = onRun, enabled = !state.running) {
Text(if (state.running) "Running…" else "Run measurement") Text(if (state.running) "Running…" else "Run measurement")
} }
if (state.running) {
OutlinedButton(onClick = onCancel) { Text("Cancel") }
}
state.document?.let { doc -> state.document?.let { doc ->
OutlinedButton(onClick = { onExport(doc) }) { Text("Export JSON") } OutlinedButton(onClick = { onExport(doc) }) { Text("Export JSON") }
} }
@@ -116,9 +132,26 @@ private fun EcholotScreen(state: UiState, onRun: () -> Unit, onExport: (Measurem
} }
if (state.running) { if (state.running) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) val frac = if (state.stepsTotal > 0)
Text(state.currentStep ?: "", color = MaterialTheme.colorScheme.onSurfaceVariant) state.stepsDone.toFloat() / state.stepsTotal else 0f
LinearProgressIndicator(
progress = { frac },
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
if (state.stepsTotal > 0)
"test ${state.stepsDone + 1} 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) {
Text("~${state.etaSeconds}s left", fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
} }
} }
@@ -33,6 +33,10 @@ data class UiState(
val document: MeasurementDocument? = null, val document: MeasurementDocument? = null,
/** Result line of an automated upload (autorun mode); null when not attempted. */ /** Result line of an automated upload (autorun mode); null when not attempted. */
val uploadStatus: String? = null, val uploadStatus: String? = null,
/** Progress: tests finished / total, and a rough ETA from the remaining probes' estimates. */
val stepsDone: Int = 0,
val stepsTotal: Int = 0,
val etaSeconds: Int = 0,
) )
/** /**
@@ -46,6 +50,14 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
var state by mutableStateOf(UiState()) var state by mutableStateOf(UiState())
private set private set
private var runJob: kotlinx.coroutines.Job? = null
// Results collected so far. A cancelled run must still be able to show what it measured.
private val collected = mutableListOf<Test>()
private var runIds: RunIds = RunIds()
private var runStartWall: String = ""
private var runNetworks: List<app.echo_lot.measurement.Network> = emptyList()
private var runShizukuOk = false
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */ /** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
private class RunIds : ProbeIds { private class RunIds : ProbeIds {
val originNanos = System.nanoTime() val originNanos = System.nanoTime()
@@ -59,8 +71,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
*/ */
fun run(upload: Boolean = false) { fun run(upload: Boolean = false) {
if (state.running) return if (state.running) return
collected.clear()
state = state.copy(running = true, currentStep = "starting", document = null, uploadStatus = null) state = state.copy(running = true, currentStep = "starting", document = null, uploadStatus = null)
viewModelScope.launch { runJob = viewModelScope.launch {
val doc = withContext(Dispatchers.IO) { measure() } val doc = withContext(Dispatchers.IO) { measure() }
var status: String? = null var status: String? = null
if (upload) { if (upload) {
@@ -72,14 +85,28 @@ 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.
*/
fun cancel() {
if (!state.running) return
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",
)
}
private suspend fun measure(): MeasurementDocument { private suspend fun measure(): MeasurementDocument {
val ctx = getApplication<Application>() val ctx = getApplication<Application>()
val ids = RunIds() val ids = RunIds().also { runIds = it }
val startWall = Instant.now().toString() val startWall = Instant.now().toString().also { runStartWall = it }
step("reading networks") step("reading networks")
val entries = NetworkInventory.snapshot(ctx) val entries = NetworkInventory.snapshot(ctx)
val networks = entries.map { it.model } val networks = entries.map { it.model }.also { runNetworks = it }
val probes: List<Probe> = listOf( val probes: List<Probe> = listOf(
LinkSnapshotProbe(entries), LinkSnapshotProbe(entries),
@@ -93,10 +120,18 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
StunProbe(serverHost = "fmr-1.echo-lot.app"), StunProbe(serverHost = "fmr-1.echo-lot.app"),
) )
// Plan the run first: the Shizuku battery is counted alongside the app-tier probes so
// the bar reflects the whole run. Estimates are per-probe (see Probe.estimatedMs).
val shizukuEstimateMs = 8_000L
val totalSteps = probes.size + 1
var remainingMs = probes.sumOf { it.estimatedMs } + shizukuEstimateMs
state = state.copy(stepsDone = 0, stepsTotal = totalSteps,
etaSeconds = ((remainingMs + 999) / 1000).toInt())
val tests = ArrayList<Test>() val tests = ArrayList<Test>()
for (p in probes) { for ((i, p) in probes.withIndex()) {
step(p.type) step(p.type, done = i, total = totalSteps, etaMs = remainingMs)
tests.add( val result = (
try { try {
p.run(ctx, ids) p.run(ctx, ids)
} catch (t: Throwable) { } catch (t: Throwable) {
@@ -108,10 +143,12 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
) )
} }
) )
tests.add(result); collected.add(result)
remainingMs -= p.estimatedMs
} }
// Shizuku shell tier — self-degrades to UNSUPPORTED when Shizuku isn't running. // Shizuku shell tier — self-degrades to UNSUPPORTED when Shizuku isn't running.
step("shizuku.command_battery") step("link.ip_monitor (shizuku)", done = probes.size, total = totalSteps, etaMs = shizukuEstimateMs)
val shizukuTest = try { val shizukuTest = try {
ShizukuProbe().run(ctx, ids::uuid, ids::monoNs) ShizukuProbe().run(ctx, ids::uuid, ids::monoNs)
} catch (t: Throwable) { } catch (t: Throwable) {
@@ -121,17 +158,20 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
status = TestStatus.FAILED, error = TestError("uncaught", t.message ?: t.javaClass.simpleName), status = TestStatus.FAILED, error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
) )
} }
tests.add(shizukuTest) tests.add(shizukuTest); collected.add(shizukuTest)
val shizukuAvailable = shizukuTest.status == TestStatus.OK || shizukuTest.status == TestStatus.PARTIAL runShizukuOk = shizukuTest.status == TestStatus.OK || shizukuTest.status == TestStatus.PARTIAL
val findings = deriveFindings(tests, networks) return buildDocument(tests)
val summary = Verdicts.derive(tests, findings) }
/** Assembles a document from whatever tests are in hand — used for both full and cancelled runs. */
private fun buildDocument(tests: List<Test>): MeasurementDocument {
val findings = deriveFindings(tests, runNetworks)
return MeasurementDocument( return MeasurementDocument(
run = Run( run = Run(
id = ids.uuid(), trigger = Trigger.MANUAL, startedAt = startWall, id = runIds.uuid(), trigger = Trigger.MANUAL, startedAt = runStartWall,
endedAt = Instant.now().toString(), endedAt = Instant.now().toString(),
clock = Clock(monoOriginWall = startWall), clock = Clock(monoOriginWall = runStartWall),
app = AppInfo( app = AppInfo(
version = BuildConfig.VERSION_NAME, build = BuildConfig.VERSION_CODE, flavor = "app", version = BuildConfig.VERSION_NAME, build = BuildConfig.VERSION_CODE, flavor = "app",
), ),
@@ -139,12 +179,12 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
manufacturer = Build.MANUFACTURER, model = Build.MODEL, manufacturer = Build.MANUFACTURER, model = Build.MODEL,
androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE, androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE,
), ),
tiers = Tiers(app = true, shizuku = shizukuAvailable), tiers = Tiers(app = true, shizuku = runShizukuOk),
), ),
networks = networks, networks = runNetworks,
tests = tests, tests = tests,
findings = findings, findings = findings,
summary = summary, summary = Verdicts.derive(tests, findings),
) )
} }
@@ -259,7 +299,10 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
return out return out
} }
private fun step(s: String) { private fun step(s: String, done: Int = state.stepsDone, total: Int = state.stepsTotal, etaMs: Long = -1) {
state = state.copy(currentStep = s) state = state.copy(
currentStep = s, stepsDone = done, stepsTotal = total,
etaSeconds = if (etaMs >= 0) ((etaMs + 999) / 1000).toInt() else state.etaSeconds,
)
} }
} }
@@ -36,6 +36,7 @@ import java.net.URL
class CaptivePortalProbe(private val entries: List<NetworkInventory.Entry>) : Probe { class CaptivePortalProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
override val type = TestType.NET_CAPTIVE_PORTAL override val type = TestType.NET_CAPTIVE_PORTAL
override val tier = Tier.APP override val tier = Tier.APP
override val estimatedMs = 9_000L // two HTTP probes per network, 4s timeouts each
private val httpsUrl = "https://www.google.com/generate_204" private val httpsUrl = "https://www.google.com/generate_204"
private val httpUrl = "http://connectivitycheck.gstatic.com/generate_204" private val httpUrl = "http://connectivitycheck.gstatic.com/generate_204"
@@ -37,6 +37,7 @@ class DnsCanaryProbe(
) : Probe { ) : Probe {
override val type = TestType.DNS_CANARY override val type = TestType.DNS_CANARY
override val tier = Tier.APP override val tier = Tier.APP
override val estimatedMs = 3_000L // five resolutions through the platform resolver
/** Frozen ground truth from probe-protocol.md §6.1 — must match the server's dns_reference.go. */ /** Frozen ground truth from probe-protocol.md §6.1 — must match the server's dns_reference.go. */
private val references = listOf( private val references = listOf(
@@ -35,6 +35,8 @@ class IcmpProbe(
) : Probe { ) : Probe {
override val type = if (v6) TestType.ICMP_PING6 else TestType.ICMP_PING4 override val type = if (v6) TestType.ICMP_PING6 else TestType.ICMP_PING4
override val tier = Tier.APP override val tier = Tier.APP
// v6 on a v4-only network waits out a 3s timeout per network; v4 answers in ms.
override val estimatedMs = if (v6) 7_000L else 800L
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) { override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
val b = TestBuilder(type, tier, ids) val b = TestBuilder(type, tier, ids)
@@ -23,6 +23,7 @@ import kotlinx.serialization.json.addJsonObject
class LinkSnapshotProbe(private val entries: List<NetworkInventory.Entry>) : Probe { class LinkSnapshotProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
override val type = TestType.LINK_SNAPSHOT override val type = TestType.LINK_SNAPSHOT
override val tier = Tier.APP override val tier = Tier.APP
override val estimatedMs = 200L // reads LinkProperties, no I/O
override suspend fun run(ctx: Context, ids: ProbeIds): Test { override suspend fun run(ctx: Context, ids: ProbeIds): Test {
val b = TestBuilder(type, tier, ids) val b = TestBuilder(type, tier, ids)
@@ -19,6 +19,14 @@ interface Probe {
val type: String val type: String
val tier: Tier val tier: Tier
/**
* Rough wall-clock estimate in ms, used only to drive the progress bar / ETA. Probes that
* wait on timeouts (ICMPv6 on a v4-only net, SSDP, STUN) dominate a run, so they override
* this with realistic values measured on device — a bad estimate misleads the user, it does
* not break the run.
*/
val estimatedMs: Long get() = 2_000
/** Runs the probe. [ctx] gives platform access; [ids] supplies UUIDs + the monotonic clock so /** Runs the probe. [ctx] gives platform access; [ids] supplies UUIDs + the monotonic clock so
* results are attributable and use the two-clock rule. Must never throw. */ * results are attributable and use the two-clock rule. Must never throw. */
suspend fun run(ctx: Context, ids: ProbeIds): Test suspend fun run(ctx: Context, ids: ProbeIds): Test
@@ -47,6 +47,7 @@ import java.net.URL
class RouterIdentityProbe(private val entries: List<NetworkInventory.Entry>) : Probe { class RouterIdentityProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
override val type = TestType.LINK_RA_SOURCE override val type = TestType.LINK_RA_SOURCE
override val tier = Tier.APP override val tier = Tier.APP
override val estimatedMs = 7_000L // SSDP M-SEARCH window + description fetches + rDNS
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) { override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
val b = TestBuilder(type, tier, ids) val b = TestBuilder(type, tier, ids)
@@ -43,6 +43,7 @@ class StunProbe(
) : Probe { ) : Probe {
override val type = TestType.NAT_STUN_5780 override val type = TestType.NAT_STUN_5780
override val tier = Tier.APP override val tier = Tier.APP
override val estimatedMs = 6_000L // three binding requests; the change-request usually times out (3s)
private companion object { private companion object {
const val MAGIC_COOKIE = 0x2112A442.toInt() const val MAGIC_COOKIE = 0x2112A442.toInt()