app: long runs that watch, and a relay for the port that keeps moving

Long mode starts listeners at t=0 and keeps them running past the
battery: a network-change watcher that finally fills networks[].changes[]
(defined since the schema's first draft, never populated), an RSSI log, a
ping series giving loss and jitter over minutes, and mDNS listening for
the whole window. This is the class of fault a short run cannot see - a
link that drops for four seconds between two probes is reported healthy
by both of them. run.mode records which question was asked, because
silence means different things in the two modes.

The adb relay replaces the retired beacon: AdbRelay watches adbd's own
mDNS with the resolve-once discipline the beacon learned the hard way
(resolving re-arms adbd and pops a notification), a foreground service
keeps it alive with the screen off, and the heartbeat re-posts the cached
endpoint rather than re-resolving. It exists because mDNS does not cross
subnets and the wireless-debug port rotates every few minutes.

Also records why LLDP/CDP cannot follow SSDP into long mode: both are raw
L2 frames, so they need CAP_NET_RAW - root tier, not app, and Shizuku's
shell user does not have it either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 14:43:33 +02:00
co-authored by Claude Opus 5
parent ae63bd7c7f
commit 0071e00003
24 changed files with 1834 additions and 107 deletions
@@ -0,0 +1,78 @@
// 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.TestError
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.Tier
/**
* A measurement that watches, rather than one that asks — the long-run counterpart to [Probe].
*
* The difference is not duration but what is observable at all. A [Probe] describes the network
* during its own two seconds, so anything intermittent is invisible to the whole battery unless it
* happens to coincide with a probe: a wifi link that drops for four seconds every two minutes is
* reported as perfectly healthy by every one-shot test, both before and after the gap. A collector
* starts at t=0, keeps sampling while the battery runs beside it and after it finishes, and yields
* one [Test] when the window closes.
*
* Contract, and all of it is load-bearing for a run that can be cancelled at any second:
* - [start] must return promptly, having launched whatever it needs on its own scope. The battery
* runs concurrently and must not wait for a listener.
* - [stop] must be callable after a failed [start], must never throw, and must return whatever was
* gathered so far. A cancelled long run still owes the user the two minutes it did watch.
* - Neither may throw to the caller; a collector that could not register its listener reports that
* as an `unsupported` Test, which is a result rather than an absence.
*/
interface Collector {
/** A TestType registry id — collectors do not get their own namespace. */
val type: String
val tier: Tier get() = Tier.APP
suspend fun start(ctx: Context, ids: ProbeIds)
suspend fun stop(): Test
}
/**
* Shared plumbing: the ids and the [TestBuilder] a collector needs in [Collector.stop], captured in
* [Collector.start] before anything that can fail.
*
* Assigned first thing on purpose. A collector whose registration throws still has to produce a
* Test saying so, and it cannot do that without a UUID source and a start timestamp — so acquiring
* them is never allowed to be the step that failed.
*/
abstract class BaseCollector : Collector {
protected var ids: ProbeIds? = null
private set
private var builder: TestBuilder? = null
/** Call at the top of [Collector.start], before any platform call. */
protected fun begin(ids: ProbeIds, networkRef: String? = null) {
this.ids = ids
builder = TestBuilder(type, tier, ids, networkRef = networkRef)
}
/**
* Builds this collector's Test, or — when [begin] never ran, so the collector was stopped
* without ever being started — a `skipped` one saying exactly that. It reports rather than
* throws for the same reason probes do: the caller is assembling a document, and an exception
* there costs every other collector's data too.
*/
protected fun build(
status: TestStatus,
evidence: kotlinx.serialization.json.JsonObject? = null,
metrics: kotlinx.serialization.json.JsonObject? = null,
error: TestError? = null,
params: kotlinx.serialization.json.JsonObject? = null,
): Test = builder?.build(status, evidence, metrics, error, params)
?: Test(
id = "00000000-0000-7000-8000-000000000000", type = type, tier = tier,
startedMonoNs = 0, endedMonoNs = 0, status = TestStatus.SKIPPED,
error = TestError("not_started", "the collector was stopped before it was started"),
)
}
@@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
import android.net.Network
import android.system.Os
import android.system.OsConstants
import android.system.StructTimeval
import java.io.FileDescriptor
import java.net.InetAddress
import java.nio.ByteBuffer
import java.util.Locale
/**
* One echo exchange over the unprivileged ICMP datagram socket.
*
* Extracted from [IcmpProbe] when the long-run [PingSeriesCollector] needed the same exchange a
* few hundred times instead of once. The two differ only in how often they call this; a second
* copy of the checksum and the sent/not-sent bookkeeping would only be a second place for them to
* drift apart.
*
* Works without root because Android ships an open `ping_group_range` — validated on hardware by
* the prober, and the reason this probe family exists at app tier at all.
*/
internal object IcmpEcho {
/**
* One attempt's result.
*
* [attempted] separates "we sent an echo request and heard nothing" from "we never got as far
* as sending one". Both leave [ok] false, and collapsing them is how a probe ends up asserting
* something about a network it never touched: binding to a non-default network can fail with
* EPERM, and reporting that as ICMP silence blames the network for the app's own inability to
* use the interface. For a series it is also the difference between a lost packet and a socket
* that was never usable — one is loss, the other is not.
*/
data class Result(
val ok: Boolean,
val attempted: Boolean,
val detail: String,
val rttMs: Double?,
)
fun ping(
network: Network?,
target: String,
v6: Boolean,
timeoutMs: Int,
seq: Int = 1,
): Result {
var fd: FileDescriptor? = null
var sent = false
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)
// Everything up to and including sendto is setup. A failure here means the test did
// not run on this network — not that the network stayed silent.
network?.bindSocket(fd)
Os.setsockoptTimeval(
fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO,
StructTimeval.fromMillis(timeoutMs.toLong()),
)
val addr = network?.getByName(target) ?: InetAddress.getByName(target)
val ident = (Os.getpid() and 0xFFFF)
val packet = buildEchoRequest(v6, ident.toShort(), seq.toShort())
val t0 = System.nanoTime()
Os.sendto(fd, packet, 0, packet.size, 0, addr, 0)
sent = true
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)
Result(
ok, true,
"reply type=$replyType rtt_ms=${"%.1f".format(Locale.ROOT, rttMs)} bytes=$received",
if (ok) rttMs else null,
)
} catch (e: Throwable) {
// A timeout after a successful send is a real "no reply"; anything before it is not.
Result(false, sent, "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()
// The v6 checksum is computed by the kernel over a pseudo-header the socket owns; filling
// it in here would be wrong, not merely redundant.
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()
}
}
@@ -5,9 +5,6 @@ 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
@@ -17,10 +14,6 @@ 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
@@ -40,7 +33,7 @@ class IcmpProbe(
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
val b = TestBuilder(type, tier, ids)
val perNetwork = LinkedHashMap<String, Pair<String?, Attempt>>()
val perNetwork = LinkedHashMap<String, Pair<String?, IcmpEcho.Result>>()
var anyOk = false
val rtts = ArrayList<Double>()
@@ -82,75 +75,9 @@ class IcmpProbe(
b.build(status, evidence = evidence, metrics = metrics)
}
/**
* One network's result.
*
* [attempted] separates "we sent an echo request and heard nothing" from "we never got as far
* as sending one". Both leave [ok] false, and collapsing them is how a probe ends up asserting
* something about a network it never touched: binding to a non-default network can fail with
* EPERM, and reporting that as ICMPv6 silence blames the carrier for the app's own inability
* to use the interface.
*/
private data class Attempt(
val ok: Boolean,
val attempted: Boolean,
val detail: String,
val rttMs: Double?,
)
private fun attempt(network: Network?): Attempt {
var fd: FileDescriptor? = null
var sent = false
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)
// Everything up to and including sendto is setup. A failure here means the test did
// not run on this network — not that the network stayed silent.
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)
sent = true
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, true, "reply type=$replyType rtt_ms=${"%.1f".format(Locale.ROOT, rttMs)} bytes=$received", if (ok) rttMs else null)
} catch (e: Throwable) {
// A timeout after a successful send is a real "no reply"; anything before it is not.
Attempt(false, sent, "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()
}
/** The echo exchange itself lives in [IcmpEcho], shared with the long-run ping series. */
private fun attempt(network: Network?): IcmpEcho.Result =
IcmpEcho.ping(network, target, v6, timeoutMs = 3000)
private companion object {
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
@@ -30,11 +30,16 @@ import java.util.Collections
* found live services — NsdManager's meta-query support is unreliable across builds, so the
* concrete types are the measurement and the meta-query result is itself evidence.
* - 4 s of listening missed services that 10 s catches; mDNS answers straggle.
*
* That last lesson is why [listenMs] is a parameter. 10 s is the short-mode default because it is
* the shortest window that was not demonstrably lossy; a long run hands it the whole measurement
* window, since the curve does not stop at ten seconds — devices announce on their own schedule,
* and a printer that is asleep answers when something else wakes it.
*/
class MdnsInventoryProbe : Probe {
class MdnsInventoryProbe(private val listenMs: Long = 10_000) : Probe {
override val type = TestType.LOCAL_MDNS_INVENTORY
override val tier = Tier.APP
override val estimatedMs = 10_500L
override val estimatedMs = listenMs + 500
/** Meta-query + common concrete types (HTTP covers HA/printers/NAS; googlecast is ubiquitous). */
private val queries = listOf(
@@ -75,7 +80,7 @@ class MdnsInventoryProbe : Probe {
Triple(label, type, r)
}
try {
delay(10_000)
delay(listenMs)
var total = 0
var anyStarted = false
val evidence = buildJsonObject {
@@ -95,10 +100,13 @@ class MdnsInventoryProbe : Probe {
}
}
val metrics = buildJsonObject { put("services_found", total) }
// How long it listened belongs in params: "4 services" means something different after
// ten seconds than after five minutes, and the number alone cannot say which it was.
val params = buildJsonObject { put("listen_ms", listenMs) }
// Zero services on a started discovery is a legitimate result (an empty or properly
// isolated network), not a failure — only discovery refusing to start is one.
b.build(if (anyStarted) TestStatus.OK else TestStatus.FAILED,
evidence = evidence, metrics = metrics)
evidence = evidence, metrics = metrics, params = params)
} finally {
recorders.forEach { (_, _, r) -> runCatching { nsd.stopServiceDiscovery(r) } }
runCatching { lock?.release() }
@@ -0,0 +1,274 @@
// 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.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import app.echo_lot.measurement.NetworkChange
import app.echo_lot.measurement.NetworkChanges
import app.echo_lot.measurement.Test
import app.echo_lot.measurement.TestError
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier
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
import java.util.Collections
/**
* Watches every network for the whole run and fills `networks[].changes[]` (measurement-schema §4).
*
* That array has been in the schema since the first draft and has never been populated, because
* nothing in a battery of one-shot probes is in a position to fill it. It is the single most
* valuable thing a long run adds: a wifi link that drops and returns mid-window is invisible to
* every probe — the ones before and after the gap both succeed — and it is exactly the fault people
* open a network diagnostic to chase.
*
* Reported as [TestType.LINK_IP_MONITOR] at app tier. The registry lists that type as Shizuku's
* (`ip monitor`), and this is deliberately the same observation from a tier that does not need it:
* link state as it changes over time. A document may therefore carry two `link.ip_monitor` tests,
* told apart by `tier` — which is what `tier` is for.
*/
class NetworkChangeCollector(
private val entries: List<NetworkInventory.Entry>,
) : BaseCollector() {
override val type = TestType.LINK_IP_MONITOR
override val tier = Tier.APP
private data class Change(
val atMonoNs: Long,
val kind: String,
val event: String,
val iface: String,
val detail: String,
)
private val changes: MutableList<Change> = Collections.synchronizedList(mutableListOf())
private var cm: ConnectivityManager? = null
private var callback: ConnectivityManager.NetworkCallback? = null
private var registerError: String? = null
/**
* Last seen state per network, so only real changes are recorded.
*
* Both capability and link-property callbacks fire constantly on a live device — signal
* strength alone re-delivers capabilities every few seconds — and a five-minute window of that
* would bury the four events that matter under several hundred that do not. The first callback
* after a network appears is the baseline, not a change.
*/
private val lastCaps = HashMap<String, String>()
private val lastLink = HashMap<String, String>()
/** Interface name per network handle, remembered because `onLost` can no longer look it up. */
private val ifaceOf = HashMap<String, String>()
/**
* The networks that were already up when the window opened.
*
* `registerNetworkCallback` replays `onAvailable` for every matching network the instant it is
* registered, so without this every run would open with three "the wifi appeared" events that
* describe the registration and not the network. A link that drops and returns comes back as a
* new handle, which is not in this set, so real re-appearances are still recorded.
*/
private val seeded = HashSet<String>()
override suspend fun start(ctx: Context, ids: ProbeIds) {
begin(ids)
val manager = ctx.getSystemService(ConnectivityManager::class.java)
if (manager == null) {
registerError = "ConnectivityManager unavailable"
return
}
cm = manager
// Seed the interface names from the snapshot the run already took, so a network that is
// lost without ever having delivered a callback here is still attributable.
for (e in entries) {
e.model.iface?.let { ifaceOf[key(e.handle)] = it }
seeded.add(key(e.handle))
}
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
val iface = resolveIface(network)
if (key(network) in seeded) return
record(ids, NetworkChanges.GAINED, "available", iface, "network became available")
}
override fun onLost(network: Network) {
val iface = ifaceOf[key(network)] ?: "(unknown)"
record(ids, NetworkChanges.LOST, "lost", iface, "network went away")
// Dropped so a returning link re-baselines instead of reporting every property it
// ever had as a change the moment it comes back.
lastCaps.remove(key(network)); lastLink.remove(key(network))
}
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
val iface = resolveIface(network)
val print = capsFingerprint(caps)
val previous = lastCaps.put(key(network), print)
if (previous == null || previous == print) return
record(ids, NetworkChanges.LINK_CHANGED, "capabilities", iface, "$previous$print")
}
override fun onLinkPropertiesChanged(network: Network, lp: LinkProperties) {
lp.interfaceName?.let { ifaceOf[key(network)] = it }
val print = linkFingerprint(lp)
val previous = lastLink.put(key(network), print)
if (previous == null || previous == print) return
record(ids, NetworkChanges.LINK_CHANGED, "link_properties", lp.interfaceName ?: "(unknown)",
describeLinkDelta(previous, print))
}
override fun onLosing(network: Network, maxMsToLive: Int) {
val iface = ifaceOf[key(network)] ?: "(unknown)"
record(ids, NetworkChanges.LINK_CHANGED, "losing", iface, "about to be torn down in ${maxMsToLive} ms")
}
}
callback = cb
// clearCapabilities(), or the default request only matches INTERNET + NOT_RESTRICTED and
// the carrier's IMS/MMS networks — and, more importantly, a network in the middle of
// failing validation — never appear. The transports are named explicitly so this does not
// also follow whatever internal networks a vendor keeps in the list.
val request = NetworkRequest.Builder()
.clearCapabilities()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_VPN)
.build()
runCatching { manager.registerNetworkCallback(request, cb) }
.onFailure { registerError = it.message ?: it.javaClass.simpleName; callback = null }
}
override suspend fun stop(): Test {
callback?.let { cb -> runCatching { cm?.unregisterNetworkCallback(cb) } }
callback = null
val snapshot = synchronized(changes) { changes.toList() }
if (registerError != null) {
return build(
TestStatus.UNSUPPORTED,
error = TestError("callback_unavailable", registerError),
)
}
val evidence: JsonObject = buildJsonObject {
putJsonArray("changes") {
for (c in snapshot) addJsonObject {
put("at_mono_ns", c.atMonoNs)
put("kind", c.kind)
put("event", c.event)
put("interface", c.iface)
put("detail", c.detail)
}
}
}
val perIface = snapshot.groupBy { it.iface }
val metrics: JsonObject = buildJsonObject {
put("changes_total", snapshot.size)
put("networks_lost", snapshot.count { it.kind == NetworkChanges.LOST })
put("networks_gained", snapshot.count { it.event == "available" })
put("link_changes", snapshot.count { it.kind == NetworkChanges.LINK_CHANGED })
// The number a reader actually wants: how many times a link went away and came back.
// Counted by the same function the flapping finding uses, so the metric and the
// finding can never tell different stories about one window.
put("flap_cycles", perIface.values.sumOf { NetworkChanges.flapCycles(it.map { c -> c.kind }) })
}
// Zero changes over the window is a real, useful result — a stable network — so it is OK
// rather than a failure. The window that produced it is what makes that mean anything, and
// it is recorded in run.mode plus the sibling collectors' params.
return build(TestStatus.OK, evidence = evidence, metrics = metrics)
}
/**
* The changes belonging to each network in `networks[]`, keyed by its model id.
*
* Matched by interface name rather than by Android's `Network` handle, because a link that
* drops and returns comes back as a *different* handle with the same interface — and the
* flapping case is precisely the one this must not lose. Changes on an interface that was not
* in the run's initial snapshot stay in the test evidence but have no `networks[]` entry to
* hang from.
*/
fun changesByNetwork(): Map<String, List<NetworkChange>> {
val byIface = entries.mapNotNull { e -> e.model.iface?.let { it to e.model.id } }.toMap()
val out = LinkedHashMap<String, MutableList<NetworkChange>>()
for (c in synchronized(changes) { changes.toList() }) {
val id = byIface[c.iface] ?: continue
out.getOrPut(id) { mutableListOf() }.add(
NetworkChange(
atMonoNs = c.atMonoNs,
kind = c.kind,
detail = buildJsonObject {
put("event", c.event)
put("interface", c.iface)
put("detail", c.detail)
},
)
)
}
return out
}
private fun record(ids: ProbeIds, kind: String, event: String, iface: String, detail: String) {
changes.add(Change(ids.monoNs(), kind, event, iface, detail))
}
private fun resolveIface(network: Network): String {
val known = ifaceOf[key(network)]
if (known != null) return known
val name = runCatching { cm?.getLinkProperties(network)?.interfaceName }.getOrNull()
if (name != null) ifaceOf[key(network)] = name
return name ?: "(unknown)"
}
private companion object {
/** Android's own network id, stable for the life of one Network object. */
private fun key(n: Network): String = n.toString()
/**
* Only the capabilities whose change means something diagnostically.
*
* Bandwidth estimates and signal strength are deliberately excluded: they change every few
* seconds on a moving device, and including them turns a change log into a sampling log.
* VALIDATED and CAPTIVE_PORTAL are the two that matter most — they are the moment Android
* decides a network does or does not carry the internet.
*/
private fun capsFingerprint(c: NetworkCapabilities): String = buildString {
fun flag(name: String, cap: Int) {
if (runCatching { c.hasCapability(cap) }.getOrDefault(false)) append(name).append(' ')
}
flag("internet", NetworkCapabilities.NET_CAPABILITY_INTERNET)
flag("validated", NetworkCapabilities.NET_CAPABILITY_VALIDATED)
flag("captive", NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)
flag("not-metered", NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
flag("not-suspended", NET_CAPABILITY_NOT_SUSPENDED)
flag("not-restricted", NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
}.trim().ifEmpty { "(none)" }
/** NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED, API 28+ (@SystemApi constant). */
private const val NET_CAPABILITY_NOT_SUSPENDED = 21
private fun linkFingerprint(lp: LinkProperties): String {
val addrs = lp.linkAddresses.map { it.toString() }.sorted().joinToString(",")
val routes = lp.routes.map { it.toString() }.sorted().joinToString(",")
val dns = lp.dnsServers.mapNotNull { it.hostAddress }.sorted().joinToString(",")
return "mtu=${lp.mtu}|addr=$addrs|route=$routes|dns=$dns"
}
/** Names which part of the link changed, so the detail is readable without a diff tool. */
private fun describeLinkDelta(before: String, after: String): String {
val b = before.split('|'); val a = after.split('|')
val changed = b.indices.filter { it < a.size && b[it] != a[it] }
.map { a[it].substringBefore('=') }
return if (changed.isEmpty()) "changed" else "changed: ${changed.joinToString(", ")}"
}
}
}
@@ -0,0 +1,158 @@
// 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.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.add
import java.util.Collections
/**
* icmp.ping4 sampled across the window — loss and jitter over minutes instead of one packet.
*
* The battery's [IcmpProbe] answers "does this network reply at all", which one echo can settle.
* It cannot answer "how often does it not", and that is the complaint people actually have:
* 2 % loss is invisible to a single ping and ruins a video call. A series over five minutes also
* catches loss that comes in bursts, which an average taken over ten packets in one second cannot
* distinguish from a clean link.
*
* Emitted as its own `icmp.ping4` test alongside the battery's. `params` carries the window and
* the interval precisely so the two are never mistaken for each other — a reader seeing 300 sent
* packets in one and 1 in the other must be able to tell which is which without guessing.
*
* Sustained loss here deliberately emits **no finding**. Every loss code in the registry is about
* the server path — `connectivity.udp_loss` and its directional siblings all say "UDP", and they
* mean the probe protocol's traffic, whose direction the server can attest to. ICMP echo to a
* public address is a different measurement with a different set of benign explanations (rate
* limiting at the target is the obvious one), and borrowing a code that claims otherwise would put
* two unrelated things under one dashboard entry — the exact failure the registry exists to
* prevent. The metrics say what was seen; a code for it can be added when it has been defined.
*/
class PingSeriesCollector(
private val target: String = "1.1.1.1",
private val intervalMs: Long = 2_000,
/** Deliberately below [intervalMs]: a reply that arrives after the next probe was due is lost
* for any practical purpose, and waiting for it would make the series drift out of cadence. */
private val timeoutMs: Int = 1_500,
) : BaseCollector() {
override val type = TestType.ICMP_PING4
override val tier = Tier.APP
private val txMonoNs: MutableList<Long> = Collections.synchronizedList(mutableListOf())
private val rttMs: MutableList<Double?> = Collections.synchronizedList(mutableListOf())
private var notSent = 0
private var scope: CoroutineScope? = null
private var startedAtMonoNs = 0L
override suspend fun start(ctx: Context, ids: ProbeIds) {
begin(ids)
startedAtMonoNs = ids.monoNs()
// The default network, and only the default network: this measures what the device's own
// traffic experiences over the window. Per-network binding is the battery's job, and doing
// it here would multiply the packet rate by the number of interfaces for no new answer.
val s = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it }
s.launch {
var seq = 1
while (isActive) {
val t0 = ids.monoNs()
val r = IcmpEcho.ping(null, target, v6 = false, timeoutMs = timeoutMs, seq = seq)
if (r.attempted) {
txMonoNs.add(t0)
rttMs.add(r.rttMs)
} else {
// Never left the device — a socket or bind failure is not packet loss, and
// counting it as loss would blame the network for the app's own trouble.
notSent++
}
seq = (seq + 1) and 0xFFFF
delay(intervalMs)
}
}
}
override suspend fun stop(): Test {
scope?.coroutineContext?.get(Job)?.cancel()
scope = null
val tx = synchronized(txMonoNs) { txMonoNs.toList() }
val rtt = synchronized(rttMs) { rttMs.toList() }
val received = rtt.filterNotNull()
val evidence: JsonObject = buildJsonObject {
putJsonArray("seq") { for (i in tx.indices) add(i) }
putJsonArray("t_tx_ns") { for (v in tx) add(v) }
// null at an index is a lost probe, per the §6.2 columnar convention.
putJsonArray("rtt_ms") {
for (v in rtt) add(v?.let { JsonPrimitive(round1(it)) } ?: JsonNull)
}
}
val metrics: JsonObject = buildJsonObject {
put("sent", tx.size)
put("received", received.size)
put("not_sent", notSent)
if (tx.isNotEmpty()) {
put("loss_pct", round1((tx.size - received.size) * 100.0 / tx.size))
}
if (received.isNotEmpty()) {
put("rtt_ms_min", round1(received.min()))
put("rtt_ms_avg", round1(received.average()))
put("rtt_ms_max", round1(received.max()))
put("jitter_ms", round1(meanDeviation(received)))
}
}
val status = when {
tx.isEmpty() -> TestStatus.UNSUPPORTED
received.isEmpty() -> TestStatus.FAILED
received.size < tx.size -> TestStatus.PARTIAL
else -> TestStatus.OK
}
return build(status, evidence = evidence, metrics = metrics, params = params())
}
private fun params(): JsonObject = buildJsonObject {
// What separates this from the battery's single ping, and what a reader needs to reproduce
// it. Without these two numbers "300 packets, 2 % loss" is a rate nobody can interpret.
put("mode", "series")
put("target", target)
put("interval_ms", intervalMs)
put("timeout_ms", timeoutMs)
put("started_mono_ns", startedAtMonoNs)
put("network", "default")
}
private companion object {
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
/**
* Mean deviation between consecutive round trips — jitter as a stream experiences it.
*
* Not the spread around the average: a link that alternates 20 ms / 200 ms and one that
* drifts slowly from 20 ms to 200 ms have the same standard deviation, and only the first
* one breaks a call.
*/
fun meanDeviation(values: List<Double>): Double {
if (values.size < 2) return 0.0
var sum = 0.0
for (i in 1 until values.size) sum += kotlin.math.abs(values[i] - values[i - 1])
return sum / (values.size - 1)
}
}
}
@@ -0,0 +1,69 @@
// 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.TestError
import app.echo_lot.measurement.TestStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.withTimeoutOrNull
/**
* Runs an ordinary [Probe] beside the battery instead of inside it.
*
* Some probes are already listeners with a fixed window — [MdnsInventoryProbe] does nothing but
* wait for answers — and in a long run their window should be the run's window. Running them in
* the sequential battery would then stall every probe behind them for five minutes, which is a
* scheduling problem and not a measurement one, so the fix is to move them rather than to shorten
* them.
*
* Durations are deliberately *not* fed back into the estimate learning: a probe that listens for
* the whole window would teach the short-mode progress bar that mDNS discovery takes five minutes.
*/
class ProbeCollector(private val probe: Probe) : BaseCollector() {
override val type get() = probe.type
override val tier get() = probe.tier
private var scope: CoroutineScope? = null
private var running: Deferred<Test>? = null
override suspend fun start(ctx: Context, ids: ProbeIds) {
begin(ids)
val s = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it }
running = s.async { probe.run(ctx, ids) }
}
override suspend fun stop(): Test {
val job = running
running = null
val finished = job?.let {
// A short grace, not a long one. A probe timed to the window has already finished by
// the time this is called, so the normal path returns instantly; the grace only covers
// it being slightly late. It is deliberately kept to a second and a half because the
// other caller is the Cancel button, where every millisecond spent waiting for a
// listener that will not finish is a millisecond the user watches nothing happen.
withTimeoutOrNull(GRACE_MS) { runCatching { it.await() }.getOrNull() }
}
scope?.coroutineContext?.get(Job)?.cancel()
scope = null
return finished ?: build(
TestStatus.PARTIAL,
error = TestError(
"window_closed",
"the run's window ended before this listener finished",
),
)
}
private companion object {
const val GRACE_MS = 1_500L
}
}
@@ -0,0 +1,165 @@
// 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.wifi.WifiManager
import app.echo_lot.measurement.Test
import app.echo_lot.measurement.TestError
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier
import app.echo_lot.measurement.Transport
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.add
import java.util.Collections
/**
* wifi.signal_log — RSSI, link speed and frequency sampled across the whole window.
*
* One reading of the signal strength says almost nothing: -67 dBm is fine, and -67 dBm that was
* -45 dBm ninety seconds ago is somebody walking away from the AP, or an AP whose power is being
* managed, or a band steer about to happen. The series is the measurement; the snapshot in
* `networks[].wifi` is only its first sample.
*
* Evidence is columnar (measurement-schema §6.2 conventions): parallel arrays keep a five-minute
* log at 2 s intervals in a few kB.
*/
class WifiSignalCollector(
private val entries: List<NetworkInventory.Entry>,
private val intervalMs: Long = 2_000,
) : BaseCollector() {
override val type = TestType.WIFI_SIGNAL_LOG
override val tier = Tier.APP
private val atMonoNs: MutableList<Long> = Collections.synchronizedList(mutableListOf())
private val rssi: MutableList<Int> = Collections.synchronizedList(mutableListOf())
private val speed: MutableList<Int> = Collections.synchronizedList(mutableListOf())
private val freq: MutableList<Int> = Collections.synchronizedList(mutableListOf())
/** Only the count of distinct BSSIDs leaves this class — a roam is the fact worth reporting,
* and the addresses themselves are neighbours' hardware identifiers. */
private val bssids = Collections.synchronizedSet(HashSet<String>())
private var scope: CoroutineScope? = null
private var unsupported: String? = null
private var startedAtMonoNs = 0L
override suspend fun start(ctx: Context, ids: ProbeIds) {
// network_ref up front: this samples the wifi link, and a signal log with nothing to
// attach it to is a series of numbers about an unnamed thing.
val wifiNet = entries.firstOrNull { it.model.transport == Transport.WIFI }
begin(ids, networkRef = wifiNet?.model?.id)
startedAtMonoNs = ids.monoNs()
val wifi = ctx.applicationContext.getSystemService(WifiManager::class.java)
if (wifi == null) {
unsupported = "WifiManager unavailable"
return
}
if (wifiNet == null) {
unsupported = "no wifi network is connected"
return
}
// Own scope, not the caller's: the run job is cancelled the instant the user taps Cancel,
// and the samples taken up to that point are exactly what a cancelled long run still owes
// them. stop() ends this scope.
val s = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it }
s.launch {
while (isActive) {
sample(ids, wifi)
delay(intervalMs)
}
}
}
@Suppress("DEPRECATION")
private fun sample(ids: ProbeIds, wifi: WifiManager) {
// WifiManager.getConnectionInfo is deprecated in favour of the NetworkCallback's
// TransportInfo, which delivers a WifiInfo only when the capabilities change — i.e. at the
// platform's cadence, not ours, and with no way to ask for a sample. For a fixed-interval
// log the deprecated call is the one that answers the question, and it still works.
val info = runCatching { wifi.connectionInfo }.getOrNull() ?: return
val r = info.rssi
// -127 and 0 are the "no reading" sentinels; recording them would drag every average down
// and invent a signal cliff that never happened.
if (r == 0 || r <= -127) return
atMonoNs.add(ids.monoNs())
rssi.add(r)
speed.add(info.linkSpeed)
freq.add(runCatching { info.frequency }.getOrDefault(0))
runCatching { info.bssid }.getOrNull()
?.takeIf { it.isNotBlank() && it != "02:00:00:00:00:00" }
?.let { bssids.add(it) }
}
override suspend fun stop(): Test {
scope?.coroutineContext?.get(Job)?.cancel()
scope = null
unsupported?.let {
return build(
TestStatus.UNSUPPORTED,
params = params(),
error = TestError("no_wifi", it),
)
}
val t = synchronized(atMonoNs) { atMonoNs.toList() }
val r = synchronized(rssi) { rssi.toList() }
val sp = synchronized(speed) { speed.toList() }
val f = synchronized(freq) { freq.toList() }
val evidence: JsonObject = buildJsonObject {
putJsonArray("at_mono_ns") { for (v in t) add(v) }
putJsonArray("rssi_dbm") { for (v in r) add(v) }
putJsonArray("link_speed_mbps") { for (v in sp) add(v) }
putJsonArray("frequency_mhz") { for (v in f) add(v) }
}
val metrics: JsonObject = buildJsonObject {
put("samples", r.size)
if (r.isNotEmpty()) {
put("rssi_dbm_min", r.min())
put("rssi_dbm_avg", round1(r.average()))
put("rssi_dbm_max", r.max())
put("rssi_dbm_range", r.max() - r.min())
}
sp.filter { it > 0 }.let { valid ->
if (valid.isNotEmpty()) {
put("link_speed_mbps_min", valid.min())
put("link_speed_mbps_avg", round1(valid.average()))
put("link_speed_mbps_max", valid.max())
}
}
// Distinct BSSIDs minus the one we started on: how often the phone changed AP without
// the network ever going down — invisible to any one-shot probe, and a common cause of
// "the call drops when I walk into the kitchen".
put("roams", (bssids.size - 1).coerceAtLeast(0))
}
return build(
if (r.isEmpty()) TestStatus.PARTIAL else TestStatus.OK,
evidence = evidence, metrics = metrics, params = params(),
)
}
private fun params(): JsonObject = buildJsonObject {
put("interval_ms", intervalMs)
put("started_mono_ns", startedAtMonoNs)
put("source", "WifiManager.connectionInfo")
}
private companion object {
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
}
}