diff --git a/docs/build-status.md b/docs/build-status.md index 32eed11..4e7f60c 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -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. 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. + +### 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. 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 becc9f5..bf43ffa 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 @@ -63,6 +63,7 @@ class MainActivity : ComponentActivity() { EcholotScreen( state = vm.state, onRun = { vm.run() }, + onCancel = vm::cancel, 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 -private fun EcholotScreen(state: UiState, onRun: () -> Unit, onExport: (MeasurementDocument) -> Unit) { +private fun EcholotScreen( + state: UiState, + onRun: () -> Unit, + onCancel: () -> Unit, + onExport: (MeasurementDocument) -> Unit, +) { 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), ) { 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) { Text(if (state.running) "Running…" else "Run measurement") } + if (state.running) { + OutlinedButton(onClick = onCancel) { Text("Cancel") } + } state.document?.let { doc -> OutlinedButton(onClick = { onExport(doc) }) { Text("Export JSON") } } @@ -116,9 +132,26 @@ private fun EcholotScreen(state: UiState, onRun: () -> Unit, onExport: (Measurem } if (state.running) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) - Text(state.currentStep ?: "…", color = MaterialTheme.colorScheme.onSurfaceVariant) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + val frac = if (state.stepsTotal > 0) + 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) + } + } } } 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 c8ab7d4..3daf3ac 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 @@ -33,6 +33,10 @@ data class UiState( val document: MeasurementDocument? = null, /** Result line of an automated upload (autorun mode); null when not attempted. */ 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()) 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() + private var runIds: RunIds = RunIds() + private var runStartWall: String = "" + private var runNetworks: List = emptyList() + private var runShizukuOk = false + /** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */ private class RunIds : ProbeIds { val originNanos = System.nanoTime() @@ -59,8 +71,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { */ fun run(upload: Boolean = false) { if (state.running) return + collected.clear() state = state.copy(running = true, currentStep = "starting", document = null, uploadStatus = null) - viewModelScope.launch { + runJob = viewModelScope.launch { val doc = withContext(Dispatchers.IO) { measure() } var status: String? = null 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 { val ctx = getApplication() - val ids = RunIds() - val startWall = Instant.now().toString() + val ids = RunIds().also { runIds = it } + val startWall = Instant.now().toString().also { runStartWall = it } step("reading networks") val entries = NetworkInventory.snapshot(ctx) - val networks = entries.map { it.model } + val networks = entries.map { it.model }.also { runNetworks = it } val probes: List = listOf( LinkSnapshotProbe(entries), @@ -93,10 +120,18 @@ class RunViewModel(app: Application) : AndroidViewModel(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() - for (p in probes) { - step(p.type) - tests.add( + for ((i, p) in probes.withIndex()) { + step(p.type, done = i, total = totalSteps, etaMs = remainingMs) + val result = ( try { p.run(ctx, ids) } 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. - step("shizuku.command_battery") + step("link.ip_monitor (shizuku)", done = probes.size, total = totalSteps, etaMs = shizukuEstimateMs) val shizukuTest = try { ShizukuProbe().run(ctx, ids::uuid, ids::monoNs) } catch (t: Throwable) { @@ -121,17 +158,20 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { status = TestStatus.FAILED, error = TestError("uncaught", t.message ?: t.javaClass.simpleName), ) } - tests.add(shizukuTest) - val shizukuAvailable = shizukuTest.status == TestStatus.OK || shizukuTest.status == TestStatus.PARTIAL + tests.add(shizukuTest); collected.add(shizukuTest) + runShizukuOk = shizukuTest.status == TestStatus.OK || shizukuTest.status == TestStatus.PARTIAL - val findings = deriveFindings(tests, networks) - val summary = Verdicts.derive(tests, findings) + return buildDocument(tests) + } + /** Assembles a document from whatever tests are in hand — used for both full and cancelled runs. */ + private fun buildDocument(tests: List): MeasurementDocument { + val findings = deriveFindings(tests, runNetworks) return MeasurementDocument( run = Run( - id = ids.uuid(), trigger = Trigger.MANUAL, startedAt = startWall, + id = runIds.uuid(), trigger = Trigger.MANUAL, startedAt = runStartWall, endedAt = Instant.now().toString(), - clock = Clock(monoOriginWall = startWall), + clock = Clock(monoOriginWall = runStartWall), app = AppInfo( 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, 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, findings = findings, - summary = summary, + summary = Verdicts.derive(tests, findings), ) } @@ -259,7 +299,10 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { return out } - private fun step(s: String) { - state = state.copy(currentStep = s) + private fun step(s: String, done: Int = state.stepsDone, total: Int = state.stepsTotal, etaMs: Long = -1) { + state = state.copy( + currentStep = s, stepsDone = done, stepsTotal = total, + etaSeconds = if (etaMs >= 0) ((etaMs + 999) / 1000).toInt() else state.etaSeconds, + ) } } diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/CaptivePortalProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/CaptivePortalProbe.kt index f96994a..b811963 100644 --- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/CaptivePortalProbe.kt +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/CaptivePortalProbe.kt @@ -36,6 +36,7 @@ import java.net.URL class CaptivePortalProbe(private val entries: List) : Probe { override val type = TestType.NET_CAPTIVE_PORTAL 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 httpUrl = "http://connectivitycheck.gstatic.com/generate_204" diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/DnsCanaryProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/DnsCanaryProbe.kt index 28d887f..c36e8c0 100644 --- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/DnsCanaryProbe.kt +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/DnsCanaryProbe.kt @@ -37,6 +37,7 @@ class DnsCanaryProbe( ) : Probe { override val type = TestType.DNS_CANARY 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. */ private val references = listOf( diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/IcmpProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/IcmpProbe.kt index 3fb1bdb..ddd2bff 100644 --- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/IcmpProbe.kt +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/IcmpProbe.kt @@ -35,6 +35,8 @@ class IcmpProbe( ) : Probe { override val type = if (v6) TestType.ICMP_PING6 else TestType.ICMP_PING4 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) { val b = TestBuilder(type, tier, ids) diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/LinkSnapshotProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/LinkSnapshotProbe.kt index 3402b67..d102109 100644 --- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/LinkSnapshotProbe.kt +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/LinkSnapshotProbe.kt @@ -23,6 +23,7 @@ import kotlinx.serialization.json.addJsonObject class LinkSnapshotProbe(private val entries: List) : Probe { override val type = TestType.LINK_SNAPSHOT override val tier = Tier.APP + override val estimatedMs = 200L // reads LinkProperties, no I/O override suspend fun run(ctx: Context, ids: ProbeIds): Test { val b = TestBuilder(type, tier, ids) diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/Probe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/Probe.kt index 67b2faa..4041499 100644 --- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/Probe.kt +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/Probe.kt @@ -19,6 +19,14 @@ interface Probe { val type: String 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 * results are attributable and use the two-clock rule. Must never throw. */ suspend fun run(ctx: Context, ids: ProbeIds): Test diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/RouterIdentityProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/RouterIdentityProbe.kt index 6b718fc..16ecfa1 100644 --- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/RouterIdentityProbe.kt +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/RouterIdentityProbe.kt @@ -47,6 +47,7 @@ import java.net.URL class RouterIdentityProbe(private val entries: List) : Probe { override val type = TestType.LINK_RA_SOURCE 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) { val b = TestBuilder(type, tier, ids) diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/StunProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/StunProbe.kt index 931e75c..e9e0a0f 100644 --- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/StunProbe.kt +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/StunProbe.kt @@ -43,6 +43,7 @@ class StunProbe( ) : Probe { override val type = TestType.NAT_STUN_5780 override val tier = Tier.APP + override val estimatedMs = 6_000L // three binding requests; the change-request usually times out (3s) private companion object { const val MAGIC_COOKIE = 0x2112A442.toInt()