git.iko.soy/opencode-android
on master help sign in
Remove local server discovery (mDNS/NSD) feature
commit2794fdaa09275489e2a84827e6a9818d36594a76 authoriko <mail@iko.soy> dateSun 5 Jul 2026, 06:36 (3 months ago) parentd416181
5 files changed+1−317
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt−6 app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt−195 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+1−108 app/src/main/res/values-es/strings.xml−4 app/src/main/res/values/strings.xml−4
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt−6
@@ -200,12 +200,6 @@ object NetworkConfig {
200200 /** Timeout for loading a profile from DataStore before giving up. */
201201 const val profileLoadTimeoutMs = 5_000L
202202
203− /** How long mDNS discovery searches before showing a "no servers found" terminal state.
204− * Discovery continues running in the background (a late server can still appear), but the
205− * spinner is replaced with the no-results message so the user isn't left looking at an
206− * infinite spinner on a network with no opencode servers. */
207− const val nsdDiscoveryNoResultTimeoutMs = 8_000L
208−
209203 /** Timeout for a credential test (probe) before giving up, so a hung/unresponsive server
210204 * doesn't leave the "Testing…" spinner spinning indefinitely. */
211205 const val testCredentialsTimeoutMs = 15_000L
app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.ktdeleted−195
@@ -1,195 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import android.content.Context
4−import android.net.nsd.NsdManager
5−import android.net.nsd.NsdServiceInfo
6−import android.os.Build
7−import android.util.Log
8−import kotlinx.coroutines.channels.Channel
9−import kotlinx.coroutines.flow.Flow
10−import kotlinx.coroutines.channels.awaitClose
11−import kotlinx.coroutines.flow.callbackFlow
12−import kotlinx.coroutines.launch
13−import kotlinx.coroutines.suspendCancellableCoroutine
14−import kotlinx.coroutines.withTimeoutOrNull
15−import kotlin.coroutines.resume
16−
17−/** A server discovered on the local network via mDNS/DNS-SD. */
18−data class DiscoveredServer(val name: String, val host: String, val port: Int) {
19− val baseUrl: String get() {
20− // IPv6 literals (e.g. "fe80::1%wlan0", "2001:db8::1") must be bracketed to form a valid
21− // URL authority; a link-local zone id's '%' is percent-encoded to "%25" per RFC 6874
22− // ("[fe80::1%25wlan0]") rather than dropped, so link-local hosts stay dialable. IPv4 unchanged.
23− val formattedHost = if (host.contains(':')) "[${host.replace("%", "%25")}]" else host
24− return "http://$formattedHost:$port"
25− }
26−}
27−
28−/**
29− * Discovers opencode servers advertised on the LAN. opencode's `--mdns` publishes a DNS-SD
30− * `_http._tcp` service named `opencode-<port>` (with a `path=/` TXT record), so we browse
31− * that service type and keep only names that look like opencode. Requires no extra
32− * permission — [NsdManager] manages the multicast lock internally.
33− */
34−open class NsdDiscovery(context: Context?) {
35−
36− protected constructor() : this(null)
37−
38− private val appContext = context?.applicationContext
39−
40− /**
41− * A cold [Flow] that starts a discovery when collected and stops it when the collector
42− * cancels. Emits the growing/shrinking set of discovered servers. Resolutions are
43− * serialized through a channel because [NsdManager.resolveService] can only process one
44− * at a time on older platforms (a concurrent resolve fails with ALREADY_ACTIVE).
45− */
46− open fun discover(): Flow<List<DiscoveredServer>> = callbackFlow {
47− val nsd = appContext?.getSystemService(Context.NSD_SERVICE) as? NsdManager
48− if (nsd == null) {
49− trySend(emptyList())
50− close()
51− return@callbackFlow
52− }
53− // Thread-safe: mutated by the resolver coroutine and the (main-thread) lost callback.
54− val found = java.util.concurrent.ConcurrentHashMap<String, DiscoveredServer>()
55− // Per-name epoch, bumped on every onServiceLost. Each queued resolve is stamped with the
56− // epoch that was current when it was enqueued; the resolver discards its result if the
57− // epoch has advanced since. This makes loss ordering authoritative: a resolve queued
58− // BEFORE a loss — a stale duplicate onServiceFound (stock NsdManager can emit these on
59− // multi-homed devices), or a resolve still in flight when the service departs — is
60− // dropped, while a resolve queued AFTER a loss (a genuine re-announcement of a restarted
61− // server) still inserts. It fixes the phantom a plain "lost-pending" flag missed:
62− // onServiceLost removing an already-resolved entry couldn't tell a second, still-queued
63− // duplicate resolve to skip re-inserting the departed service — yet it also avoids that
64− // flag's converse hazard of leaking a marker that suppresses a later legitimate re-appearance.
65− val epoch = java.util.concurrent.ConcurrentHashMap<String, Int>()
66− val toResolve = Channel<Pair<NsdServiceInfo, Int>>(Channel.UNLIMITED)
67−
68− val resolver = launch {
69− for ((info, stampedEpoch) in toResolve) {
70− // Key the map by the name from discovery: onServiceFound/onServiceLost both use
71− // NsdServiceInfo.serviceName, whereas the resolved info's serviceName can differ —
72− // keying by the resolved name would leave a departed server that onServiceLost
73− // can't match, so it would linger in the emitted list forever.
74− val discoveredName = info.serviceName.orEmpty()
75− // NsdManager.resolveService occasionally never invokes its listener. Without a
76− // timeout, one stuck resolve would hang this single serialized resolver forever,
77− // so every later-discovered service queued behind it is never processed and the
78− // emitted list silently stops updating. Abandon a stuck resolve and keep draining.
79− val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { resolveService(nsd, info) }
80− // Drop the result if the service was reported lost since this resolve was queued
81− // (its epoch advanced), or if the resolve failed/timed out (the service is gone).
82− if ((epoch[discoveredName] ?: 0) != stampedEpoch || resolved == null) continue
83− val name = resolved.serviceName.orEmpty()
84− // Keep only opencode's own advertisements, not every _http._tcp service
85− // (printers, routers, other web servers) on the network.
86− if (!name.startsWith("opencode", ignoreCase = true)) continue
87− @Suppress("DEPRECATION")
88− val host = resolved.host?.hostAddress ?: continue
89− found[discoveredName] = DiscoveredServer(name = name, host = host, port = resolved.port)
90− // Close the check-then-insert race: an onServiceLost firing in the non-suspending
91− // window since the check above bumped the epoch but couldn't remove this name from
92− // `found` (nothing was inserted yet). Re-check and evict now — the epoch is
93− // monotonic, so any loss that raced the insert is still visible here.
94− if ((epoch[discoveredName] ?: 0) != stampedEpoch) found.remove(discoveredName)
95− trySend(found.values.sortedBy { it.name })
96− }
97− }
98−
99− val listener = object : NsdManager.DiscoveryListener {
100− override fun onDiscoveryStarted(serviceType: String?) {}
101− override fun onServiceFound(serviceInfo: NsdServiceInfo) {
102− // Stamp the resolve with the name's current epoch so a loss after this point (but
103− // before the serialized resolver reaches it) invalidates the result.
104− val name = serviceInfo.serviceName.orEmpty()
105− toResolve.trySend(serviceInfo to (epoch[name] ?: 0))
106− }
107− override fun onServiceLost(serviceInfo: NsdServiceInfo) {
108− val name = serviceInfo.serviceName ?: return
109− // Advance the epoch so any resolve queued before now (including a duplicate
110− // onServiceFound whose result hasn't landed yet) is discarded and can't resurrect
111− // this service, then drop it from the emitted list if it was already resolved.
112− epoch.merge(name, 1) { old, _ -> old + 1 }
113− if (found.remove(name) != null) {
114− trySend(found.values.sortedBy { it.name })
115− }
116− }
117− override fun onDiscoveryStopped(serviceType: String?) {}
118− override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
119− Log.w(TAG, "mDNS discovery start failed: $errorCode")
120− close()
121− }
122− override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) {}
123− }
124−
125− runCatching { nsd.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) }
126− .onFailure { close(it) }
127−
128− awaitClose {
129− resolver.cancel()
130− toResolve.close()
131− runCatching { nsd.stopServiceDiscovery(listener) }
132− }
133− }
134−
135− private suspend fun resolveService(nsd: NsdManager, info: NsdServiceInfo): NsdServiceInfo? =
136− suspendCancellableCoroutine { cont ->
137− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
138− // API 34 deprecated the one-shot resolveService (which has no cancel API and
139− // whose listener leaks on timeout, wedging the platform's serialized slot so the
140− // next resolve fails with ALREADY_ACTIVE) in favour of a persistent callback that
141− // can be detached. A timed-out (cancelled) resolve unregisters its callback in
142− // invokeOnCancellation, and a settled one unregisters itself, so nothing lingers.
143− val settled = java.util.concurrent.atomic.AtomicBoolean(false)
144− lateinit var callback: NsdManager.ServiceInfoCallback
145− callback = object : NsdManager.ServiceInfoCallback {
146− override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
147− if (settled.compareAndSet(false, true) && cont.isActive) cont.resume(null)
148− }
149− override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
150− if (settled.compareAndSet(false, true)) {
151− runCatching { nsd.unregisterServiceInfoCallback(callback) }
152− if (cont.isActive) cont.resume(serviceInfo)
153− }
154− }
155− override fun onServiceLost() {
156− if (settled.compareAndSet(false, true)) {
157− runCatching { nsd.unregisterServiceInfoCallback(callback) }
158− if (cont.isActive) cont.resume(null)
159− }
160− }
161− override fun onServiceInfoCallbackUnregistered() {}
162− }
163− cont.invokeOnCancellation {
164− if (settled.compareAndSet(false, true)) {
165− runCatching { nsd.unregisterServiceInfoCallback(callback) }
166− }
167− }
168− runCatching { nsd.registerServiceInfoCallback(info, { it.run() }, callback) }
169− .onFailure { if (settled.compareAndSet(false, true) && cont.isActive) cont.resume(null) }
170− } else {
171− // Pre-34: the deprecated one-shot resolveService has no cancel API, so a stuck
172− // resolve abandoned by RESOLVE_TIMEOUT_MS can still wedge the platform's single
173− // serialized slot; this is a platform limitation we can only bound, not clear.
174− val listener = object : NsdManager.ResolveListener {
175− override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {
176− if (cont.isActive) cont.resume(null)
177− }
178− override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
179− if (cont.isActive) cont.resume(serviceInfo)
180− }
181− }
182− runCatching { @Suppress("DEPRECATION") nsd.resolveService(info, listener) }
183− .onFailure { if (cont.isActive) cont.resume(null) }
184− }
185− }
186−
187− private companion object {
188− const val TAG = "NsdDiscovery"
189− // opencode advertises bonjour type "http" → DNS-SD "_http._tcp.".
190− const val SERVICE_TYPE = "_http._tcp."
191− // Upper bound on a single mDNS resolve before it's abandoned so a stuck platform
192− // callback can't wedge the serialized resolver. Local-network resolves are sub-second.
193− const val RESOLVE_TIMEOUT_MS = 5_000L
194− }
195−}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+1−108
@@ -2,14 +2,12 @@ package soy.iko.opencode.ui.server
22
33import androidx.activity.compose.BackHandler
44import androidx.compose.animation.AnimatedVisibility
5−import androidx.compose.foundation.clickable
65import androidx.compose.foundation.layout.Arrangement
76import androidx.compose.foundation.layout.Box
87import androidx.compose.foundation.layout.Column
98import androidx.compose.foundation.layout.Row
109import androidx.compose.foundation.layout.fillMaxSize
1110import androidx.compose.foundation.layout.fillMaxWidth
12−import androidx.compose.foundation.layout.heightIn
1311import androidx.compose.foundation.layout.imePadding
1412import androidx.compose.foundation.layout.padding
1513import androidx.compose.foundation.layout.size
@@ -20,14 +18,12 @@ import androidx.compose.foundation.text.KeyboardActions
2018import androidx.compose.foundation.text.KeyboardOptions
2119import androidx.compose.foundation.verticalScroll
2220import androidx.compose.material.icons.Icons
23−import androidx.compose.material.icons.filled.Refresh
2421import androidx.compose.material.icons.automirrored.filled.ArrowBack
2522import androidx.compose.material.icons.filled.Delete
2623import androidx.compose.material.icons.filled.ExpandLess
2724import androidx.compose.material.icons.filled.ExpandMore
2825import androidx.compose.material.icons.filled.Visibility
2926import androidx.compose.material.icons.filled.VisibilityOff
30−import androidx.compose.material.icons.filled.Wifi
3127import androidx.compose.material3.AlertDialog
3228import androidx.compose.material3.Button
3329import androidx.compose.material3.CircularProgressIndicator
@@ -55,7 +51,6 @@ import androidx.compose.ui.Modifier
5551import androidx.compose.ui.focus.FocusRequester
5652import androidx.compose.ui.focus.focusRequester
5753import androidx.compose.ui.input.nestedscroll.nestedScroll
58−import androidx.compose.ui.platform.LocalContext
5954import androidx.compose.ui.platform.LocalSoftwareKeyboardController
6055import androidx.compose.ui.platform.testTag
6156import androidx.compose.ui.semantics.Role
@@ -71,8 +66,6 @@ import androidx.compose.ui.res.stringResource
7166import androidx.compose.ui.unit.dp
7267import androidx.lifecycle.compose.collectAsStateWithLifecycle
7368import androidx.lifecycle.viewmodel.compose.viewModel
74−import soy.iko.opencode.data.network.NsdDiscovery
75−import soy.iko.opencode.data.network.NetworkConfig
7669import soy.iko.opencode.di.AppContainer
7770import soy.iko.opencode.R
7871import soy.iko.opencode.ui.components.autofillModifier
@@ -113,7 +106,7 @@ fun ServerEditScreen(
113106 }
114107
115108 // Collapsing/lifting top bar so the long form scrolls under it, matching Settings and
116− // Diagnostics (the form can be tall: Base URL + Discovery + Auth + Test + Connect + Save +
109+ // Diagnostics (the form can be tall: Base URL + Auth + Test + Connect + Save +
117110 // Label + Security).
118111 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
119112 Scaffold(
@@ -291,13 +284,6 @@ private fun ServerEditForm(
291284 .focusRequester(baseUrlFocus)
292285 .testTag("server_url"),
293286 )
294− // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
295− // server is the primary path; on edit the populated URL keeps it (and its multicast)
296− // collapsed.
297− DiscoverySection(
298− initiallyExpanded = state.baseUrl.isBlank(),
299− onPick = { url -> vm.update { it.copy(baseUrl = url) } },
300− )
301287 // Auth fields appear only once a connect attempt learns the server requires them
302288 // (or an authed profile is loaded) — the common no-auth server never sees them.
303289 AnimatedVisibility(visible = state.authFieldsVisible) {
@@ -590,96 +576,3 @@ private fun SecurityFields(
590576 )
591577 }
592578}
593−
594−/**
595− * Optional LAN discovery: browses for opencode servers advertised via mDNS (`--mdns`) and
596− * offers each as a one-tap fill for the base URL. Discovery (and its multicast traffic) only
597− * runs while the section is expanded — the collection stops when the section is collapsed or
598− * the screen leaves composition. [initiallyExpanded] starts it open on a fresh add form so the
599− * discovered list is the primary path; an edit form (populated URL) leaves it collapsed.
600− */
601−@Composable
602−private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolean = false) {
603− val context = LocalContext.current
604− var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
605− val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
606− Column(modifier = Modifier.fillMaxWidth()) {
607− TextButton(
608− onClick = { expanded = !expanded },
609− modifier = Modifier.semantics { stateDescription = stateLabel },
610− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
611− ) {
612− Icon(Icons.Filled.Wifi, contentDescription = null, modifier = Modifier.size(18.dp))
613− Text(stringResource(R.string.discover_on_network), modifier = Modifier.padding(start = 8.dp))
614− }
615− if (expanded) {
616− val discovery = remember { NsdDiscovery(context) }
617− // scanNonce drives a manual re-scan: bumping it recreates the discovery flow
618− // (cancelling the prior collection / Nsd listener) and restarts the no-result
619− // timeout, so a user on a flaky multicast network can retry without collapsing
620− // and re-expanding the section.
621− var scanNonce by remember { mutableStateOf(0) }
622− val servers by remember(discovery, scanNonce) { discovery.discover() }
623− .collectAsStateWithLifecycle(initialValue = emptyList())
624− // mDNS discovery is a continuous flow that never emits a terminal "no results"
625− // state — on a network with no opencode servers the spinner would spin forever.
626− // After the timeout, swap the spinner for a "no servers found" message. Discovery
627− // keeps running, so a late server can still replace the message when it arrives.
628− var searchTimedOut by remember { mutableStateOf(false) }
629− LaunchedEffect(scanNonce) {
630− searchTimedOut = false
631− kotlinx.coroutines.delay(NetworkConfig.nsdDiscoveryNoResultTimeoutMs)
632− searchTimedOut = true
633− }
634− if (servers.isNotEmpty()) {
635− servers.forEach { server ->
636− Column(
637− modifier = Modifier
638− .fillMaxWidth()
639− .heightIn(min = 48.dp)
640− .clickable(role = Role.Button) { onPick(server.baseUrl) }
641− .padding(vertical = 8.dp),
642− verticalArrangement = Arrangement.Center,
643− ) {
644− Text(server.name, style = MaterialTheme.typography.bodyMedium)
645− Text(
646− server.baseUrl,
647− style = MaterialTheme.typography.bodySmall,
648− color = MaterialTheme.colorScheme.onSurfaceVariant,
649− )
650− }
651− }
652− } else if (searchTimedOut) {
653− Text(
654− stringResource(R.string.no_servers_found),
655− style = MaterialTheme.typography.bodySmall,
656− color = MaterialTheme.colorScheme.onSurfaceVariant,
657− modifier = Modifier.padding(vertical = 8.dp),
658− )
659− } else {
660− Row(
661− verticalAlignment = Alignment.CenterVertically,
662− modifier = Modifier.padding(vertical = 8.dp),
663− ) {
664− CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
665− Text(
666− stringResource(R.string.searching_network),
667− style = MaterialTheme.typography.bodySmall,
668− color = MaterialTheme.colorScheme.onSurfaceVariant,
669− modifier = Modifier.padding(start = 8.dp),
670− )
671− }
672− }
673− // Manual re-scan affordance: shown after a timeout (or while results are present)
674− // so a user on flaky multicast can retry discovery without leaving the section.
675− TextButton(
676− onClick = { scanNonce++ },
677− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
678− modifier = Modifier.padding(top = 4.dp),
679− ) {
680− Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
681− Text(stringResource(R.string.scan_again), modifier = Modifier.padding(start = 8.dp))
682− }
683− }
684− }
685−}
app/src/main/res/values-es/strings.xml−4
@@ -103,10 +103,6 @@
103103 <string name="testing_connection">Probando…</string>
104104 <string name="delete_server">Eliminar servidor</string>
105105 <string name="delete_server_confirm">¿Eliminar este perfil de servidor?</string>
106− <string name="discover_on_network">Descubrir en la red</string>
107− <string name="searching_network">Buscando en la red local…</string>
108− <string name="no_servers_found">No se encontraron servidores de opencode en la red local.</string>
109− <string name="scan_again">Buscar de nuevo</string>
110106 <string name="testing_credentials">Probando…</string>
111107 <string name="credentials_ok">Credenciales aceptadas.</string>
112108 <string name="credentials_rejected">Credenciales rechazadas.</string>
app/src/main/res/values/strings.xml−4
@@ -112,10 +112,6 @@
112112 <string name="testing_connection">Testing…</string>
113113 <string name="delete_server">Delete server</string>
114114 <string name="delete_server_confirm">Delete this server profile?</string>
115− <string name="discover_on_network">Discover on network</string>
116− <string name="searching_network">Searching the local network…</string>
117− <string name="no_servers_found">No opencode servers found on the local network.</string>
118− <string name="scan_again">Scan again</string>
119115 <string name="testing_credentials">Testing…</string>
120116 <string name="credentials_ok">Credentials accepted.</string>
121117 <string name="credentials_rejected">Credentials rejected.</string>