Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a88a2d4b9 | ||
|
|
97515f7090 | ||
|
|
c414534e03 |
@@ -21,6 +21,8 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<!-- Only so the relay's ongoing status is visible; the service runs either way. -->
|
<!-- Only so the relay's ongoing status is visible; the service runs either way. -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
@@ -73,6 +75,19 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
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
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ class AdbRelayService : Service() {
|
|||||||
r.start()
|
r.start()
|
||||||
|
|
||||||
scope.launch {
|
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) {
|
while (true) {
|
||||||
val ep = r.lastEndpoint
|
val ep = r.lastEndpoint
|
||||||
if (ep != null) {
|
if (ep != null) {
|
||||||
@@ -66,13 +71,14 @@ class AdbRelayService : Service() {
|
|||||||
val result = post(settings, ep)
|
val result = post(settings, ep)
|
||||||
status = if (result == null) {
|
status = if (result == null) {
|
||||||
lastPosted = wire
|
lastPosted = wire
|
||||||
|
reportedOnce = true
|
||||||
"reported $wire"
|
"reported $wire"
|
||||||
} else {
|
} else {
|
||||||
"found $wire, but reporting failed: $result"
|
"found $wire, but reporting failed: $result"
|
||||||
}
|
}
|
||||||
notify(status)
|
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
|
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) {
|
fun start(ctx: Context) {
|
||||||
val i = Intent(ctx, AdbRelayService::class.java)
|
val i = Intent(ctx, AdbRelayService::class.java)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i)
|
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".
|
// 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
|
||||||
@@ -259,6 +262,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
Screen.RUN -> EcholotScreen(
|
Screen.RUN -> EcholotScreen(
|
||||||
state = vm.state,
|
state = vm.state,
|
||||||
longMinutes = vm.settings.longRunMinutes,
|
longMinutes = vm.settings.longRunMinutes,
|
||||||
|
discoveryEnabled = { vm.settings.discoveryEnabled(it) },
|
||||||
|
onDiscoveryChange = { id, on -> vm.settings.setDiscoveryEnabled(id, on) },
|
||||||
onRun = { mode -> vm.run(mode) },
|
onRun = { mode -> vm.run(mode) },
|
||||||
onCancel = vm::cancel,
|
onCancel = vm::cancel,
|
||||||
onDeveloperOptions = {
|
onDeveloperOptions = {
|
||||||
@@ -281,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 },
|
||||||
@@ -336,9 +354,19 @@ private fun statusColor(s: TestStatus): Color = when (s) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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(
|
private fun EcholotScreen(
|
||||||
state: UiState,
|
state: UiState,
|
||||||
longMinutes: Int,
|
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,
|
onRun: (RunMode) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onShizukuAction: () -> Unit,
|
onShizukuAction: () -> Unit,
|
||||||
@@ -431,6 +459,76 @@ private fun EcholotScreen(
|
|||||||
fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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) {
|
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")
|
||||||
|
|||||||
@@ -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
|
* to `networks[]`. Idempotent — the normal path and the cancel path both call it, and only the
|
||||||
* first does anything.
|
* 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> {
|
private suspend fun stopCollectors(): List<Test> {
|
||||||
val running = activeCollectors
|
val running = activeCollectors
|
||||||
activeCollectors = emptyList()
|
activeCollectors = emptyList()
|
||||||
@@ -507,13 +561,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
// knowing it will usually report `unsupported` — UDP 137 is privileged, so the
|
// 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
|
// app tier cannot bind it — because a recorded reason beats an absent test, and
|
||||||
// the decoder is ready for the Shizuku tier.
|
// the decoder is ready for the Shizuku tier.
|
||||||
app.echo_lot.probe.SsdpCollector(
|
) + discoveryCollectors(windowMs)
|
||||||
searchIntervalMs = (windowMs / 5).coerceAtLeast(30_000),
|
|
||||||
),
|
|
||||||
app.echo_lot.probe.LlmnrCollector(),
|
|
||||||
app.echo_lot.probe.NetbiosCollector(),
|
|
||||||
app.echo_lot.probe.WsdCollector(),
|
|
||||||
)
|
|
||||||
activeCollectors = collectors
|
activeCollectors = collectors
|
||||||
for (c in collectors) runCatching { c.start(ctx, ids) }
|
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
|
// 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 =
|
private val prefs: SharedPreferences =
|
||||||
context.getSharedPreferences("echolot-settings", Context.MODE_PRIVATE)
|
context.getSharedPreferences("echolot-settings", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
|
||||||
// ---- archive ---------------------------------------------------------------------
|
// ---- archive ---------------------------------------------------------------------
|
||||||
|
|
||||||
var archiveEnabled: Boolean
|
var archiveEnabled: Boolean
|
||||||
@@ -80,6 +81,28 @@ class Settings(context: Context) {
|
|||||||
get() = prefs.getBoolean(ADB_RELAY, false)
|
get() = prefs.getBoolean(ADB_RELAY, false)
|
||||||
set(v) = prefs.edit().putBoolean(ADB_RELAY, v).apply()
|
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 ---------------------------------------------------------
|
// ---- run-duration learning ---------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -276,6 +299,42 @@ class Settings(context: Context) {
|
|||||||
const val ACCOUNT_ID = "account_id"
|
const val ACCOUNT_ID = "account_id"
|
||||||
const val DURATION_PREFIX = "duration_ms."
|
const val DURATION_PREFIX = "duration_ms."
|
||||||
const val ADB_RELAY = "adb_relay_enabled"
|
const val ADB_RELAY = "adb_relay_enabled"
|
||||||
|
const val DISCOVERY_PREFIX = "discovery."
|
||||||
const val LONG_RUN_MINUTES = "long_run_minutes"
|
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 ------------------------------------------------------------------
|
// ---- 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(
|
||||||
|
|||||||
Reference in New Issue
Block a user