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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user