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
@@ -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)
}
}
}
}
@@ -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<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. */
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<Application>()
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<Probe> = 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<Test>()
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<Test>): 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,
)
}
}
@@ -36,6 +36,7 @@ import java.net.URL
class CaptivePortalProbe(private val entries: List<NetworkInventory.Entry>) : 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"
@@ -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(
@@ -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)
@@ -23,6 +23,7 @@ import kotlinx.serialization.json.addJsonObject
class LinkSnapshotProbe(private val entries: List<NetworkInventory.Entry>) : 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)
@@ -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
@@ -47,6 +47,7 @@ import java.net.URL
class RouterIdentityProbe(private val entries: List<NetworkInventory.Entry>) : 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)
@@ -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()