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:
co-authored by
Claude Opus 5
parent
ae63bd7c7f
commit
0071e00003
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user