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:
co-authored by
Claude Opus 5
parent
2e34463c0a
commit
217818f7b3
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user