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,31 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
plugins {
|
||||
// AGP 9 has built-in Kotlin — do NOT also apply kotlin.android (double-registers
|
||||
// the `kotlin` extension). Only the serialization compiler plugin is added.
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
// Device-tier probes (Android platform APIs), emitting core-measurement Test
|
||||
// objects. Ported/adapted from the validated echolot-prober. minSdk 26 to
|
||||
// match the prober and the feasibility findings.
|
||||
android {
|
||||
namespace = "app.echo_lot.probe"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core-measurement"))
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.probe
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Network
|
||||
import android.system.Os
|
||||
import android.system.OsConstants
|
||||
import android.system.StructTimeval
|
||||
import app.echo_lot.measurement.Test
|
||||
import app.echo_lot.measurement.TestStatus
|
||||
import app.echo_lot.measurement.TestType
|
||||
import app.echo_lot.measurement.Tier
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import java.io.FileDescriptor
|
||||
import java.net.InetAddress
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* icmp.ping4 / icmp.ping6 via the unprivileged ICMP datagram socket, per active network
|
||||
* (Network.bindSocket). Ported from the prober, which validated on real hardware that Android's
|
||||
* open ping_group_range makes this work with no root — and that per-network binding turns a
|
||||
* default-network v6 EAGAIN into topology evidence rather than a false failure.
|
||||
*/
|
||||
class IcmpProbe(
|
||||
private val entries: List<NetworkInventory.Entry>,
|
||||
private val v6: Boolean,
|
||||
private val target: String = if (v6) "2606:4700:4700::1111" else "1.1.1.1",
|
||||
) : Probe {
|
||||
override val type = if (v6) TestType.ICMP_PING6 else TestType.ICMP_PING4
|
||||
override val tier = Tier.APP
|
||||
|
||||
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||
val b = TestBuilder(type, tier, ids)
|
||||
val perNetwork = LinkedHashMap<String, String>()
|
||||
var anyOk = false
|
||||
val rtts = ArrayList<Double>()
|
||||
|
||||
// Default network first, then each active network explicitly.
|
||||
attempt(null).let { (ok, detail, rtt) ->
|
||||
perNetwork["default"] = detail; if (ok) { anyOk = true; rtt?.let(rtts::add) }
|
||||
}
|
||||
for (e in entries) {
|
||||
val label = "${e.model.transport.name.lowercase()}:${e.model.id}"
|
||||
val (ok, detail, rtt) = attempt(e.handle)
|
||||
perNetwork[label] = detail
|
||||
if (ok) { anyOk = true; rtt?.let(rtts::add) }
|
||||
}
|
||||
|
||||
val evidence: JsonObject = buildJsonObject {
|
||||
put("target", target)
|
||||
for ((k, v) in perNetwork) put(k, v)
|
||||
}
|
||||
val metrics: JsonObject = buildJsonObject {
|
||||
put("networks_ok", rtts.size)
|
||||
rtts.minOrNull()?.let { put("rtt_ms_min", round1(it)) }
|
||||
if (rtts.isNotEmpty()) put("rtt_ms_avg", round1(rtts.average()))
|
||||
rtts.maxOrNull()?.let { put("rtt_ms_max", round1(it)) }
|
||||
}
|
||||
val status = if (anyOk) TestStatus.OK else TestStatus.FAILED
|
||||
b.build(status, evidence = evidence, metrics = metrics)
|
||||
}
|
||||
|
||||
private data class Attempt(val ok: Boolean, val detail: String, val rttMs: Double?)
|
||||
|
||||
private fun attempt(network: Network?): Attempt {
|
||||
var fd: FileDescriptor? = null
|
||||
return try {
|
||||
val proto = if (v6) OsConstants.IPPROTO_ICMPV6 else OsConstants.IPPROTO_ICMP
|
||||
val family = if (v6) OsConstants.AF_INET6 else OsConstants.AF_INET
|
||||
fd = Os.socket(family, OsConstants.SOCK_DGRAM, proto)
|
||||
network?.bindSocket(fd)
|
||||
Os.setsockoptTimeval(fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO, StructTimeval.fromMillis(3000))
|
||||
val addr = network?.getByName(target) ?: InetAddress.getByName(target)
|
||||
|
||||
val ident = (Os.getpid() and 0xFFFF)
|
||||
val packet = buildEchoRequest(v6, ident.toShort(), 1)
|
||||
val t0 = System.nanoTime()
|
||||
Os.sendto(fd, packet, 0, packet.size, 0, addr, 0)
|
||||
val buf = ByteBuffer.allocate(1500)
|
||||
val received = Os.recvfrom(fd, buf, 0, null)
|
||||
val rttMs = (System.nanoTime() - t0) / 1_000_000.0
|
||||
val replyType = if (received > 0) buf.get(0).toInt() and 0xFF else -1
|
||||
val ok = replyType == (if (v6) 129 else 0)
|
||||
Attempt(ok, "reply type=$replyType rtt_ms=${"%.1f".format(Locale.ROOT, rttMs)} bytes=$received", if (ok) rttMs else null)
|
||||
} catch (e: Throwable) {
|
||||
Attempt(false, "error: ${e.message ?: e.javaClass.simpleName}", null)
|
||||
} finally {
|
||||
fd?.let { runCatching { Os.close(it) } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildEchoRequest(v6: Boolean, ident: Short, seq: Short): ByteArray {
|
||||
val type = if (v6) 128 else 8
|
||||
val payload = "echolot".toByteArray()
|
||||
val pkt = ByteBuffer.allocate(8 + payload.size)
|
||||
pkt.put(type.toByte()); pkt.put(0); pkt.putShort(0)
|
||||
pkt.putShort(ident); pkt.putShort(seq); pkt.put(payload)
|
||||
val bytes = pkt.array()
|
||||
if (!v6) {
|
||||
val cs = checksum(bytes)
|
||||
bytes[2] = (cs.toInt() shr 8).toByte(); bytes[3] = (cs.toInt() and 0xFF).toByte()
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
private fun checksum(b: ByteArray): Short {
|
||||
var sum = 0; var i = 0
|
||||
while (i < b.size - 1) { sum += ((b[i].toInt() and 0xFF) shl 8) or (b[i + 1].toInt() and 0xFF); i += 2 }
|
||||
if (i < b.size) sum += (b[i].toInt() and 0xFF) shl 8
|
||||
while (sum shr 16 != 0) sum = (sum and 0xFFFF) + (sum shr 16)
|
||||
return sum.inv().toShort()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.probe
|
||||
|
||||
import android.content.Context
|
||||
import app.echo_lot.measurement.Test
|
||||
import app.echo_lot.measurement.TestStatus
|
||||
import app.echo_lot.measurement.TestType
|
||||
import app.echo_lot.measurement.Tier
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
|
||||
/**
|
||||
* link.snapshot: records every active network's LinkProperties as evidence. The full network
|
||||
* models also feed the document's `networks[]` (see [NetworkInventory]); this test captures the
|
||||
* count and a compact per-network summary so the snapshot is attributable in `tests[]`.
|
||||
*/
|
||||
class LinkSnapshotProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
|
||||
override val type = TestType.LINK_SNAPSHOT
|
||||
override val tier = Tier.APP
|
||||
|
||||
override suspend fun run(ctx: Context, ids: ProbeIds): Test {
|
||||
val b = TestBuilder(type, tier, ids)
|
||||
val evidence: JsonObject = buildJsonObject {
|
||||
put("network_count", entries.size)
|
||||
putJsonArray("networks") {
|
||||
for (e in entries) addJsonObject {
|
||||
put("id", e.model.id)
|
||||
put("transport", e.model.transport.name.lowercase())
|
||||
put("interface", e.model.iface ?: "")
|
||||
put("mtu", e.model.link.mtu ?: 0)
|
||||
put("addresses", e.model.link.addresses.joinToString(", ") { "${it.addr}/${it.prefixLen}" })
|
||||
put("dns", (e.model.link.dns?.servers ?: emptyList()).joinToString(", "))
|
||||
put("nat64", e.model.link.dns?.nat64Prefix ?: "none")
|
||||
}
|
||||
}
|
||||
}
|
||||
val status = if (entries.isEmpty()) TestStatus.FAILED else TestStatus.OK
|
||||
return b.build(status, evidence = evidence)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.probe
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import android.net.NetworkCapabilities
|
||||
import app.echo_lot.measurement.Address
|
||||
import app.echo_lot.measurement.DnsConfig
|
||||
import app.echo_lot.measurement.Link
|
||||
import app.echo_lot.measurement.Route
|
||||
import app.echo_lot.measurement.Transport
|
||||
import app.echo_lot.measurement.Network as MNetwork
|
||||
|
||||
/**
|
||||
* Reads the app-tier snapshot of every active Android Network into measurement `networks[]`
|
||||
* (measurement-schema.md §4). App tier fills what LinkProperties exposes; route proto and address
|
||||
* lifetimes are Shizuku-tier and left absent (absence = "not observed"). Ported from the prober's
|
||||
* LinkPropertiesProbe.
|
||||
*/
|
||||
object NetworkInventory {
|
||||
|
||||
/** One measurement Network per active Android Network, plus the Android Network handle so
|
||||
* server/ICMP probes can bind to it. */
|
||||
data class Entry(val model: MNetwork, val handle: android.net.Network)
|
||||
|
||||
fun snapshot(ctx: Context): List<Entry> {
|
||||
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val out = ArrayList<Entry>()
|
||||
var idx = 0
|
||||
for (net in cm.allNetworks) {
|
||||
val caps = cm.getNetworkCapabilities(net) ?: continue
|
||||
val lp = cm.getLinkProperties(net) ?: continue
|
||||
out.add(Entry(model = toModel("net-${idx}", caps, lp), handle = net))
|
||||
idx++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun toModel(id: String, caps: NetworkCapabilities, lp: LinkProperties): MNetwork {
|
||||
val transport = when {
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Transport.WIFI
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> Transport.CELLULAR
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> Transport.ETHERNET
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> Transport.VPN
|
||||
else -> Transport.OTHER
|
||||
}
|
||||
val addresses = lp.linkAddresses.map {
|
||||
Address(
|
||||
addr = it.address.hostAddress ?: it.address.toString(),
|
||||
prefixLen = it.prefixLength,
|
||||
scope = null,
|
||||
)
|
||||
}
|
||||
val routes = lp.routes.map {
|
||||
Route(
|
||||
dst = it.destination.toString(),
|
||||
gateway = it.gateway?.hostAddress,
|
||||
iface = it.`interface`,
|
||||
)
|
||||
}
|
||||
val nat64 = runCatching { lp.nat64Prefix?.toString() }.getOrNull()
|
||||
val dns = DnsConfig(
|
||||
servers = lp.dnsServers.mapNotNull { it.hostAddress },
|
||||
privateDnsMode = if (lp.isPrivateDnsActive) "strict/opportunistic" else "off",
|
||||
privateDnsHostname = lp.privateDnsServerName,
|
||||
searchDomains = lp.domains?.split(",")?.map { it.trim() } ?: emptyList(),
|
||||
nat64Prefix = nat64,
|
||||
)
|
||||
return MNetwork(
|
||||
id = id, transport = transport, iface = lp.interfaceName,
|
||||
link = Link(mtu = lp.mtu.takeIf { it > 0 }, addresses = addresses, routes = routes, dns = dns),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package probe holds the device-tier probes (app + shizuku tiers). Each probe runs a platform
|
||||
// measurement and returns a core-measurement [Test] — raw evidence + recomputable metrics — never
|
||||
// throwing to the caller. Ported from the validated echolot-prober, now emitting the production
|
||||
// schema instead of the prober's ad-hoc format.
|
||||
package app.echo_lot.probe
|
||||
|
||||
import android.content.Context
|
||||
import app.echo_lot.measurement.Test
|
||||
import app.echo_lot.measurement.TestStatus
|
||||
import app.echo_lot.measurement.TestError
|
||||
import app.echo_lot.measurement.Tier
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/** A single device-tier measurement. [type] is a TestType registry id. */
|
||||
interface Probe {
|
||||
val type: String
|
||||
val tier: Tier
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** Injected UUID/clock source (measurement-schema.md: UUIDv7 ids, *_mono_ns math clock). */
|
||||
interface ProbeIds {
|
||||
fun uuid(): String
|
||||
/** Monotonic nanoseconds relative to the run's mono origin. */
|
||||
fun monoNs(): Long
|
||||
}
|
||||
|
||||
/** Builds a [Test] envelope, capturing start/end from the shared clock. */
|
||||
class TestBuilder(
|
||||
private val type: String,
|
||||
private val tier: Tier,
|
||||
private val ids: ProbeIds,
|
||||
private val networkRef: String? = null,
|
||||
private val sessionRef: String? = null,
|
||||
) {
|
||||
private val id = ids.uuid()
|
||||
private val startedMonoNs = ids.monoNs()
|
||||
|
||||
fun build(
|
||||
status: TestStatus,
|
||||
evidence: JsonObject? = null,
|
||||
metrics: JsonObject? = null,
|
||||
error: TestError? = null,
|
||||
): Test = Test(
|
||||
id = id, type = type, networkRef = networkRef, sessionRef = sessionRef, tier = tier,
|
||||
startedMonoNs = startedMonoNs, endedMonoNs = ids.monoNs(),
|
||||
status = status, error = error, evidence = evidence, metrics = metrics,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user