app: installable APK — core-probe (device-tier) + Compose UI
First assembling build of the production app. Android toolchain mirrors the prober (AGP 9 built-in Kotlin; applying kotlin.android too double-registers the kotlin extension — the one gotcha). core-probe (Android lib): Probe→core-measurement Test abstraction; NetworkInventory (LinkProperties→networks[]), LinkSnapshotProbe, per-network IcmpProbe (ported from the prober's validated logic). app (Compose): RunViewModel orchestrates probes into a MeasurementDocument with a §7.3 summary + first-pass findings; UI renders traffic lights, networks, tests, findings; JSON export. Rotation-safe (ViewModel). App-tier only; server-facing (core-engine) + Shizuku are additive follow-ups. Debug APK 9.5 MB, assembles clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
49c6197aff
commit
bc220f950e
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="Echolot"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Echolot">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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<String, CategorySummary>) {
|
||||
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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Application>()
|
||||
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<Probe> = listOf(
|
||||
LinkSnapshotProbe(entries),
|
||||
IcmpProbe(entries, v6 = false),
|
||||
IcmpProbe(entries, v6 = true),
|
||||
)
|
||||
|
||||
val tests = ArrayList<Test>()
|
||||
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<Test>): List<Finding> {
|
||||
val out = ArrayList<Finding>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<resources>
|
||||
<style name="Theme.Echolot" parent="android:Theme.Material.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<paths>
|
||||
<cache-path name="reports" path="reports/" />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user