diff --git a/docs/build-status.md b/docs/build-status.md index e76e443..acff43f 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -328,3 +328,18 @@ Two more pure-Kotlin/JVM modules, both verifiable without a device: So the whole server-facing stack — protocol client → engine → schema document → verdict — is now proven against the live server, no device needed. Next modules (core-probe device-tier, core-shizuku dual-path, Compose app) are Android + need on-device verification. + +## App: installable APK — core-probe + Compose UI (2026-07-31) +The production Android app assembles. Android toolchain in echolot-app mirrors the prober (AGP +9.2 built-in Kotlin — do NOT also apply kotlin.android, it double-registers the `kotlin` +extension; that was the one build gotcha). Modules added: +- **core-probe** (Android lib): Probe→core-measurement Test abstraction; NetworkInventory + (LinkProperties → measurement networks[]), LinkSnapshotProbe (link.snapshot), IcmpProbe + (per-network icmp.ping4/6, ported from the prober's validated per-network logic). +- **app** (Compose): RunViewModel orchestrates probes → assembles a MeasurementDocument with a + §7.3 summary + first-pass findings; Compose UI shows overall/ per-category traffic lights, + networks, tests (status/metrics), findings; JSON export via share intent. Survives rotation + (ViewModel). App-tier only for now; server-facing (core-engine) and Shizuku tier are additive + follow-ups (app degrades gracefully without them, like the prober). +Debug APK: 9.5 MB, `echolot-app/app/build/outputs/apk/debug/app-debug.apk`. Not yet run on device +(needs the user's phone). core-shizuku (dual-path executor) deferred as additive. diff --git a/echolot-app/app/.gitignore b/echolot-app/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/echolot-app/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/echolot-app/app/build.gradle.kts b/echolot-app/app/build.gradle.kts new file mode 100644 index 0000000..98fc0bf --- /dev/null +++ b/echolot-app/app/build.gradle.kts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +plugins { + // AGP 9 built-in Kotlin — no kotlin.android here (see core-probe note). + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "app.echo_lot.app" + compileSdk = 36 + + defaultConfig { + applicationId = "app.echo_lot.app" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + } + buildTypes { + release { isMinifyEnabled = false } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { + compose = true + buildConfig = true + } +} + +dependencies { + implementation(project(":core-measurement")) + implementation(project(":core-protocol")) + implementation(project(":core-engine")) + implementation(project(":core-probe")) + + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + debugImplementation(libs.androidx.ui.tooling) +} diff --git a/echolot-app/app/src/main/AndroidManifest.xml b/echolot-app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d7046db --- /dev/null +++ b/echolot-app/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000..21e0901 --- /dev/null +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.app + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.lifecycle.viewmodel.compose.viewModel +import app.echo_lot.measurement.* + +class MainActivity : ComponentActivity() { + + private val permissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { /* proceed regardless */ } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + requestRuntimePermissions() + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + Surface(color = MaterialTheme.colorScheme.background) { + val vm: RunViewModel = viewModel() + EcholotScreen( + state = vm.state, + onRun = vm::run, + onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) }, + ) + } + } + } + } + + private fun requestRuntimePermissions() { + val perms = mutableListOf(Manifest.permission.ACCESS_FINE_LOCATION) + val missing = perms.filter { + ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED + } + if (missing.isNotEmpty()) permissionLauncher.launch(missing.toTypedArray()) + } +} + +private fun verdictColor(v: Verdict): Color = when (v) { + Verdict.GREEN -> Color(0xFF2E7D32) + Verdict.YELLOW -> Color(0xFFF9A825) + Verdict.RED -> Color(0xFFC62828) + Verdict.INCONCLUSIVE -> Color(0xFF616161) +} + +private fun statusColor(s: TestStatus): Color = when (s) { + TestStatus.OK -> Color(0xFF66BB6A) + TestStatus.PARTIAL -> Color(0xFFFFB300) + TestStatus.FAILED -> Color(0xFFEF5350) + TestStatus.UNSUPPORTED, TestStatus.SKIPPED -> Color(0xFF9E9E9E) +} + +@Composable +private fun EcholotScreen(state: UiState, onRun: () -> Unit, onExport: (MeasurementDocument) -> Unit) { + Column( + Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold) + Text("measure, don't guess", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Button(onClick = onRun, enabled = !state.running) { + Text(if (state.running) "Running…" else "Run measurement") + } + state.document?.let { doc -> + OutlinedButton(onClick = { onExport(doc) }) { Text("Export JSON") } + } + } + + 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) + } + } + + state.document?.let { doc -> Results(doc) } + } +} + +@Composable +private fun Results(doc: MeasurementDocument) { + val summary = doc.summary + if (summary != null) { + Card(colors = CardDefaults.cardColors(containerColor = verdictColor(summary.overall))) { + Column(Modifier.fillMaxWidth().padding(16.dp)) { + Text("Overall: ${summary.overall}", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp) + } + } + FlowCategories(summary.categories) + } + + SectionTitle("Networks (${doc.networks.size})") + for (n in doc.networks) { + Text("• ${n.transport.name.lowercase()} ${n.iface ?: ""} — " + + n.link.addresses.joinToString(", ") { "${it.addr}/${it.prefixLen}" }, + fontSize = 13.sp, fontFamily = FontFamily.Monospace) + } + + SectionTitle("Tests (${doc.tests.size})") + for (t in doc.tests) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Dot(statusColor(t.status)) + Text(t.type, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) + Text(t.status.name, color = statusColor(t.status), fontSize = 12.sp) + } + val ms = (t.endedMonoNs - t.startedMonoNs) / 1_000_000 + Text("${t.tier.name.lowercase()} · ${ms} ms", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + t.metrics?.let { Text(it.toString(), fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + } + } + + if (doc.findings.isNotEmpty()) { + SectionTitle("Findings (${doc.findings.size})") + for (f in doc.findings) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text(f.title, fontWeight = FontWeight.Medium) + Text("${f.category.name.lowercase()} · ${f.severity.name.lowercase()}", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(f.description, fontSize = 12.sp) + } + } + } + } +} + +@Composable +private fun FlowCategories(categories: Map) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + for ((name, cat) in categories) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Dot(verdictColor(cat.verdict)) + Text(name, modifier = Modifier.weight(1f)) + Text("${cat.testsRun} run" + if (cat.testsFailed > 0) " · ${cat.testsFailed} failed" else "", + fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +private fun Dot(color: Color) { + Surface(color = color, shape = RoundedCornerShape(50), modifier = Modifier.size(12.dp)) {} +} + +@Composable +private fun SectionTitle(text: String) { + Text(text, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp)) +} diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Report.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Report.kt new file mode 100644 index 0000000..bd008dd --- /dev/null +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Report.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.app + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import app.echo_lot.measurement.MeasurementDocument +import kotlinx.serialization.json.Json +import java.io.File + +/** Serializes a measurement run to JSON and builds a share intent (measurement-schema.md §2). */ +object Report { + private val json = Json { prettyPrint = true; encodeDefaults = true } + + fun toJson(doc: MeasurementDocument): String = + json.encodeToString(MeasurementDocument.serializer(), doc) + + fun share(ctx: Context, doc: MeasurementDocument): Intent { + val dir = File(ctx.cacheDir, "reports").apply { mkdirs() } + val file = File(dir, "echolot-run-${doc.run.id}.json") + file.writeText(toJson(doc)) + val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file) + return Intent(Intent.ACTION_SEND).apply { + type = "application/json" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } +} 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 new file mode 100644 index 0000000..fbb645a --- /dev/null +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.app + +import android.app.Application +import android.os.Build +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import app.echo_lot.measurement.* +import app.echo_lot.probe.IcmpProbe +import app.echo_lot.probe.LinkSnapshotProbe +import app.echo_lot.probe.NetworkInventory +import app.echo_lot.probe.Probe +import app.echo_lot.probe.ProbeIds +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.Instant +import java.util.UUID + +data class UiState( + val running: Boolean = false, + val currentStep: String? = null, + val document: MeasurementDocument? = null, +) + +/** + * Drives one measurement run: device-tier probes (link snapshot, per-network ICMP) always run; + * results assemble into a MeasurementDocument with a §7.3 summary. Lives in a ViewModel so a run + * survives rotation — a dropped run means a lost report. Server-facing tests (core-engine) are a + * follow-up once enrollment UI lands. + */ +class RunViewModel(app: Application) : AndroidViewModel(app) { + + var state by mutableStateOf(UiState()) + private set + + /** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */ + private class RunIds : ProbeIds { + val originNanos = System.nanoTime() + override fun uuid(): String = UUID.randomUUID().toString() + override fun monoNs(): Long = System.nanoTime() - originNanos + } + + fun run() { + if (state.running) return + state = state.copy(running = true, currentStep = "starting", document = null) + viewModelScope.launch { + val doc = withContext(Dispatchers.IO) { measure() } + state = UiState(running = false, currentStep = null, document = doc) + } + } + + private suspend fun measure(): MeasurementDocument { + val ctx = getApplication() + val ids = RunIds() + val startWall = Instant.now().toString() + + step("reading networks") + val entries = NetworkInventory.snapshot(ctx) + val networks = entries.map { it.model } + + val probes: List = listOf( + LinkSnapshotProbe(entries), + IcmpProbe(entries, v6 = false), + IcmpProbe(entries, v6 = true), + ) + + val tests = ArrayList() + for (p in probes) { + step(p.type) + tests.add( + try { + p.run(ctx, ids) + } catch (t: Throwable) { + Test( + id = ids.uuid(), type = p.type, tier = p.tier, + startedMonoNs = ids.monoNs(), endedMonoNs = ids.monoNs(), + status = TestStatus.FAILED, + error = TestError("uncaught", t.message ?: t.javaClass.simpleName), + ) + } + ) + } + + val findings = deriveFindings(tests) + val summary = Verdicts.derive(tests, findings) + + return MeasurementDocument( + run = Run( + id = ids.uuid(), trigger = Trigger.MANUAL, startedAt = startWall, + endedAt = Instant.now().toString(), + clock = Clock(monoOriginWall = startWall), + app = AppInfo( + version = BuildConfig.VERSION_NAME, build = BuildConfig.VERSION_CODE, flavor = "app", + ), + device = DeviceInfo( + manufacturer = Build.MANUFACTURER, model = Build.MODEL, + androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE, + ), + tiers = Tiers(app = true), + ), + networks = networks, + tests = tests, + findings = findings, + summary = summary, + ) + } + + /** Minimal first-pass findings from device-tier evidence; the full findings registry grows + * with the test suite. */ + private fun deriveFindings(tests: List): List { + val out = ArrayList() + val ids = RunIds() + for (t in tests) { + 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)), + ) + ) + } + } + return out + } + + private fun step(s: String) { + state = state.copy(currentStep = s) + } +} diff --git a/echolot-app/app/src/main/res/values/themes.xml b/echolot-app/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..6350295 --- /dev/null +++ b/echolot-app/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +