app: autorun mode (unattended run + upload + auto-exit); IPv6 severity rework

IPv6: absence is no longer a defect. If the network never provisioned v6
(no global address, no ::/0 route) the finding is ipv6.not_offered at INFO
(green) — most networks are still IPv4-only. If v6 IS advertised but
doesn't work, it's ipv6.broken at MEDIUM (yellow), because half-working v6
stalls connections. Verified on-device: our LAN advertises a v6 default
route with no path, and now reports ipv6.broken.

Autorun: `am start ... --ez autorun true` runs the suite immediately,
POSTs the report to the collection endpoint, shows the result for 3s and
finishes the activity (stays open if the upload failed). receiver.py gains
POST /report + GET /reports + GET /report/<name>. Verified end to end: one
adb command, report retrieved over HTTP, app closed itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 09:27:04 +02:00
co-authored by Claude Opus 5
parent 483de5ca54
commit dc094d1631
6 changed files with 186 additions and 17 deletions
@@ -40,9 +40,29 @@ class MainActivity : ComponentActivity() {
MaterialTheme(colorScheme = darkColorScheme()) {
Surface(color = MaterialTheme.colorScheme.background) {
val vm: RunViewModel = viewModel()
// Automation entry point:
// adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
// starts a run immediately and uploads the report, so an unattended
// measurement needs no UI tapping and no adb round-trip to collect.
val autorun = intent?.getBooleanExtra("autorun", false) == true
androidx.compose.runtime.LaunchedEffect(autorun) {
if (autorun) vm.run(upload = true)
}
// In autorun the app is a batch job: once the run is done AND the upload
// succeeded, show the result briefly, then close so the device is left as it
// was found. On failure it stays open so the error is visible.
val st = vm.state
androidx.compose.runtime.LaunchedEffect(autorun, st.running, st.uploadStatus) {
if (autorun && !st.running && st.document != null &&
st.uploadStatus?.startsWith("uploaded") == true
) {
kotlinx.coroutines.delay(3000)
finish()
}
}
EcholotScreen(
state = vm.state,
onRun = vm::run,
onRun = { vm.run() },
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
)
}
@@ -91,6 +111,10 @@ private fun EcholotScreen(state: UiState, onRun: () -> Unit, onExport: (Measurem
}
}
state.uploadStatus?.let {
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (state.running) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
@@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import app.echo_lot.measurement.MeasurementDocument
import java.net.HttpURLConnection
import java.net.URL
/**
* Dev/automation helper: uploads a finished run to the collection endpoint so an unattended run
* (see MainActivity's `autorun` extra) needs no adb round-trip to retrieve its result. Off unless
* an upload URL is configured. Never throws — a failed upload must not lose the local report.
*/
object ReportUploader {
data class Result(val ok: Boolean, val detail: String)
fun upload(doc: MeasurementDocument): Result {
val url = BuildConfig.REPORT_UPLOAD_URL
if (url.isBlank()) return Result(false, "no upload URL configured")
return try {
val body = Report.toJson(doc).toByteArray()
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 10_000
readTimeout = 15_000
doOutput = true
setRequestProperty("Content-Type", "application/json")
if (BuildConfig.REPORT_UPLOAD_SECRET.isNotBlank()) {
setRequestProperty("X-Beacon-Secret", BuildConfig.REPORT_UPLOAD_SECRET)
}
}
conn.outputStream.use { it.write(body) }
val code = conn.responseCode
val resp = (if (code in 200..299) conn.inputStream else conn.errorStream)
?.bufferedReader()?.use { it.readText() } ?: ""
conn.disconnect()
Result(code in 200..299, "HTTP $code ${resp.take(120)}")
} catch (t: Throwable) {
Result(false, t.message ?: t.javaClass.simpleName)
}
}
}
@@ -30,6 +30,8 @@ data class UiState(
val running: Boolean = false,
val currentStep: String? = null,
val document: MeasurementDocument? = null,
/** Result line of an automated upload (autorun mode); null when not attempted. */
val uploadStatus: String? = null,
)
/**
@@ -50,12 +52,22 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
override fun monoNs(): Long = System.nanoTime() - originNanos
}
fun run() {
/**
* Runs one measurement. With [upload] (autorun mode) the finished document is POSTed to the
* collection endpoint so an unattended run can be retrieved without adb.
*/
fun run(upload: Boolean = false) {
if (state.running) return
state = state.copy(running = true, currentStep = "starting", document = null)
state = state.copy(running = true, currentStep = "starting", document = null, uploadStatus = null)
viewModelScope.launch {
val doc = withContext(Dispatchers.IO) { measure() }
state = UiState(running = false, currentStep = null, document = doc)
var status: String? = null
if (upload) {
state = state.copy(currentStep = "uploading report")
val r = withContext(Dispatchers.IO) { ReportUploader.upload(doc) }
status = if (r.ok) "uploaded ✓ ${r.detail}" else "upload failed: ${r.detail}"
}
state = UiState(running = false, currentStep = null, document = doc, uploadStatus = status)
}
}
@@ -110,7 +122,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
tests.add(shizukuTest)
val shizukuAvailable = shizukuTest.status == TestStatus.OK || shizukuTest.status == TestStatus.PARTIAL
val findings = deriveFindings(tests)
val findings = deriveFindings(tests, networks)
val summary = Verdicts.derive(tests, findings)
return MeasurementDocument(
@@ -134,9 +146,21 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
)
}
/** Minimal first-pass findings from device-tier evidence; the full findings registry grows
* with the test suite. */
private fun deriveFindings(tests: List<Test>): List<Finding> {
/**
* Was IPv6 actually provisioned on any network? A global (non-link-local) v6 address or a
* v6 default route means the network claims to offer IPv6 — link-local only does not count.
*/
private fun ipv6Provisioned(networks: List<app.echo_lot.measurement.Network>): Boolean =
networks.any { n ->
n.link.addresses.any { a ->
a.addr.contains(':') &&
!a.addr.startsWith("fe80", ignoreCase = true) &&
!a.addr.startsWith("::1")
} || n.link.routes.any { it.dst == "::/0" }
}
/** Minimal first-pass findings from device-tier evidence; the registry grows with the suite. */
private fun deriveFindings(tests: List<Test>, networks: List<app.echo_lot.measurement.Network>): List<Finding> {
val out = ArrayList<Finding>()
val ids = RunIds()
for (t in tests) {
@@ -202,15 +226,32 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
}
}
if (t.type == TestType.ICMP_PING6 && t.status == TestStatus.FAILED) {
out.add(
Finding(
id = ids.uuid(), code = "ipv6.no_icmp_path", category = Category.IPV6,
severity = Severity.LOW, confidence = Confidence.MEDIUM,
title = "No IPv6 ICMP path on any active network",
description = "ICMPv6 echo got no reply on any active network — this network has no working IPv6 path (or filters ICMPv6).",
evidenceRefs = listOf(EvidenceRef(t.id)),
// A network with no IPv6 at all is NORMAL — most networks are still IPv4-only,
// and that is not a defect. What IS a defect is IPv6 that the network claims to
// provide (a global address or a default route from RA/DHCPv6) but that does not
// work: that causes Happy-Eyeballs delays, timeouts and hangs. So the severity
// depends on whether v6 was provisioned at all.
if (ipv6Provisioned(networks)) {
out.add(
Finding(
id = ids.uuid(), code = "ipv6.broken", category = Category.IPV6,
severity = Severity.MEDIUM, confidence = Confidence.HIGH,
title = "IPv6 is configured but not working",
description = "This network advertises IPv6 (a global address and/or a default route), but ICMPv6 got no reply on any network. Half-configured IPv6 is worse than none: connections try IPv6 first and stall before falling back.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
)
} else {
out.add(
Finding(
id = ids.uuid(), code = "ipv6.not_offered", category = Category.IPV6,
severity = Severity.INFO, confidence = Confidence.HIGH,
title = "IPv4-only network (no IPv6 offered)",
description = "No IPv6 address or default route was provisioned, so IPv6 tests could not run. This is normal — many networks are still IPv4-only and it is not a fault.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
}
}
}
return out