Compare commits

..
Author SHA1 Message Date
mrambossekandClaude Opus 5 7a88a2d4b9 app: make the relay come back by itself, and report within seconds
Two faults found by watching it run. The heartbeat was the only cadence,
so the first pass ran before mDNS discovery finished, saw nothing, and
then sat silent for two minutes - precisely when someone has just
switched the relay on and is watching for it to work. It now polls every
five seconds until the first successful report, then settles.

And nothing restarted the service after a reboot or an app update, which
is the most common way it dies during development. A relay that has
quietly stopped is worse than one never switched on: the endpoint it last
published keeps looking authoritative while pointing at a port nothing is
listening on. RelayBootReceiver handles BOOT_COMPLETED and
MY_PACKAGE_REPLACED, debug builds only, and does nothing unless the relay
was already on.

Verified end to end: installing this build triggered the receiver and the
phone reported its endpoint 7 seconds later, unattended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 15:27:32 +02:00
mrambossekandClaude Opus 5 97515f7090 app: put the relay switch where it is looked for, and fix why it found nothing
The relay was buried at the bottom of settings. It is now a switch on the
home screen - not a third chip beside Quick and Long, because those choose
how the next run measures while this starts a service that outlives it and
produces no document at all. Debug builds only: it publishes where this
device can be reached over adb, which is scaffolding for driving a test
device and a footgun in a release build.

And it never worked. localIp() read WifiManager.connectionInfo.ipAddress,
deprecated and returning 0 to ordinary apps on current Android, so the
own-device check compared every advertisement against null and rejected
all of them - the service ran, reported healthy, and relayed nothing. Now
read from LinkProperties, and when this device genuinely cannot say what
its own address is the endpoint is relayed anyway, labelled unverified:
silence was the worse answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 15:21:45 +02:00
mrambossekandClaude Opus 5 c414534e03 app: pick the discovery listeners, and say when one was not asked
They differ in kind from the rest of the battery: they record what OTHER
devices broadcast about themselves - hostnames, models, printer names -
so which of them run is a choice per run rather than a default to
inherit. Chips beside the mode, shown only for long runs, where a passive
listener means anything.

A listener that was switched off emits a SKIPPED test with its reason
rather than vanishing. Same argument as run.mode: a document where
local.ssdp_inventory is simply missing cannot tell a reader whether
nothing announced itself or nobody was listening, and those are opposite
conclusions about a network.

