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
@@ -1379,6 +1379,26 @@ poisons `/releases/latest` for the string-comparing updater the moment anyone ta
|
||||
The stale `server-v0.9.2` release (same code lineage, wrong number, created during the confusion)
|
||||
remains in Gitea but is harmless now that v0.11.3 outranks it as latest.
|
||||
|
||||
## LLDP and CDP are root-tier, and that is a hard boundary (2026-08-02)
|
||||
|
||||
Asked for alongside SSDP in long mode; they belong to a different tier and no amount of app-side
|
||||
cleverness moves them. LLDP is an EtherType `0x88CC` frame to `01:80:C2:00:00:0E`; CDP is an
|
||||
LLC/SNAP frame to `01:00:0C:CC:CC:CC`. Neither is IP, so neither is ever delivered to a socket an
|
||||
app can open — receiving them needs `AF_PACKET` with `CAP_NET_RAW`, which is root. Shizuku does
|
||||
not bridge this either: the ADB shell user (uid 2000) has no `CAP_NET_RAW`, and stock devices do
|
||||
not ship `tcpdump`. Android's unprivileged ICMP sockets are what make `icmp.ping4` work without
|
||||
root; there is no equivalent back door for raw L2 receive.
|
||||
|
||||
Worth building in the root module when it lands, because the payoff is large: LLDP names the
|
||||
switch, the port and the VLAN a device is attached to, which is the best available answer to
|
||||
"where in this building am I actually plugged in", and CDP does the same on Cisco gear. Until
|
||||
then they are recorded as absent capabilities rather than left to look unimplemented.
|
||||
|
||||
What IS reachable at app tier, and what long mode now listens for instead: SSDP (passive NOTIFY
|
||||
plus periodic M-SEARCH), LLMNR, NetBIOS-NS and WS-Discovery — all IP multicast/broadcast, all
|
||||
sockets an app may open. The security reading matters as much as the inventory: LLMNR and
|
||||
NetBIOS-NS being live on a segment is a finding in itself, since both are trivially spoofable.
|
||||
|
||||
## Design note: what BLE between two devices is actually for (2026-08-02, not built)
|
||||
|
||||
Two or more phones running Echolot, talking over Bluetooth LE. The schema already anticipates
|
||||
|
||||
@@ -50,6 +50,13 @@ because it looks authoritative.
|
||||
| `connectivity.downstream_reorder` | low | Downstream packets arrive in a different order than they were sent. | — |
|
||||
| `connectivity.captive_portal` | medium | A captive portal is intercepting connectivity checks. | — |
|
||||
| `connectivity.no_internet` | high | Android's own connectivity checks fail on this network. | — |
|
||||
| `connectivity.link_flapping` | medium | A network dropped and came back one or more times during the run. | A momentary probe failure: the drop was watched happening, not inferred from silence. |
|
||||
|
||||
`connectivity.link_flapping` is only reachable from a **long run** (`run.mode: "long"`,
|
||||
measurement-schema §3). It is derived from `networks[].changes[]` rather than from any test's
|
||||
evidence, because no one-shot probe can produce it: the probes before and after a four-second drop
|
||||
both succeed. The emitter escalates to *high* from three completed drop-and-return cycles, and
|
||||
requires the cycle to complete — a network switched off partway through a run is not flapping.
|
||||
|
||||
### mtu
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ Export encoding: UTF-8 JSON, gzip for files (`.echolot.json.gz`), share intent u
|
||||
{
|
||||
"id": "0198c5f2-...-uuidv7",
|
||||
"trigger": "manual | scheduled | monitor | peer",
|
||||
"mode": "short | long",
|
||||
"started_at": "2026-07-29T14:03:21.114Z",
|
||||
"ended_at": "2026-07-29T14:07:44.902Z",
|
||||
"clock": {
|
||||
@@ -63,6 +64,27 @@ Export encoding: UTF-8 JSON, gzip for files (`.echolot.json.gz`), share intent u
|
||||
|
||||
`tiers` records what was *available*; each test records what it *used*.
|
||||
|
||||
`mode` records how long the run watched, and it exists because **it changes what a reader may
|
||||
conclude from absence**. A `short` run is a sequence of one-shot probes — each looks at the network
|
||||
for a second or two and moves on — which characterises the network's *configuration* well and is
|
||||
structurally blind to anything intermittent. A `long` run starts continuous listeners at t=0, runs
|
||||
the same battery beside them, and keeps sampling until its window closes; the window's length is
|
||||
recorded in the `params` of the tests the listeners produce, not here.
|
||||
|
||||
The consequence is asymmetric and matters more than the field looks. A finding is worth the same in
|
||||
either mode: a drop that was observed, was observed. Silence is not. "No link changes were seen" is
|
||||
evidence of a stable link after five minutes of watching and is evidence of nothing at all after a
|
||||
thirty-second run, in which a link could drop and return between two consecutive probes without
|
||||
leaving a mark anywhere in the document. Consumers — a diff between two runs, a dashboard counting
|
||||
how often a fault occurs, a person reading one report — must therefore not treat the absence of a
|
||||
time-dependent finding in a `short` run as its refutation, and must not compare the two modes as if
|
||||
they had asked the same question. `connectivity.link_flapping` is the first finding that only a
|
||||
`long` run can reach; `networks[].changes[]` (§4) is likewise populated only by a long run's
|
||||
listener, and an empty `changes[]` in a short run means "not watched", never "nothing happened".
|
||||
|
||||
Absent `mode` means `short`: it was added after the first documents were written, and every one of
|
||||
them was a battery of one-shot probes.
|
||||
|
||||
`constraints` records what was *prevented*. A constrained run is neither a failed run nor a normal
|
||||
one, and the distinction has to survive into the data: a run taken through a VPN has the same shape
|
||||
and the same green verdict as a clean run of a healthy network, so without this a reader — or a
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<!--
|
||||
The adb relay (AdbRelayService) only. It runs in the foreground because it must keep
|
||||
watching while the tablet sits unattended with its screen off, and dataSync is the type
|
||||
that describes it: it carries an observation off the LAN, nothing more. Android 15 caps
|
||||
dataSync at a few hours a day, which is acceptable for a tool that is switched on for a
|
||||
debugging session rather than left running forever.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!-- Only so the relay's ongoing status is visible; the service runs either way. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
@@ -53,6 +64,15 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
Not exported: nothing outside this app has any business starting a relay that reports
|
||||
where this device can be reached.
|
||||
-->
|
||||
<service
|
||||
android:name=".AdbRelayService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.app
|
||||
|
||||
import android.content.Context
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.net.wifi.WifiManager
|
||||
import java.net.Inet4Address
|
||||
|
||||
/**
|
||||
* Watches adbd's own mDNS advertisement on this device and reports the endpoint to the server.
|
||||
*
|
||||
* This replaces the retired Python adb-beacon, and exists for one reason: **mDNS does not cross
|
||||
* subnets**. A developer on another network cannot see `_adb-tls-connect._tcp` at all, while the
|
||||
* wireless-debug port rotates every few minutes — so the port has to be carried out of the LAN by
|
||||
* something sitting inside it. That is this. A tablet parked on the test network relays; the
|
||||
* developer reads the endpoint back from the server.
|
||||
*
|
||||
* Two hard-won rules from the beacon, both load-bearing:
|
||||
*
|
||||
* - **Resolve each service instance exactly ONCE.** Resolving adbd's advertisement makes adbd
|
||||
* re-arm its connection and post a "wireless debugging connected" notification; re-resolving on
|
||||
* every heartbeat turns that into a stream of them. The guard is cleared only when the service
|
||||
* is *lost*, which is also what catches rotation: the new advertisement is a new instance, gets
|
||||
* resolved once, and is reported within seconds.
|
||||
* - **Do not run this on the OnePlus.** On network churn that device drops and re-publishes its
|
||||
* advertisement repeatedly, so lost/found cycles keep clearing the guard and each resolve
|
||||
* re-arms adbd. Guarding reduces but cannot eliminate the noise; the Lenovo tablet is the
|
||||
* intended host, which is also why relaying is a mode rather than something always on.
|
||||
*/
|
||||
class AdbRelay(
|
||||
private val ctx: Context,
|
||||
private val onEvent: (String) -> Unit,
|
||||
) {
|
||||
private val nsd = ctx.getSystemService(NsdManager::class.java)
|
||||
|
||||
/** Instances already resolved, by service name — the re-arm guard described above. */
|
||||
private val resolved = HashSet<String>()
|
||||
|
||||
/** Last endpoint reported, so the heartbeat re-posts from cache instead of re-resolving. */
|
||||
@Volatile var lastEndpoint: Endpoint? = null
|
||||
private set
|
||||
|
||||
data class Endpoint(val host: String, val port: Int, val serviceName: String)
|
||||
|
||||
private var listener: NsdManager.DiscoveryListener? = null
|
||||
|
||||
fun start() {
|
||||
if (nsd == null) {
|
||||
onEvent("mDNS unavailable on this device")
|
||||
return
|
||||
}
|
||||
if (listener != null) return
|
||||
val l = object : NsdManager.DiscoveryListener {
|
||||
override fun onStartDiscoveryFailed(type: String?, code: Int) {
|
||||
onEvent("discovery failed to start (code $code)")
|
||||
}
|
||||
override fun onStopDiscoveryFailed(type: String?, code: Int) {}
|
||||
override fun onDiscoveryStarted(type: String?) {
|
||||
onEvent("watching for adbd on this network")
|
||||
}
|
||||
override fun onDiscoveryStopped(type: String?) {}
|
||||
|
||||
override fun onServiceFound(info: NsdServiceInfo?) {
|
||||
val name = info?.serviceName ?: return
|
||||
// The guard: one resolve per instance, ever. adbd re-arms on every resolve.
|
||||
if (!resolved.add(name)) return
|
||||
resolve(info)
|
||||
}
|
||||
|
||||
override fun onServiceLost(info: NsdServiceInfo?) {
|
||||
// Rotation: the old instance is gone, so allow the replacement to be resolved.
|
||||
info?.serviceName?.let { resolved.remove(it) }
|
||||
}
|
||||
}
|
||||
listener = l
|
||||
runCatching { nsd.discoverServices(ADB_SERVICE, NsdManager.PROTOCOL_DNS_SD, l) }
|
||||
.onFailure { onEvent("could not start discovery: ${it.message}") }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
listener?.let { l -> runCatching { nsd?.stopServiceDiscovery(l) } }
|
||||
listener = null
|
||||
resolved.clear()
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION") // the callback-based resolve is the one that exists across our minSdk
|
||||
private fun resolve(info: NsdServiceInfo) {
|
||||
val cb = object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(i: NsdServiceInfo?, code: Int) {
|
||||
// Let a failed instance be retried: the guard exists to stop *successful*
|
||||
// re-resolution, not to give up on a transient failure.
|
||||
i?.serviceName?.let { resolved.remove(it) }
|
||||
onEvent("resolve failed (code $code)")
|
||||
}
|
||||
|
||||
override fun onServiceResolved(i: NsdServiceInfo?) {
|
||||
val host = i?.host?.hostAddress ?: return
|
||||
// adbd advertises on every address it listens on, including link-local v6. The
|
||||
// reachable one from a developer's subnet is the routable v4 address, and it is
|
||||
// also the only one worth relaying — a link-local address means nothing off-link.
|
||||
if (i.host !is Inet4Address) return
|
||||
// Only this device's own advertisement: on a shared network several phones may
|
||||
// have wireless debugging on, and relaying a neighbour's port would send a
|
||||
// developer to the wrong device.
|
||||
if (host != localIp()) return
|
||||
lastEndpoint = Endpoint(host, i.port, i.serviceName ?: "adb")
|
||||
onEvent("found adbd at $host:${i.port}")
|
||||
}
|
||||
}
|
||||
runCatching { nsd?.resolveService(info, cb) }
|
||||
.onFailure {
|
||||
resolved.remove(info.serviceName)
|
||||
onEvent("resolve threw: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** This device's own IPv4 address on the wifi it is relaying from. */
|
||||
private fun localIp(): String? = runCatching {
|
||||
val wifi = ctx.getSystemService(WifiManager::class.java) ?: return null
|
||||
@Suppress("DEPRECATION")
|
||||
val ip = wifi.connectionInfo.ipAddress
|
||||
if (ip == 0) return null
|
||||
@Suppress("DEPRECATION")
|
||||
String.format(
|
||||
"%d.%d.%d.%d",
|
||||
ip and 0xff, ip shr 8 and 0xff, ip shr 16 and 0xff, ip shr 24 and 0xff,
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
private companion object {
|
||||
const val ADB_SERVICE = "_adb-tls-connect._tcp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.app
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import app.echo_lot.protocol.ControlClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Keeps [AdbRelay] running and posts what it finds to the enrolled server.
|
||||
*
|
||||
* A foreground service because the whole point is to be useful while nobody is looking at the
|
||||
* tablet: a background process is frozen within minutes of the screen going off, and a relay that
|
||||
* stops relaying the moment it is left alone would be worse than none — it would be trusted right
|
||||
* up until the moment it went quiet.
|
||||
*
|
||||
* The heartbeat re-posts the CACHED endpoint and never re-resolves. Resolving adbd's advertisement
|
||||
* makes adbd re-arm its connection and raise a "wireless debugging connected" notification, so a
|
||||
* heartbeat that re-resolved would turn a background convenience into a stream of notifications on
|
||||
* a device sitting on a shelf. Rotation is still caught, because losing the old advertisement
|
||||
* clears the resolve guard in [AdbRelay] and the replacement is resolved once, within seconds.
|
||||
*/
|
||||
class AdbRelayService : Service() {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var relay: AdbRelay? = null
|
||||
|
||||
@Volatile private var status: String = "starting"
|
||||
@Volatile private var lastPosted: String? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
startForeground(NOTIFICATION_ID, notification("starting"))
|
||||
val settings = Settings(this)
|
||||
val r = AdbRelay(this) { msg ->
|
||||
status = msg
|
||||
notify(msg)
|
||||
}
|
||||
relay = r
|
||||
r.start()
|
||||
|
||||
scope.launch {
|
||||
while (true) {
|
||||
val ep = r.lastEndpoint
|
||||
if (ep != null) {
|
||||
val wire = "${ep.host}:${ep.port}"
|
||||
// Re-post on a heartbeat even when unchanged: the server stamps a received-at
|
||||
// time, and a developer needs to tell "this endpoint is current" from "this
|
||||
// endpoint is what the tablet saw before it went out of range".
|
||||
val result = post(settings, ep)
|
||||
status = if (result == null) {
|
||||
lastPosted = wire
|
||||
"reported $wire"
|
||||
} else {
|
||||
"found $wire, but reporting failed: $result"
|
||||
}
|
||||
notify(status)
|
||||
}
|
||||
delay(HEARTBEAT_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Posts one endpoint; returns null on success or a short reason on failure. */
|
||||
private suspend fun post(settings: Settings, ep: AdbRelay.Endpoint): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!settings.serverConfigured) return@withContext "no server enrolled"
|
||||
runCatching {
|
||||
ControlClient(settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER)
|
||||
.reportAdbEndpoint(
|
||||
credential = settings.serverCredential,
|
||||
host = ep.host,
|
||||
port = ep.port,
|
||||
deviceName = Build.MODEL,
|
||||
note = "echolot relay",
|
||||
)
|
||||
null
|
||||
}.getOrElse { it.message?.take(120) ?: it.javaClass.simpleName }
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// Restarted by the system if it is killed: a relay that quietly does not come back after
|
||||
// a low-memory kill is the failure mode this exists to avoid.
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
relay?.stop()
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun notify(text: String) {
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
nm?.notify(NOTIFICATION_ID, notification(text))
|
||||
}
|
||||
|
||||
private fun notification(text: String): Notification {
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// LOW: this is a status line for a tool the user deliberately started, not news.
|
||||
val ch = NotificationChannel(CHANNEL, "adb relay", NotificationManager.IMPORTANCE_LOW)
|
||||
ch.description = "Reports this device's wireless-debug endpoint to the Echolot server"
|
||||
nm?.createNotificationChannel(ch)
|
||||
}
|
||||
val open = android.app.PendingIntent.getActivity(
|
||||
this, 0, Intent(this, MainActivity::class.java),
|
||||
android.app.PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return Notification.Builder(this, CHANNEL)
|
||||
.setContentTitle("Echolot adb relay")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(open)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL = "adb-relay"
|
||||
private const val NOTIFICATION_ID = 4711
|
||||
|
||||
/**
|
||||
* Two minutes. The port rotates on roughly that cadence, and the freshness of the answer
|
||||
* is the whole product — but this only re-posts a cached value, so it costs one small
|
||||
* HTTPS request and never touches mDNS.
|
||||
*/
|
||||
private const val HEARTBEAT_MS = 120_000L
|
||||
|
||||
fun start(ctx: Context) {
|
||||
val i = Intent(ctx, AdbRelayService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i)
|
||||
else ctx.startService(i)
|
||||
}
|
||||
|
||||
fun stop(ctx: Context) {
|
||||
ctx.stopService(Intent(ctx, AdbRelayService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestRuntimePermissions()
|
||||
resumeRelayIfEnabled()
|
||||
liveIntent.value = intent
|
||||
setContent {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
@@ -149,6 +150,16 @@ class MainActivity : ComponentActivity() {
|
||||
screen = Screen.SETTINGS
|
||||
}
|
||||
}
|
||||
// A long run samples for minutes, and Android starts throttling timers and
|
||||
// network access within moments of the screen going off — so a run left to
|
||||
// itself would measure the device's power management rather than the network,
|
||||
// and would do it silently. Held only while a run is in flight, and released
|
||||
// on the way out.
|
||||
val view = androidx.compose.ui.platform.LocalView.current
|
||||
androidx.compose.runtime.DisposableEffect(vm.state.running) {
|
||||
view.keepScreenOn = vm.state.running
|
||||
onDispose { view.keepScreenOn = false }
|
||||
}
|
||||
// Shizuku can be started, stopped or authorised in its own app, where nothing
|
||||
// calls back into this process. Asking again each time this screen comes
|
||||
// forward is what makes the banner right after the user has been away to fix
|
||||
@@ -163,8 +174,13 @@ class MainActivity : ComponentActivity() {
|
||||
lifecycleOwner.lifecycle.addObserver(obs)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
|
||||
}
|
||||
// Autorun stays a quick run: it is an unattended batch job driven over adb, and
|
||||
// an automation that silently held the device for five minutes would be a
|
||||
// surprise. `--es mode long` asks for the other one explicitly.
|
||||
val autorunMode =
|
||||
if (intent?.getStringExtra("mode") == "long") RunMode.LONG else RunMode.SHORT
|
||||
androidx.compose.runtime.LaunchedEffect(autorun) {
|
||||
if (autorun) vm.run(devUpload = true)
|
||||
if (autorun) vm.run(autorunMode, devUpload = true)
|
||||
}
|
||||
// In autorun the app is a batch job: once the run is done AND the upload
|
||||
// succeeded, show the result briefly, then close so the device is left as it
|
||||
@@ -215,6 +231,10 @@ class MainActivity : ComponentActivity() {
|
||||
onEnroll = vm::enroll,
|
||||
serverStatus = vm.state.archiveStatus,
|
||||
enrollStatus = vm.state.enrollStatus,
|
||||
onRelayChange = { on ->
|
||||
if (on) AdbRelayService.start(this@MainActivity)
|
||||
else AdbRelayService.stop(this@MainActivity)
|
||||
},
|
||||
onBack = { screen = Screen.RUN },
|
||||
)
|
||||
Screen.HISTORY -> HistoryScreen(
|
||||
@@ -238,7 +258,8 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
Screen.RUN -> EcholotScreen(
|
||||
state = vm.state,
|
||||
onRun = { vm.run() },
|
||||
longMinutes = vm.settings.longRunMinutes,
|
||||
onRun = { mode -> vm.run(mode) },
|
||||
onCancel = vm::cancel,
|
||||
onDeveloperOptions = {
|
||||
runCatching {
|
||||
@@ -275,11 +296,29 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun requestRuntimePermissions() {
|
||||
val perms = mutableListOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
// Only so the relay's ongoing notification is visible. The service runs either way, but a
|
||||
// foreground service the user cannot see is worse than one they can dismiss knowingly.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
perms.add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
val missing = perms.filter {
|
||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (missing.isNotEmpty()) permissionLauncher.launch(missing.toTypedArray())
|
||||
}
|
||||
|
||||
/**
|
||||
* Restarts the relay if it was left on.
|
||||
*
|
||||
* A relay that silently fails to come back after a reboot or a process kill is worse than one
|
||||
* that was never enabled: it is trusted right up to the moment it goes quiet, and the symptom
|
||||
* is a stale endpoint that sends a developer to a port nothing is listening on.
|
||||
*/
|
||||
private fun resumeRelayIfEnabled() {
|
||||
if (Settings(this).adbRelayEnabled) {
|
||||
runCatching { AdbRelayService.start(this) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun verdictColor(v: Verdict): Color = when (v) {
|
||||
@@ -299,7 +338,8 @@ private fun statusColor(s: TestStatus): Color = when (s) {
|
||||
@Composable
|
||||
private fun EcholotScreen(
|
||||
state: UiState,
|
||||
onRun: () -> Unit,
|
||||
longMinutes: Int,
|
||||
onRun: (RunMode) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onShizukuAction: () -> Unit,
|
||||
onDeveloperOptions: () -> Unit,
|
||||
@@ -363,8 +403,36 @@ private fun EcholotScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// The choice is made before the run, not after, because it is a choice about how long the
|
||||
// user is willing to stand still — and because the two modes answer different questions.
|
||||
var mode by remember { mutableStateOf(RunMode.SHORT) }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
FilterChip(
|
||||
selected = mode == RunMode.SHORT,
|
||||
onClick = { mode = RunMode.SHORT },
|
||||
enabled = !state.running,
|
||||
label = { Text("Quick") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = mode == RunMode.LONG,
|
||||
onClick = { mode = RunMode.LONG },
|
||||
enabled = !state.running,
|
||||
label = { Text("Long ($longMinutes min)") },
|
||||
)
|
||||
}
|
||||
Text(
|
||||
if (mode == RunMode.SHORT) {
|
||||
"About 30 seconds. Describes how the network is configured right now."
|
||||
} else {
|
||||
"Listens for $longMinutes minutes while it measures. Finds what a quick run " +
|
||||
"structurally cannot: links that drop and come back, signal that decays, " +
|
||||
"loss that arrives in bursts."
|
||||
},
|
||||
fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(onClick = onRun, enabled = !state.running) {
|
||||
Button(onClick = { onRun(mode) }, enabled = !state.running) {
|
||||
Text(if (state.running) "Running…" else "Run measurement")
|
||||
}
|
||||
if (state.running) {
|
||||
@@ -385,25 +453,55 @@ private fun EcholotScreen(
|
||||
|
||||
if (state.running) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
val frac = if (state.stepsTotal > 0)
|
||||
state.stepsDone.toFloat() / state.stepsTotal else 0f
|
||||
// In a long run the window is the run: once the battery is done, four minutes of
|
||||
// listening remain, and a bar driven by the step count would sit at 100 % through
|
||||
// all of it — which reads as an app that has hung, not one that is working.
|
||||
val listening = state.windowTotalS > 0
|
||||
val frac = when {
|
||||
listening -> state.windowElapsedS.toFloat() / state.windowTotalS
|
||||
state.stepsTotal > 0 -> state.stepsDone.toFloat() / state.stepsTotal
|
||||
else -> 0f
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { frac },
|
||||
progress = { frac.coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (listening) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"listening ${clock(state.windowElapsedS)} of ${clock(state.windowTotalS)}",
|
||||
fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
"${clock(state.windowTotalS - state.windowElapsedS)} left",
|
||||
fontSize = 12.sp, modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Still shown during a long run's listening phase, where it stops at the last
|
||||
// test: the battery's progress is real information, it is simply not the whole
|
||||
// run any more.
|
||||
Text(
|
||||
if (state.stepsTotal > 0)
|
||||
"test ${state.stepsDone + 1} of ${state.stepsTotal}" else "starting",
|
||||
"test ${(state.stepsDone + 1).coerceAtMost(state.stepsTotal)} of ${state.stepsTotal}"
|
||||
else "starting",
|
||||
fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(state.currentStep ?: "…", fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
|
||||
if (state.etaSeconds > 0) {
|
||||
if (!listening && state.etaSeconds > 0) {
|
||||
Text("~${state.etaSeconds}s left", fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
if (listening) {
|
||||
Text(
|
||||
"Cancelling keeps what has been collected so far.",
|
||||
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,6 +551,17 @@ private fun Results(doc: MeasurementDocument) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = verdictColor(summary.overall))) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Text("Overall: ${summary.overall}", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||
// Which question this document answers. A green light from a quick run does not
|
||||
// mean the same thing as a green light from a long one, and the report should not
|
||||
// let the two look identical.
|
||||
Text(
|
||||
if (doc.run.mode == RunMode.LONG) {
|
||||
"long run — the network was watched continuously as well as probed"
|
||||
} else {
|
||||
"quick run — a snapshot; nothing here rules out an intermittent fault"
|
||||
},
|
||||
color = Color.White, fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
FlowCategories(summary.categories)
|
||||
@@ -558,6 +667,12 @@ private fun FlowCategories(categories: Map<String, CategorySummary>) {
|
||||
}
|
||||
}
|
||||
|
||||
/** m:ss — minutes are how a five-minute wait is read; "247s left" is a number to convert. */
|
||||
private fun clock(seconds: Int): String {
|
||||
val s = seconds.coerceAtLeast(0)
|
||||
return "${s / 60}:${"%02d".format(s % 60)}"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Dot(color: Color) {
|
||||
Surface(color = color, shape = RoundedCornerShape(50), modifier = Modifier.size(12.dp)) {}
|
||||
|
||||
@@ -23,6 +23,8 @@ import app.echo_lot.probe.RouterIdentityProbe
|
||||
import app.echo_lot.shizuku.ShizukuAvailability
|
||||
import app.echo_lot.shizuku.ShizukuProbe
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.Instant
|
||||
@@ -38,6 +40,17 @@ data class UiState(
|
||||
val stepsDone: Int = 0,
|
||||
val stepsTotal: Int = 0,
|
||||
val etaSeconds: Int = 0,
|
||||
/** Which mode the run in progress (or the last one) used. */
|
||||
val mode: RunMode = RunMode.SHORT,
|
||||
/**
|
||||
* The listening window, in seconds. Zero for a short run.
|
||||
*
|
||||
* A long run's step count stops being the honest progress measure the moment the battery is
|
||||
* done and four minutes of listening remain: the bar would sit at 100 % while the run carried
|
||||
* on, which reads as a hung app. Over a window, elapsed-of-total is the truth.
|
||||
*/
|
||||
val windowElapsedS: Int = 0,
|
||||
val windowTotalS: Int = 0,
|
||||
/** Where the finished run went: archived locally, uploaded, or neither (and why). */
|
||||
val archiveStatus: String? = null,
|
||||
/** History, newest first. Refreshed after every run and whenever the history screen opens. */
|
||||
@@ -122,6 +135,17 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
override fun onCleared() {
|
||||
stopShizukuObserver()
|
||||
// A NetworkCallback outlives the object that registered it: the system holds the reference,
|
||||
// so a ViewModel dying mid-run with listeners still up leaks one for the life of the
|
||||
// process. viewModelScope is already cancelled by now, hence a detached scope purely to
|
||||
// hang up — its results are discarded, only the unregistration matters.
|
||||
val leftovers = activeCollectors
|
||||
activeCollectors = emptyList()
|
||||
if (leftovers.isNotEmpty()) {
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||
leftovers.forEach { runCatching { it.stop() } }
|
||||
}
|
||||
}
|
||||
super.onCleared()
|
||||
}
|
||||
// Results collected so far. A cancelled run must still be able to show what it measured.
|
||||
@@ -131,6 +155,18 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private var runNetworks: List<app.echo_lot.measurement.Network> = emptyList()
|
||||
private var runShizukuOk = false
|
||||
private var runConstraints = Constraints()
|
||||
private var runMode: RunMode = RunMode.SHORT
|
||||
|
||||
/**
|
||||
* The listeners of the run in flight.
|
||||
*
|
||||
* Held on the ViewModel rather than inside `measure()` because [cancel] has to be able to reach
|
||||
* them: the run job is dead by then, and what the listeners gathered up to that moment is the
|
||||
* most valuable part of a long run that was cut short. They own their own coroutine scopes for
|
||||
* the same reason — cancelling the run must stop the sampling without discarding the samples.
|
||||
*/
|
||||
private var activeCollectors: List<app.echo_lot.probe.Collector> = emptyList()
|
||||
private var changeCollector: app.echo_lot.probe.NetworkChangeCollector? = null
|
||||
|
||||
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
|
||||
private class RunIds : ProbeIds {
|
||||
@@ -146,14 +182,17 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
* from the user-facing upload so a debugging convenience can never be mistaken for, or
|
||||
* silently satisfy, the consent-gated one.
|
||||
*/
|
||||
fun run(devUpload: Boolean = false) {
|
||||
fun run(mode: RunMode = RunMode.SHORT, devUpload: Boolean = false) {
|
||||
if (state.running) return
|
||||
collected.clear()
|
||||
runConstraints = Constraints()
|
||||
runMode = mode
|
||||
val windowS = if (mode == RunMode.LONG) settings.longRunMinutes * 60 else 0
|
||||
state = state.copy(running = true, currentStep = "starting", document = null,
|
||||
uploadStatus = null, archiveStatus = null)
|
||||
uploadStatus = null, archiveStatus = null,
|
||||
mode = mode, windowElapsedS = 0, windowTotalS = windowS)
|
||||
runJob = viewModelScope.launch {
|
||||
val doc = withContext(Dispatchers.IO) { measure() }
|
||||
val doc = withContext(Dispatchers.IO) { measure(mode) }
|
||||
|
||||
step("archiving")
|
||||
val archived = withContext(Dispatchers.IO) { store.archive(doc) }
|
||||
@@ -184,6 +223,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
state = UiState(
|
||||
running = false, currentStep = null, document = doc,
|
||||
uploadStatus = status, archiveStatus = archiveStatus,
|
||||
mode = mode,
|
||||
history = withContext(Dispatchers.IO) { store.list() },
|
||||
)
|
||||
}
|
||||
@@ -371,23 +411,63 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
/**
|
||||
* Stops an in-flight run and shows what was measured so far. Deliberately does NOT upload:
|
||||
* a partial run is for the person looking at the screen, not for the record.
|
||||
*
|
||||
* Cancelling a long run must still hand back what the listeners heard — two minutes of watching
|
||||
* is worth reporting, and throwing it away because the user did not wait for the third would be
|
||||
* the worst possible answer to "I've seen enough". The harvest runs on [viewModelScope] rather
|
||||
* than in the (now cancelled) run job, and the listeners' own scopes are what kept their data
|
||||
* alive long enough to collect.
|
||||
*/
|
||||
fun cancel() {
|
||||
if (!state.running) return
|
||||
if (!state.running || cancelling) return
|
||||
cancelling = true
|
||||
runJob?.cancel()
|
||||
state = state.copy(currentStep = "stopping listeners")
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) { stopCollectors() }
|
||||
val doc = buildDocument(collected.toList())
|
||||
cancelling = false
|
||||
state = UiState(
|
||||
running = false, currentStep = null, document = doc,
|
||||
uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
|
||||
archiveStatus = "partial run — not archived",
|
||||
mode = runMode,
|
||||
history = state.history,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun measure(): MeasurementDocument {
|
||||
private var cancelling = false
|
||||
|
||||
/**
|
||||
* Stops every listener, folds its Test into the run, and attaches the observed network changes
|
||||
* to `networks[]`. Idempotent — the normal path and the cancel path both call it, and only the
|
||||
* first does anything.
|
||||
*/
|
||||
private suspend fun stopCollectors(): List<Test> {
|
||||
val running = activeCollectors
|
||||
activeCollectors = emptyList()
|
||||
val out = ArrayList<Test>()
|
||||
for (c in running) {
|
||||
val t = runCatching { c.stop() }.getOrNull() ?: continue
|
||||
out.add(t)
|
||||
collected.add(t)
|
||||
}
|
||||
changeCollector?.let { watcher ->
|
||||
changeCollector = null
|
||||
val byNetwork = runCatching { watcher.changesByNetwork() }.getOrDefault(emptyMap())
|
||||
if (byNetwork.isNotEmpty()) {
|
||||
runNetworks = runNetworks.map { n -> n.copy(changes = byNetwork[n.id] ?: n.changes) }
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private suspend fun measure(mode: RunMode): MeasurementDocument = coroutineScope {
|
||||
val ctx = getApplication<Application>()
|
||||
val ids = RunIds().also { runIds = it }
|
||||
val startWall = Instant.now().toString().also { runStartWall = it }
|
||||
val windowMs = if (mode == RunMode.LONG) settings.longRunMinutes * 60_000L else 0L
|
||||
|
||||
step("reading networks")
|
||||
val entries = NetworkInventory.snapshot(ctx)
|
||||
@@ -398,7 +478,43 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
// per-test `attempted: false` breadcrumbs (measurement-schema.md §3 `constraints`).
|
||||
runConstraints = app.echo_lot.probe.ConstraintDetector.detect(ctx, entries)
|
||||
|
||||
val probes: List<Probe> = listOf(
|
||||
// Listeners first, at t=0, so the battery itself runs *inside* the observed window: a link
|
||||
// that drops while the DNS probe is timing out is then recorded as a drop rather than
|
||||
// guessed at from a failure.
|
||||
var ticker: kotlinx.coroutines.Job? = null
|
||||
if (mode == RunMode.LONG) {
|
||||
step("starting listeners")
|
||||
val watcher = app.echo_lot.probe.NetworkChangeCollector(entries).also { changeCollector = it }
|
||||
val collectors = listOf<app.echo_lot.probe.Collector>(
|
||||
watcher,
|
||||
app.echo_lot.probe.WifiSignalCollector(entries),
|
||||
app.echo_lot.probe.PingSeriesCollector(),
|
||||
// mDNS is a listener wearing a probe's clothes, so in long mode it listens for the
|
||||
// window instead of blocking the battery for it. Two seconds short of the window,
|
||||
// so it finishes just before everything is stopped rather than just after.
|
||||
app.echo_lot.probe.ProbeCollector(
|
||||
app.echo_lot.probe.MdnsInventoryProbe(
|
||||
listenMs = (windowMs - 2_000).coerceAtLeast(10_000)
|
||||
)
|
||||
),
|
||||
)
|
||||
activeCollectors = collectors
|
||||
for (c in collectors) runCatching { c.start(ctx, ids) }
|
||||
// One ticker for the whole run: the battery does not report progress by the second, and
|
||||
// without this the elapsed/remaining line would freeze for as long as the slowest probe.
|
||||
ticker = launch {
|
||||
while (isActive) {
|
||||
val elapsedS = (ids.monoNs() / 1_000_000_000L).toInt()
|
||||
state = state.copy(
|
||||
windowElapsedS = elapsedS.coerceAtMost((windowMs / 1000).toInt()),
|
||||
windowTotalS = (windowMs / 1000).toInt(),
|
||||
)
|
||||
kotlinx.coroutines.delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val probes: List<Probe> = listOfNotNull(
|
||||
LinkSnapshotProbe(entries),
|
||||
RouterIdentityProbe(entries),
|
||||
IcmpProbe(entries, v6 = false),
|
||||
@@ -406,7 +522,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
// Folded from the prober after hardware validation: errqueue traceroute (no root,
|
||||
// no JNI) and the mDNS service inventory / VLAN-leakage detector.
|
||||
app.echo_lot.probe.TracerouteProbe(),
|
||||
app.echo_lot.probe.MdnsInventoryProbe(),
|
||||
// Absent from a long run's battery: it runs there as a collector for the whole window
|
||||
// instead, and running it in both places would query the same services twice.
|
||||
if (mode == RunMode.LONG) null else app.echo_lot.probe.MdnsInventoryProbe(),
|
||||
CaptivePortalProbe(entries),
|
||||
// Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to
|
||||
// the reference deployment until profiles/enrollment land in the UI.
|
||||
@@ -479,7 +597,24 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
(it.status == TestStatus.OK || it.status == TestStatus.PARTIAL)
|
||||
}
|
||||
|
||||
return buildDocument(tests)
|
||||
if (mode == RunMode.LONG) {
|
||||
// The battery finishing is not the run finishing. Everything the long mode exists for
|
||||
// happens in the minutes after this point, so the run waits the window out — in one
|
||||
// second steps, because `delay` is what makes a five-minute wait cancellable.
|
||||
while (true) {
|
||||
val elapsedMs = ids.monoNs() / 1_000_000
|
||||
val remainingS = ((windowMs - elapsedMs + 999) / 1000).toInt()
|
||||
if (remainingS <= 0) break
|
||||
step("listening · ${remainingS}s left", done = totalSteps, total = totalSteps, etaMs = windowMs - elapsedMs)
|
||||
kotlinx.coroutines.delay(minOf(1000L, windowMs - elapsedMs))
|
||||
}
|
||||
step("collecting listeners")
|
||||
tests.addAll(stopCollectors())
|
||||
}
|
||||
// Before the enclosing coroutineScope waits for its children, or the run would never end.
|
||||
ticker?.cancel()
|
||||
|
||||
buildDocument(tests)
|
||||
}
|
||||
|
||||
/** Assembles a document from whatever tests are in hand — used for both full and cancelled runs. */
|
||||
@@ -487,7 +622,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val findings = deriveFindings(tests, runNetworks)
|
||||
return MeasurementDocument(
|
||||
run = Run(
|
||||
id = runIds.uuid(), trigger = Trigger.MANUAL, startedAt = runStartWall,
|
||||
id = runIds.uuid(), trigger = Trigger.MANUAL, mode = runMode, startedAt = runStartWall,
|
||||
endedAt = Instant.now().toString(),
|
||||
clock = Clock(monoOriginWall = runStartWall),
|
||||
app = AppInfo(
|
||||
@@ -618,6 +753,46 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
)
|
||||
)
|
||||
}
|
||||
// The one finding only a long run can reach. Derived from networks[].changes[] rather than
|
||||
// from the watcher's evidence, because the changes are the schema's own record of what
|
||||
// happened (§4) and re-parsing the test would be a second, divergent reading of it. A short
|
||||
// run has an empty changes[] and therefore never gets here — which is correct, not a gap:
|
||||
// it did not watch, so it has nothing to say either way.
|
||||
val watchTest = tests.firstOrNull {
|
||||
it.type == TestType.LINK_IP_MONITOR && it.tier == Tier.APP
|
||||
}
|
||||
if (watchTest != null) {
|
||||
for (n in networks) {
|
||||
val cycles = NetworkChanges.flapCyclesOf(n.changes)
|
||||
if (cycles < 1) continue
|
||||
val where = n.iface?.takeIf { it.isNotBlank() } ?: "this network"
|
||||
out.add(
|
||||
Finding(
|
||||
id = ids.uuid(),
|
||||
code = FindingRegistry.LINK_FLAPPING.code,
|
||||
category = FindingRegistry.LINK_FLAPPING.category,
|
||||
// Escalated on repetition: once is a hiccup worth knowing about, three
|
||||
// times in one window is the reason someone's calls keep dropping.
|
||||
severity = if (cycles >= 3) Severity.HIGH else FindingRegistry.LINK_FLAPPING.severity,
|
||||
confidence = Confidence.HIGH,
|
||||
title = if (cycles == 1) "$where dropped and came back during the run"
|
||||
else "$where dropped and came back $cycles times during the run",
|
||||
description = "A listener watched this link for the whole measurement and " +
|
||||
"saw it go away and return " +
|
||||
(if (cycles == 1) "once" else "$cycles times") + ". Every one-shot " +
|
||||
"test in this run may still have passed — the probes on either side of " +
|
||||
"a gap succeed — so this is the kind of fault a quick measurement " +
|
||||
"cannot find. Connections in flight are dropped each time it happens: " +
|
||||
"calls end, downloads stall, and anything long-lived reconnects. On " +
|
||||
"wifi the usual causes are a weak or contended channel, band steering, " +
|
||||
"or an access point restarting; on cellular, handovers at the edge of " +
|
||||
"coverage. The timeline of every change is in networks[].changes[].",
|
||||
evidenceRefs = listOf(EvidenceRef(watchTest.id)),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val shapes = V6Analysis.classify(networks)
|
||||
// Named per interface: on a phone several networks are up at once, and "IPv6 is broken" is
|
||||
// useless when wifi is the broken one and cellular is fine.
|
||||
|
||||
@@ -50,6 +50,36 @@ class Settings(context: Context) {
|
||||
maxTotalBytes = maxTotalMb.toLong() * 1024 * 1024,
|
||||
)
|
||||
|
||||
// ---- measurement -------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* How long a long run listens, in minutes. Offered as 1 / 5 / 15.
|
||||
*
|
||||
* 5 is the default because it is the shortest window in which the things long mode exists to
|
||||
* catch — a link that flaps, a signal that decays as someone walks around, loss that comes in
|
||||
* bursts — have a fair chance of happening at least twice. One minute is for checking that the
|
||||
* mode works at all; fifteen is for chasing something already suspected.
|
||||
*
|
||||
* Clamped rather than trusted: a zero-minute long run would produce a document claiming a
|
||||
* window it never watched, which is the one thing `run.mode` exists to prevent.
|
||||
*/
|
||||
var longRunMinutes: Int
|
||||
get() = prefs.getInt(LONG_RUN_MINUTES, 5).coerceIn(1, 60)
|
||||
set(v) = prefs.edit().putInt(LONG_RUN_MINUTES, v.coerceIn(1, 60)).apply()
|
||||
|
||||
// ---- dev relay -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether this device relays adbd's wireless-debug endpoint to the enrolled server.
|
||||
*
|
||||
* Off by default and never implied by anything else: it publishes where this device can be
|
||||
* reached for debugging, which is a decision rather than a side effect. Intended for a spare
|
||||
* device parked on a test network — see AdbRelay for why the OnePlus is a poor host for it.
|
||||
*/
|
||||
var adbRelayEnabled: Boolean
|
||||
get() = prefs.getBoolean(ADB_RELAY, false)
|
||||
set(v) = prefs.edit().putBoolean(ADB_RELAY, v).apply()
|
||||
|
||||
// ---- run-duration learning ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -245,5 +275,7 @@ class Settings(context: Context) {
|
||||
const val ACCOUNT_NAME = "account_name"
|
||||
const val ACCOUNT_ID = "account_id"
|
||||
const val DURATION_PREFIX = "duration_ms."
|
||||
const val ADB_RELAY = "adb_relay_enabled"
|
||||
const val LONG_RUN_MINUTES = "long_run_minutes"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ fun SettingsScreen(
|
||||
onEnroll: (String) -> Unit,
|
||||
serverStatus: String?,
|
||||
enrollStatus: String?,
|
||||
/** Starts or stops the adb relay service; the toggle only records the preference. */
|
||||
onRelayChange: (Boolean) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
// SharedPreferences is not observable, so mirror each value into Compose state and write
|
||||
@@ -67,9 +69,11 @@ fun SettingsScreen(
|
||||
var maxRuns by remember { mutableStateOf(settings.maxRuns.toString()) }
|
||||
var maxAgeDays by remember { mutableStateOf(settings.maxAgeDays.toString()) }
|
||||
var maxTotalMb by remember { mutableStateOf(settings.maxTotalMb.toString()) }
|
||||
var longMinutes by remember { mutableStateOf(settings.longRunMinutes) }
|
||||
var autoUpload by remember { mutableStateOf(settings.autoUpload) }
|
||||
var privacy by remember { mutableStateOf(settings.privacyLevel) }
|
||||
var stableSalt by remember { mutableStateOf(settings.stableSalt) }
|
||||
var relayOn by remember { mutableStateOf(settings.adbRelayEnabled) }
|
||||
var enrollLink by remember { mutableStateOf("") }
|
||||
// The public name, which is what the operator handed out and what a person recognises. The
|
||||
// endpoint actually dialled is shown beneath it when the two differ, rather than hidden — a
|
||||
@@ -97,6 +101,39 @@ fun SettingsScreen(
|
||||
Text("Settings", style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
|
||||
// ---- measurement ----
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Long runs", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"How long a long run keeps listening. The measurements themselves take about " +
|
||||
"30 seconds either way; the rest of the window is spent watching for " +
|
||||
"things that only happen sometimes.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (minutes in listOf(1, 5, 15)) {
|
||||
FilterChip(
|
||||
selected = longMinutes == minutes,
|
||||
onClick = { longMinutes = minutes; settings.longRunMinutes = minutes },
|
||||
label = { Text("$minutes min") },
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
when (longMinutes) {
|
||||
1 -> "Barely longer than a quick run — enough to confirm the listeners " +
|
||||
"work, rarely enough to catch anything intermittent."
|
||||
15 -> "For a fault you already suspect and have to prove. Keep the screen " +
|
||||
"on and the device where the problem happens."
|
||||
else -> "Long enough for a link that drops every couple of minutes to do " +
|
||||
"it at least once, short enough to wait out."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- archive ----
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -348,6 +385,39 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- dev relay ------------------------------------------------------------------
|
||||
//
|
||||
// Last, and deliberately plain: this is scaffolding for driving a test device, not a
|
||||
// measurement. It publishes where this device can be reached over adb, which is why it
|
||||
// is off until someone decides otherwise.
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Developer relay", style = MaterialTheme.typography.titleMedium)
|
||||
Toggle(
|
||||
label = "Relay this device's adb endpoint",
|
||||
detail = "Watches adbd's own mDNS announcement and reports host:port to the " +
|
||||
"enrolled server, so a developer on another network can reach this " +
|
||||
"device — mDNS does not cross subnets, and the port rotates every few " +
|
||||
"minutes. Runs in the foreground with a notification while on.",
|
||||
checked = relayOn,
|
||||
enabled = settings.serverConfigured,
|
||||
onChange = { on ->
|
||||
relayOn = on
|
||||
settings.adbRelayEnabled = on
|
||||
onRelayChange(on)
|
||||
},
|
||||
)
|
||||
if (!settings.serverConfigured) {
|
||||
Text(
|
||||
"Needs an enrolled server: the report is authenticated with this " +
|
||||
"device's credential.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = LocalContentColor.current.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ data class MeasurementDocument(
|
||||
data class Run(
|
||||
val id: String, // UUIDv7
|
||||
val trigger: Trigger,
|
||||
val mode: RunMode = RunMode.SHORT,
|
||||
@SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only
|
||||
@SerialName("ended_at") val endedAt: String? = null,
|
||||
val clock: Clock,
|
||||
@@ -66,6 +67,27 @@ data class Constraints(
|
||||
val constrained: Boolean get() = vpnActive || perNetworkBlocked
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the run watched the network — and therefore what its silence is worth.
|
||||
*
|
||||
* A [SHORT] run is a sequence of one-shot probes: each looks at the network for a second or two and
|
||||
* moves on. That is enough to characterise a network's *configuration*, and it is structurally
|
||||
* incapable of seeing anything intermittent. A wifi link that drops for four seconds every two
|
||||
* minutes, a resolver that stalls under load, an AP that roams — none of these leave a trace in
|
||||
* thirty seconds of probing unless the run happened to coincide with one.
|
||||
*
|
||||
* A [LONG] run starts continuous listeners at t=0, runs the same battery beside them, and keeps
|
||||
* sampling until the window closes. It answers a different question, so a reader must not treat the
|
||||
* two alike: **the mode is what licenses an argument from absence**. "No drops were observed" means
|
||||
* something after five minutes of watching and nothing at all after a thirty-second run, and
|
||||
* without this field the two documents are indistinguishable.
|
||||
*/
|
||||
@Serializable
|
||||
enum class RunMode {
|
||||
@SerialName("short") SHORT,
|
||||
@SerialName("long") LONG,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class Trigger {
|
||||
@SerialName("manual") MANUAL,
|
||||
|
||||
+21
-1
@@ -103,6 +103,26 @@ object FindingRegistry {
|
||||
"Android's own connectivity checks fail on this network.",
|
||||
)
|
||||
|
||||
/**
|
||||
* The finding a short run cannot make.
|
||||
*
|
||||
* Every one-shot probe describes the network during its own two seconds. A link that drops and
|
||||
* returns between two of them leaves no trace anywhere in the document — the probes before and
|
||||
* after both succeed, and the run reports a healthy network. Only a listener that watches the
|
||||
* whole window sees the gap, which is why this is emitted from `networks[].changes[]` (§4)
|
||||
* rather than from any test's evidence.
|
||||
*
|
||||
* MEDIUM by default and escalated by the emitter on repeat: one drop in five minutes is worth
|
||||
* knowing about, three is the difference between "the wifi hiccuped" and "this link is why
|
||||
* calls keep dropping". Deliberately claims a *completed* cycle — lost and then regained — so
|
||||
* it never fires for a network that was simply turned off partway through the run.
|
||||
*/
|
||||
val LINK_FLAPPING = FindingSpec(
|
||||
"connectivity.link_flapping", Category.CONNECTIVITY, Severity.MEDIUM,
|
||||
"A network dropped and came back one or more times during the run.",
|
||||
rulesOut = "A momentary probe failure: the drop was watched happening, not inferred from silence.",
|
||||
)
|
||||
|
||||
// ---- mtu -------------------------------------------------------------------------
|
||||
|
||||
val MTU_REDUCED_DOWNSTREAM = FindingSpec(
|
||||
@@ -305,7 +325,7 @@ object FindingRegistry {
|
||||
/** Every registered finding, in declaration order. */
|
||||
val all: List<FindingSpec> = listOf(
|
||||
UDP_UNREACHABLE, UDP_UNREACHABLE_UPSTREAM, UDP_LOSS, LOSS_UPSTREAM, LOSS_DOWNSTREAM,
|
||||
DOWNSTREAM_BLOCKED, DOWNSTREAM_REORDER, CAPTIVE_PORTAL, NO_INTERNET,
|
||||
DOWNSTREAM_BLOCKED, DOWNSTREAM_REORDER, CAPTIVE_PORTAL, NO_INTERNET, LINK_FLAPPING,
|
||||
MTU_REDUCED_DOWNSTREAM, MTU_DOWNSTREAM_BLACKHOLE, FRAGMENTS_BLOCKED,
|
||||
FRAGMENT_REORDER_SENSITIVE,
|
||||
NAT_UDP_REBINDING, NAT_SYMMETRIC,
|
||||
|
||||
@@ -144,3 +144,38 @@ data class NetworkChange(
|
||||
val kind: String, // lost | gained | link_changed
|
||||
val detail: JsonObject? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* What a network's `changes[]` add up to.
|
||||
*
|
||||
* Lives beside the type rather than in the collector that produces it because two independent
|
||||
* consumers ask the same question — the watcher, computing its metrics, and the run engine,
|
||||
* deciding whether to emit `connectivity.link_flapping` — and a document whose metric and finding
|
||||
* disagreed about how many times the link dropped would be worse than one reporting neither.
|
||||
*/
|
||||
object NetworkChanges {
|
||||
|
||||
const val LOST = "lost"
|
||||
const val GAINED = "gained"
|
||||
const val LINK_CHANGED = "link_changed"
|
||||
|
||||
/**
|
||||
* Completed drop-and-return cycles: a `lost` with a later `gained` on the same network.
|
||||
*
|
||||
* A cycle has to *complete*. A link that goes away at minute four and is still gone when the
|
||||
* window closes was not flapping — it was switched off, or the device was carried out of
|
||||
* range, and calling that the same fault would put a phone in a lift beside a failing access
|
||||
* point.
|
||||
*/
|
||||
fun flapCycles(kinds: List<String>): Int {
|
||||
var cycles = 0
|
||||
var down = false
|
||||
for (k in kinds) {
|
||||
if (k == LOST) down = true
|
||||
else if (k == GAINED && down) { cycles++; down = false }
|
||||
}
|
||||
return cycles
|
||||
}
|
||||
|
||||
fun flapCyclesOf(changes: List<NetworkChange>): Int = flapCycles(changes.map { it.kind })
|
||||
}
|
||||
|
||||
@@ -133,6 +133,16 @@ object TestType {
|
||||
const val LOCAL_MDNS_INVENTORY = "local.mdns_inventory"
|
||||
const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory"
|
||||
const val LOCAL_LLMNR_INVENTORY = "local.llmnr_inventory"
|
||||
/**
|
||||
* WS-Discovery (UDP 3702) and NetBIOS name service (UDP 137). Registry additions, v1.2.
|
||||
*
|
||||
* Both are passive: the traffic is broadcast to the segment whether or not anyone asks, so
|
||||
* listening is the whole measurement. They earn their own ids rather than folding into
|
||||
* [LOCAL_SSDP_INVENTORY] because what they imply differs — WS-Discovery inventories printers
|
||||
* and cameras, while NetBIOS/LLMNR chatter is a security finding in its own right.
|
||||
*/
|
||||
const val LOCAL_WSD_INVENTORY = "local.wsd_inventory"
|
||||
const val LOCAL_NETBIOS_INVENTORY = "local.netbios_inventory"
|
||||
const val LOCAL_GATEWAY_SERVICES = "local.gateway_services"
|
||||
const val LOCAL_NTP = "local.ntp"
|
||||
// peer
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.measurement
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Pins the counting behind `connectivity.link_flapping`.
|
||||
*
|
||||
* The finding claims a link went away and came back, and its severity escalates on repetition, so
|
||||
* this is arithmetic a person reading a report will act on. The cases that matter are the ones
|
||||
* where the naive count is wrong: a link still down when the window closed, and a run that started
|
||||
* while the link was already gone.
|
||||
*/
|
||||
class NetworkChangesTest {
|
||||
|
||||
@Test
|
||||
fun aQuietWindowHasNoCycles() {
|
||||
assertEquals(0, NetworkChanges.flapCycles(emptyList()))
|
||||
assertEquals(0, NetworkChanges.flapCycles(listOf("link_changed", "link_changed")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneDropAndReturnIsOneCycle() {
|
||||
assertEquals(1, NetworkChanges.flapCycles(listOf("lost", "gained")))
|
||||
assertEquals(
|
||||
1,
|
||||
NetworkChanges.flapCycles(listOf("link_changed", "lost", "link_changed", "gained")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedDropsCountSeparately() {
|
||||
assertEquals(3, NetworkChanges.flapCycles(listOf("lost", "gained", "lost", "gained", "lost", "gained")))
|
||||
}
|
||||
|
||||
// A link that is still down when the run ends was not flapping — it was switched off, or the
|
||||
// device left its range. Counting that as a cycle would put a phone in a lift beside a failing
|
||||
// access point.
|
||||
@Test
|
||||
fun aDropThatNeverReturnsIsNotACycle() {
|
||||
assertEquals(0, NetworkChanges.flapCycles(listOf("lost")))
|
||||
assertEquals(1, NetworkChanges.flapCycles(listOf("lost", "gained", "lost")))
|
||||
}
|
||||
|
||||
// The mirror case: the window opened while the network was already gone, so its return is the
|
||||
// first thing seen. Nothing was watched dropping, so nothing is claimed.
|
||||
@Test
|
||||
fun aReturnWithNoObservedDropIsNotACycle() {
|
||||
assertEquals(0, NetworkChanges.flapCycles(listOf("gained")))
|
||||
assertEquals(0, NetworkChanges.flapCycles(listOf("gained", "link_changed")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theChangeOverloadAgreesWithTheKindsOverload() {
|
||||
val changes = listOf(
|
||||
NetworkChange(atMonoNs = 1, kind = NetworkChanges.LOST),
|
||||
NetworkChange(atMonoNs = 2, kind = NetworkChanges.GAINED),
|
||||
NetworkChange(atMonoNs = 3, kind = NetworkChanges.LINK_CHANGED),
|
||||
)
|
||||
assertEquals(1, NetworkChanges.flapCyclesOf(changes))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,36 @@ class ControlClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports where adbd's wireless-debug listener can be reached on this device's LAN.
|
||||
*
|
||||
* Dev scaffolding, not a measurement: it exists because mDNS does not cross subnets, so a
|
||||
* developer working from a different network cannot discover the port that rotates every few
|
||||
* minutes. A device sitting on the test LAN can see it and say so. Deliberately not part of
|
||||
* the probe protocol's capability set — the server documents it as a dev relay.
|
||||
*/
|
||||
fun reportAdbEndpoint(
|
||||
credential: String,
|
||||
host: String,
|
||||
port: Int,
|
||||
deviceName: String? = null,
|
||||
note: String? = null,
|
||||
): String {
|
||||
val conn = open("/v1/devtools/adb-endpoint", "POST", credential)
|
||||
val fields = buildString {
|
||||
append("""{"host":${jstr(host)},"port":$port""")
|
||||
deviceName?.let { append(""","device_name":${jstr(it)}""") }
|
||||
note?.let { append(""","note":${jstr(it)}""") }
|
||||
append("}")
|
||||
}
|
||||
writeJson(conn, fields)
|
||||
val body = body(conn)
|
||||
check(conn.responseCode in 200..299) {
|
||||
"adb endpoint report failed: ${conn.responseCode} $body"
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
/** Lists this device's runs stored on the server. */
|
||||
fun listRuns(credential: String): String {
|
||||
val conn = open("/v1/runs", "GET", credential)
|
||||
|
||||
Reference in New Issue
Block a user