From dc094d16318241ff6e7670d40c409a44ed7096cd Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sat, 1 Aug 2026 09:27:04 +0200 Subject: [PATCH] app: autorun mode (unattended run + upload + auto-exit); IPv6 severity rework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/. Verified end to end: one adb command, report retrieved over HTTP, app closed itself. Co-Authored-By: Claude Opus 5 --- docs/build-status.md | 22 ++++++ echolot-app/app/build.gradle.kts | 4 ++ .../kotlin/app/echo_lot/app/MainActivity.kt | 26 ++++++- .../kotlin/app/echo_lot/app/ReportUploader.kt | 44 ++++++++++++ .../kotlin/app/echo_lot/app/RunViewModel.kt | 71 +++++++++++++++---- tools/adb-beacon/receiver.py | 36 +++++++++- 6 files changed, 186 insertions(+), 17 deletions(-) create mode 100644 echolot-app/app/src/main/kotlin/app/echo_lot/app/ReportUploader.kt diff --git a/docs/build-status.md b/docs/build-status.md index 0d0b290..7081bae 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -430,3 +430,25 @@ Finding wired: `nat.symmetric` (medium) when mapping is address/port-dependent. Two bugs caught by running it for real: port preservation was misread as "no NAT" (now compares ADDRESSES), and an unbound socket reports the wildcard as its local address (now resolved via a throwaway connected socket). 7 tests/run. + +## App: autorun mode + IPv6 severity rework (2026-08-01) +**IPv6 is no longer treated as a defect just for being absent.** The finding now depends on +whether the network actually provisioned IPv6 (a global v6 address or a `::/0` route): +- not provisioned → `ipv6.not_offered`, severity **INFO → green**. Most networks are still + IPv4-only and that is not a fault. +- provisioned but ICMPv6 fails → `ipv6.broken`, severity **MEDIUM → yellow**. Half-configured + IPv6 is worse than none (Happy-Eyeballs stalls). Verified on the OnePlus: our LAN advertises a + v6 default route with no working path, so it correctly reports `ipv6.broken`. + +**Autorun mode** — one adb command runs a full measurement unattended and collects the result +without any UI tapping or adb round-trip: +``` +adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true +curl http:///reports # list +curl http:///report/ # fetch +``` +The app runs the suite, POSTs the MeasurementDocument to the collection endpoint (receiver.py +gained `POST /report`, `GET /reports`, `GET /report/`), shows the result for 3 s, then +finishes itself — leaving the device as it was found. On upload failure it stays open so the +error is visible. Grant permissions once via `adb shell pm grant app.echo_lot.app +android.permission.ACCESS_FINE_LOCATION` so nothing blocks on a dialog. diff --git a/echolot-app/app/build.gradle.kts b/echolot-app/app/build.gradle.kts index 63ba4c5..f084067 100644 --- a/echolot-app/app/build.gradle.kts +++ b/echolot-app/app/build.gradle.kts @@ -18,6 +18,10 @@ android { targetSdk = 36 versionCode = 1 versionName = "0.1.0" + // Automation: `adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true` + // runs a measurement immediately and POSTs the report here (dev collection endpoint). + buildConfigField("String", "REPORT_UPLOAD_URL", "\"http://89.185.109.150:443/report\"") + buildConfigField("String", "REPORT_UPLOAD_SECRET", "\"D4OmG5gGJsElqVVbtYIZbR\"") } buildTypes { release { isMinifyEnabled = false } 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 21e0901..242826c 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 @@ -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) diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/ReportUploader.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/ReportUploader.kt new file mode 100644 index 0000000..11f744a --- /dev/null +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/ReportUploader.kt @@ -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) + } + } +} 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 7ae2db4..4731468 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 @@ -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): List { + /** + * 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): 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, networks: List): List { val out = ArrayList() 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 diff --git a/tools/adb-beacon/receiver.py b/tools/adb-beacon/receiver.py index ec8e78c..31eaaab 100644 --- a/tools/adb-beacon/receiver.py +++ b/tools/adb-beacon/receiver.py @@ -18,6 +18,7 @@ import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer STATE_PATH = os.environ.get("STATE_PATH", "/run/echolot-adb-beacon.json") +REPORT_DIR = os.environ.get("REPORT_DIR", "/tmp/echolot-reports") SECRET = os.environ.get("ECHOLOT_BEACON_SECRET", "") BIND = os.environ.get("BEACON_BIND", "0.0.0.0") PORT = int(os.environ.get("BEACON_PORT", "9099")) @@ -55,6 +56,19 @@ class Handler(BaseHTTPRequestHandler): # Dev convenience: serve a staged APK so a device (which can only reach # this host on 443) can pull it via its own curl — more resilient than # adb's sustained transport over a flaky wifi. Path from APK_PATH. + if p == "/reports": + try: + names = sorted(os.listdir(REPORT_DIR), reverse=True) + except FileNotFoundError: + names = [] + return self._send(200, json.dumps(names).encode()) + if p.startswith("/report/"): + name = os.path.basename(p[len("/report/"):]) + try: + with open(os.path.join(REPORT_DIR, name), "rb") as f: + return self._send(200, f.read()) + except FileNotFoundError: + return self._send(404, b'{"error":"no such report"}') if p == "/apk": apk = os.environ.get("APK_PATH", "/tmp/echolot-app.apk") try: @@ -65,7 +79,27 @@ class Handler(BaseHTTPRequestHandler): return self._send(404, b'{"error":"not found"}') def do_POST(self): - if self.path.rstrip("/") != "/beacon": + p = self.path.rstrip("/") + # Measurement-report drop box: the app's autorun mode POSTs its run JSON here so a + # test run needs no adb at all (start it once, collect the result over HTTP). + if p == "/report": + if SECRET and self.headers.get("X-Beacon-Secret") != SECRET: + return self._send(403, b'{"error":"bad secret"}') + n = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(n) + try: + doc = json.loads(body) + dev = doc.get("run", {}).get("device", {}).get("model", "device") + rid = doc.get("run", {}).get("id", "unknown")[:8] + except Exception: + dev, rid = "device", "unparsed" + os.makedirs(REPORT_DIR, exist_ok=True) + name = f"{int(time.time())}-{dev}-{rid}.json" + with open(os.path.join(REPORT_DIR, name), "wb") as f: + f.write(body) + return self._send(200, json.dumps({"stored": name, "bytes": len(body)}).encode()) + + if p != "/beacon": return self._send(404, b'{"error":"not found"}') if SECRET and self.headers.get("X-Beacon-Secret") != SECRET: return self._send(403, b'{"error":"bad secret"}')