NetBIOS is the one that defaults off - at app tier it can only report
unsupported, and an on-by-default listener guaranteed to fail teaches
people to ignore the status column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 15:13:44 +02:00
8 changed files with 300 additions and 27 deletions
@@ -21,6 +21,8 @@
<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" />
<!-- So the relay survives a reboot of a device left running it (see RelayBootReceiver). -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:allowBackup="false"
@@ -73,6 +75,19 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<!--
Exported because the system delivers these broadcasts; the receiver itself starts
nothing unless the relay was already switched on in a debug build.
-->
<receiver
android:name=".RelayBootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -4,9 +4,9 @@
package app.echo_lot.app
import android.content.Context
import android.net.ConnectivityManager
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.net.wifi.WifiManager
import java.net.Inet4Address
/**
@@ -102,12 +102,18 @@ class AdbRelay(
// 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
// Prefer 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
// developer to the wrong device. But when this device cannot say what its own
// address is, that is no reason to relay nothing — an unverified endpoint beats
// silence, and it is labelled so it is never mistaken for a confirmed one.
val mine = localIp()
if (mine != null && host != mine) return
lastEndpoint = Endpoint(host, i.port, i.serviceName ?: "adb")
onEvent("found adbd at $host:${i.port}")
onEvent(
if (mine == null) "found adbd at $host:${i.port} (own address unknown)"
else "found adbd at $host:${i.port}"
)
}
}
runCatching { nsd?.resolveService(info, cb) }
@@ -117,17 +123,21 @@ class AdbRelay(
}
}
/** This device's own IPv4 address on the wifi it is relaying from. */
/**
* This device's own IPv4 address on the network it is relaying from, or null if it cannot be
* determined.
*
* Read from LinkProperties rather than `WifiManager.connectionInfo.ipAddress`, which is
* deprecated and returns 0 to ordinary apps on current Android — a null that silently made the
* ownership check reject every advertisement, so the relay found nothing and said nothing.
*/
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,
)
val cm = ctx.getSystemService(ConnectivityManager::class.java) ?: return null
val lp = cm.getLinkProperties(cm.activeNetwork) ?: return null
lp.linkAddresses.map { it.address }
.filterIsInstance<Inet4Address>()
.firstOrNull { !it.isLoopbackAddress }
?.hostAddress
}.getOrNull()
private companion object {
@@ -56,6 +56,11 @@ class AdbRelayService : Service() {
r.start()
scope.launch {
// Poll quickly until the first endpoint has actually been reported, then settle into
// the heartbeat. Discovery takes a few seconds, so a loop that only ever waited the
// heartbeat would see nothing on its first pass and then sit silent for two minutes —
// exactly when someone has just switched the relay on and is watching for it to work.
var reportedOnce = false
while (true) {
val ep = r.lastEndpoint
if (ep != null) {
@@ -66,13 +71,14 @@ class AdbRelayService : Service() {
val result = post(settings, ep)
status = if (result == null) {
lastPosted = wire
reportedOnce = true
"reported $wire"
} else {
"found $wire, but reporting failed: $result"
}
notify(status)
}
delay(HEARTBEAT_MS)
delay(if (reportedOnce) HEARTBEAT_MS else STARTUP_POLL_MS)
}
}
}
@@ -143,6 +149,9 @@ class AdbRelayService : Service() {
*/
private const val HEARTBEAT_MS = 120_000L
/** Retry cadence before the first successful report; cheap, and only ever runs at start. */
private const val STARTUP_POLL_MS = 5_000L
fun start(ctx: Context) {
val i = Intent(ctx, AdbRelayService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i)
@@ -73,6 +73,9 @@ class MainActivity : ComponentActivity() {
// there is no back stack to model beyond "return to the run screen".
var screen by remember { mutableStateOf(Screen.RUN) }
var preview by remember { mutableStateOf<String?>(null) }
// Hoisted so the home-screen switch and the settings toggle cannot disagree
// about whether the relay is on.
var relayOn by remember { mutableStateOf(vm.settings.adbRelayEnabled) }
// Automation entry point:
// adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
// starts a run immediately and uploads the report, so an unattended
@@ -259,6 +262,8 @@ class MainActivity : ComponentActivity() {
Screen.RUN -> EcholotScreen(
state = vm.state,
longMinutes = vm.settings.longRunMinutes,
discoveryEnabled = { vm.settings.discoveryEnabled(it) },
onDiscoveryChange = { id, on -> vm.settings.setDiscoveryEnabled(id, on) },
onRun = { mode -> vm.run(mode) },
onCancel = vm::cancel,
onDeveloperOptions = {
@@ -281,6 +286,19 @@ class MainActivity : ComponentActivity() {
else -> Unit
}
},
relayOn = relayOn,
// Dev builds only. The relay publishes where this device can be reached
// over adb; that is scaffolding for driving a test device, and a release
// build has no business offering it — it would be useless without
// wireless debugging and a footgun for anyone who switched it on without
// knowing what it announces.
relayAvailable = BuildConfig.DEBUG && vm.settings.serverConfigured,
onRelayToggle = { on ->
relayOn = on
vm.settings.adbRelayEnabled = on
if (on) AdbRelayService.start(this@MainActivity)
else AdbRelayService.stop(this@MainActivity)
},
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
onOpenSettings = { screen = Screen.SETTINGS },
onOpenHistory = { vm.refreshHistory(); screen = Screen.HISTORY },
@@ -336,9 +354,19 @@ private fun statusColor(s: TestStatus): Color = when (s) {
}
@Composable
// FlowRow: the discovery chips wrap rather than overflow on a narrow phone. Experimental only in
// the sense that its API may gain parameters; the layout itself has been stable for releases.
@OptIn(ExperimentalLayoutApi::class)
private fun EcholotScreen(
state: UiState,
longMinutes: Int,
/** Which discovery listeners are selected, and how to change one. */
discoveryEnabled: (String) -> Boolean,
onDiscoveryChange: (String, Boolean) -> Unit,
/** The relay is not a run mode, but it is switched on from here because that is where it is looked for. */
relayOn: Boolean,
relayAvailable: Boolean,
onRelayToggle: (Boolean) -> Unit,
onRun: (RunMode) -> Unit,
onCancel: () -> Unit,
onShizukuAction: () -> Unit,
@@ -431,6 +459,76 @@ private fun EcholotScreen(
fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Discovery listeners, chosen the same way as the mode and only where they mean anything.
// They belong to the window: a passive listener in a quick run would mostly hear silence
// and report an empty network as confidently as a quiet one.
if (mode == RunMode.LONG) {
var discovery by remember {
mutableStateOf(DiscoveryIds.ALL.filter(discoveryEnabled).toSet())
}
Text(
"Also listen for",
fontSize = 12.sp, fontWeight = FontWeight.Medium,
modifier = Modifier.padding(top = 4.dp),
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (id in DiscoveryIds.ALL) {
val on = id in discovery
FilterChip(
selected = on,
onClick = {
val next = !on
onDiscoveryChange(id, next)
discovery = if (next) discovery + id else discovery - id
},
enabled = !state.running,
label = { Text(DiscoveryIds.label(id)) },
)
}
}
Text(
discovery.joinToString(" · ") { DiscoveryIds.blurb(it) }
.ifBlank { "Nothing extra — just the measurements above." },
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Deliberately NOT a third chip beside Quick and Long. Those choose how the next run
// measures; this starts a service that keeps running afterwards and produces no document
// at all, so putting it in the same row would promise that "Run measurement" starts it.
// It lives here anyway because here is where it gets looked for.
var relay by remember { mutableStateOf(relayOn) }
if (relayAvailable || relay) Card(Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
"Relay adb endpoint" + if (relay) " — on" else "",
fontSize = 13.sp, fontWeight = FontWeight.Medium,
color = LocalContentColor.current.copy(alpha = if (relayAvailable) 1f else 0.5f),
)
Text(
if (!relayAvailable) {
"Needs an enrolled server."
} else if (relay) {
"Reporting this device's wireless-debug host:port to the server."
} else {
"Not a measurement: lets a developer on another network find this " +
"device when the port rotates."
},
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = relay,
enabled = relayAvailable,
onCheckedChange = { on -> relay = on; onRelayToggle(on) },
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Button(onClick = { onRun(mode) }, enabled = !state.running) {
Text(if (state.running) "Running…" else "Run measurement")
@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* Brings the relay back after a reboot or an app update, without anyone opening the app.
*
* The relay's whole purpose is to keep answering "where is this device" while the device sits on a
* shelf unattended. Starting it only from [MainActivity] meant it silently did not come back from
* either event — and a relay that has quietly stopped is worse than one that was never switched
* on, because the endpoint it last published keeps looking authoritative while pointing at a port
* nothing is listening on.
*
* `MY_PACKAGE_REPLACED` matters as much as boot here: installing a new build is the single most
* common way this service dies during development, which is exactly when it is being relied on.
*/
class RelayBootReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
if (!BuildConfig.DEBUG) return
when (intent.action) {
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> {
if (Settings(ctx).adbRelayEnabled) {
runCatching { AdbRelayService.start(ctx) }
}
}
}
}
}
@@ -444,6 +444,60 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
* to `networks[]`. Idempotent — the normal path and the cancel path both call it, and only the
* first does anything.
*/
/**
* The discovery listeners the user selected for this run.
*
* Searches are paced across the window rather than fired at the start, so a device that was
* asleep for the first minute is still asked.
*/
private fun discoveryCollectors(windowMs: Long): List<app.echo_lot.probe.Collector> {
val out = ArrayList<app.echo_lot.probe.Collector>(4)
if (settings.discoveryEnabled(DiscoveryIds.SSDP)) {
out.add(
app.echo_lot.probe.SsdpCollector(
searchIntervalMs = (windowMs / 5).coerceAtLeast(30_000),
)
)
}
if (settings.discoveryEnabled(DiscoveryIds.WSD)) out.add(app.echo_lot.probe.WsdCollector())
if (settings.discoveryEnabled(DiscoveryIds.LLMNR)) out.add(app.echo_lot.probe.LlmnrCollector())
if (settings.discoveryEnabled(DiscoveryIds.NETBIOS)) out.add(app.echo_lot.probe.NetbiosCollector())
return out
}
/**
* A test recording that a listener was switched off, so its absence is never mistaken for its
* silence.
*
* The same reasoning as `run.mode`: a document in which `local.ssdp_inventory` is simply
* missing cannot tell a reader whether nothing announced itself or nobody was listening, and
* those are opposite conclusions about a network. SKIPPED with a reason is how the canary and
* STUN probes already say "not asked", so it is the shape a consumer already understands.
*/
private fun notSelected(type: String, ids: ProbeIds): Test {
val at = ids.monoNs()
return Test(
id = ids.uuid(), type = type, tier = Tier.APP,
startedMonoNs = at, endedMonoNs = at,
status = TestStatus.SKIPPED,
evidence = kotlinx.serialization.json.JsonObject(
mapOf(
"reason" to kotlinx.serialization.json.JsonPrimitive(
"listener not selected for this run"
)
)
),
)
}
/** Registry ids for the selectable listeners, so the skipped-test record uses the real type. */
private fun discoveryType(id: String): String = when (id) {
DiscoveryIds.SSDP -> TestType.LOCAL_SSDP_INVENTORY
DiscoveryIds.WSD -> TestType.LOCAL_WSD_INVENTORY
DiscoveryIds.LLMNR -> TestType.LOCAL_LLMNR_INVENTORY
else -> TestType.LOCAL_NETBIOS_INVENTORY
}
private suspend fun stopCollectors(): List<Test> {
val running = activeCollectors
activeCollectors = emptyList()
@@ -507,13 +561,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
// knowing it will usually report `unsupported` — UDP 137 is privileged, so the
// app tier cannot bind it — because a recorded reason beats an absent test, and
// the decoder is ready for the Shizuku tier.
app.echo_lot.probe.SsdpCollector(
searchIntervalMs = (windowMs / 5).coerceAtLeast(30_000),
),
app.echo_lot.probe.LlmnrCollector(),
app.echo_lot.probe.NetbiosCollector(),
app.echo_lot.probe.WsdCollector(),
)
) + discoveryCollectors(windowMs)
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
@@ -24,6 +24,7 @@ class Settings(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("echolot-settings", Context.MODE_PRIVATE)
// ---- archive ---------------------------------------------------------------------
var archiveEnabled: Boolean
@@ -80,6 +81,28 @@ class Settings(context: Context) {
get() = prefs.getBoolean(ADB_RELAY, false)
set(v) = prefs.edit().putBoolean(ADB_RELAY, v).apply()
// ---- discovery listeners -------------------------------------------------------------
/**
* Which passive discovery listeners a long run starts.
*
* Selectable rather than fixed because they differ from the rest of the battery in kind: they
* record what OTHER devices on the segment broadcast about themselves — hostnames, models,
* printer names — and that is someone's choice to make per run, not a default to inherit.
* They also cost nothing to leave off, since a listener that never starts cannot slow a run
* down.
*
* NetBIOS is off by default alone among them: UDP 137 is privileged, so at app tier it can
* only ever report `unsupported`, and shipping a listener that is guaranteed to fail as an
* on-by-default option would train people to ignore the status column. It stays selectable —
* the reason it reports is worth seeing once, and the tier that can bind it is coming.
*/
fun discoveryEnabled(id: String): Boolean =
prefs.getBoolean("$DISCOVERY_PREFIX$id", id != DiscoveryIds.NETBIOS)
fun setDiscoveryEnabled(id: String, on: Boolean) =
prefs.edit().putBoolean("$DISCOVERY_PREFIX$id", on).apply()
// ---- run-duration learning ---------------------------------------------------------
/**
@@ -276,6 +299,42 @@ class Settings(context: Context) {
const val ACCOUNT_ID = "account_id"
const val DURATION_PREFIX = "duration_ms."
const val ADB_RELAY = "adb_relay_enabled"
const val DISCOVERY_PREFIX = "discovery."
const val LONG_RUN_MINUTES = "long_run_minutes"
}
}
/**
* Ids for the selectable discovery listeners.
*
* Top-level rather than nested in [Settings] because a class gets exactly one companion object and
* that one is already spoken for by the preference keys, which stay private. Plain strings rather
* than an enum: they key a stored preference, so a listener being added or retired must not need a
* migration.
*/
object DiscoveryIds {
const val SSDP = "ssdp"
const val WSD = "wsd"
const val LLMNR = "llmnr"
const val NETBIOS = "netbios"
/** Display order: device inventory first, then the legacy name-resolution pair. */
val ALL = listOf(SSDP, WSD, LLMNR, NETBIOS)
fun label(id: String): String = when (id) {
SSDP -> "SSDP"
WSD -> "WS-Discovery"
LLMNR -> "LLMNR"
NETBIOS -> "NetBIOS"
else -> id
}
/** Why someone would want this one, in the few words a chip's helper line allows. */
fun blurb(id: String): String = when (id) {
SSDP -> "UPnP devices announcing themselves"
WSD -> "printers, scanners, cameras"
LLMNR -> "Windows name lookups (and that it is enabled here)"
NETBIOS -> "legacy Windows names — needs a privileged port, so app tier reports why not"
else -> ""
}
}
@@ -388,10 +388,11 @@ 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()) {
// Debug builds only, and duplicated as a switch on the home screen because that is where
// it is reached for. It publishes where this device can be reached over adb: scaffolding
// for driving a test device, useless without wireless debugging, and not something a
// release build should offer at all.
if (BuildConfig.DEBUG) Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Developer relay", style = MaterialTheme.typography.titleMedium)
Toggle(