diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/AdbRelay.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/AdbRelay.kt index 801e85d..e848844 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/AdbRelay.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/AdbRelay.kt @@ -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() + .firstOrNull { !it.isLoopbackAddress } + ?.hostAddress }.getOrNull() private companion object { diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt index 216ce08..0bac170 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt @@ -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(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 @@ -283,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 }, @@ -347,6 +363,10 @@ private fun EcholotScreen( /** 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, @@ -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) { Button(onClick = { onRun(mode) }, enabled = !state.running) { Text(if (state.running) "Running…" else "Run measurement") diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt index 7281861..baa9962 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt @@ -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(