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>
This commit is contained in:
mrambossek
2026-08-02 15:21:45 +02:00
co-authored by Claude Opus 5
parent c414534e03
commit 97515f7090
3 changed files with 86 additions and 19 deletions
@@ -4,9 +4,9 @@
package app.echo_lot.app package app.echo_lot.app
import android.content.Context import android.content.Context
import android.net.ConnectivityManager
import android.net.nsd.NsdManager import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo import android.net.nsd.NsdServiceInfo
import android.net.wifi.WifiManager
import java.net.Inet4Address 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 // 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. // also the only one worth relaying — a link-local address means nothing off-link.
if (i.host !is Inet4Address) return 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 // have wireless debugging on, and relaying a neighbour's port would send a
// developer to the wrong device. // developer to the wrong device. But when this device cannot say what its own
if (host != localIp()) return // 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") 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) } 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 { private fun localIp(): String? = runCatching {
val wifi = ctx.getSystemService(WifiManager::class.java) ?: return null val cm = ctx.getSystemService(ConnectivityManager::class.java) ?: return null
@Suppress("DEPRECATION") val lp = cm.getLinkProperties(cm.activeNetwork) ?: return null
val ip = wifi.connectionInfo.ipAddress lp.linkAddresses.map { it.address }
if (ip == 0) return null .filterIsInstance<Inet4Address>()
@Suppress("DEPRECATION") .firstOrNull { !it.isLoopbackAddress }
String.format( ?.hostAddress
"%d.%d.%d.%d",
ip and 0xff, ip shr 8 and 0xff, ip shr 16 and 0xff, ip shr 24 and 0xff,
)
}.getOrNull() }.getOrNull()
private companion object { private companion object {
@@ -73,6 +73,9 @@ class MainActivity : ComponentActivity() {
// there is no back stack to model beyond "return to the run screen". // there is no back stack to model beyond "return to the run screen".
var screen by remember { mutableStateOf(Screen.RUN) } var screen by remember { mutableStateOf(Screen.RUN) }
var preview by remember { mutableStateOf<String?>(null) } 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: // Automation entry point:
// adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true // adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
// starts a run immediately and uploads the report, so an unattended // starts a run immediately and uploads the report, so an unattended
@@ -283,6 +286,19 @@ class MainActivity : ComponentActivity() {
else -> Unit 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")) }, onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
onOpenSettings = { screen = Screen.SETTINGS }, onOpenSettings = { screen = Screen.SETTINGS },
onOpenHistory = { vm.refreshHistory(); screen = Screen.HISTORY }, onOpenHistory = { vm.refreshHistory(); screen = Screen.HISTORY },
@@ -347,6 +363,10 @@ private fun EcholotScreen(
/** Which discovery listeners are selected, and how to change one. */ /** Which discovery listeners are selected, and how to change one. */
discoveryEnabled: (String) -> Boolean, discoveryEnabled: (String) -> Boolean,
onDiscoveryChange: (String, Boolean) -> Unit, 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, onRun: (RunMode) -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
onShizukuAction: () -> Unit, onShizukuAction: () -> Unit,
@@ -473,6 +493,42 @@ private fun EcholotScreen(
) )
} }
// 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) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Button(onClick = { onRun(mode) }, enabled = !state.running) { Button(onClick = { onRun(mode) }, enabled = !state.running) {
Text(if (state.running) "Running…" else "Run measurement") Text(if (state.running) "Running…" else "Run measurement")
@@ -388,10 +388,11 @@ fun SettingsScreen(
// ---- dev relay ------------------------------------------------------------------ // ---- dev relay ------------------------------------------------------------------
// //
// Last, and deliberately plain: this is scaffolding for driving a test device, not a // Debug builds only, and duplicated as a switch on the home screen because that is where
// measurement. It publishes where this device can be reached over adb, which is why it // it is reached for. It publishes where this device can be reached over adb: scaffolding
// is off until someone decides otherwise. // for driving a test device, useless without wireless debugging, and not something a
Card(Modifier.fillMaxWidth()) { // release build should offer at all.
if (BuildConfig.DEBUG) Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Developer relay", style = MaterialTheme.typography.titleMedium) Text("Developer relay", style = MaterialTheme.typography.titleMedium)
Toggle( Toggle(