feat(android): mDNS host discovery (NsdManager) in the connect screen
apple / swift (push) Successful in 53s
ci / rust (push) Successful in 1m12s
android / android (push) Failing after 1m42s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 31s
ci / bench (push) Successful in 1m44s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
flatpak / build-publish (push) Failing after 1s
deb / build-publish (push) Failing after 2m45s
docker / deploy-docs (push) Successful in 5s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m49s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m0s

M4 Android stage 1 (discovery). Kotlin-only — browse _punktfunk._udp and present a
tappable host list above the manual Host/Port fields.

- clients/android/kit: HostDiscovery — NsdManager browse + resolve (registerServiceInfoCallback
  on API 34+ for reliable TXT, legacy resolveService on 31-33), MulticastLock while running, and
  a pure parseTxt(proto/fp/pair/id). Exposes the live host set via an onChange callback (NSD
  callbacks land on the main thread). DiscoveredHost(name, host, port, fingerprint?, pairingRequired).
  + a JVM unit test of parseTxt.
- clients/android/app: ConnectScreen renders discovered hosts (tap -> fill host/port + connect);
  discovery scoped to the screen (start on enter, stop on connect/leave). Manifest adds
  CHANGE_WIFI_MULTICAST_STATE + ACCESS_WIFI_STATE (NEARBY_WIFI_DEVICES already declared). Trust
  stays TOFU (pin=None); fp shown advisory; pairingRequired shown (SPAKE2 PIN wiring is later).

Verified: parseTxt unit test (5/5 green); on the emulator a loopback NsdManager.registerService of
a fake _punktfunk._udp host was discovered + resolved + TXT-parsed and rendered as a card
(name/host:port/TOFU/fp) -- the full browse->resolve->parse->UI path. Real cross-LAN discovery
needs a physical device on the host LAN (the emulator's SLIRP NAT drops mDNS multicast).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 11:32:09 +02:00
parent a7c5d4256c
commit 3167c936c0
5 changed files with 343 additions and 15 deletions
@@ -8,6 +8,9 @@
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<!-- Hold a MulticastLock while NsdManager discovery runs (OEM Wi-Fi power-save hedge). -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
Harmless to declare on earlier releases. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
@@ -11,6 +11,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
@@ -18,10 +19,16 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
@@ -46,6 +53,8 @@ import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.Keymap
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.discovery.HostDiscovery
import kotlin.math.abs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -139,6 +148,7 @@ private fun App() {
@Composable
private fun ConnectScreen(onConnected: (Long) -> Unit) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var host by remember { mutableStateOf("") }
var port by remember { mutableStateOf("9777") }
var connecting by remember { mutableStateOf(false) }
@@ -146,6 +156,37 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) {
val abi = remember { runCatching { NativeBridge.abiVersion() }.getOrDefault(-1) }
val (w, h, hz) = REQUEST_MODE
// mDNS discovery scoped to this screen; NsdManager callbacks arrive on the main thread, so the
// onChange callback can set Compose state directly. (Emulator SLIRP drops multicast → empty.)
val discovery = remember { HostDiscovery(context) }
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
DisposableEffect(Unit) {
discovery.onChange = { discovered = it }
discovery.start()
onDispose {
discovery.onChange = null
discovery.stop()
}
}
fun connect(targetHost: String, targetPort: Int) {
connecting = true
status = "Connecting to $targetHost:$targetPort"
discovery.stop() // free the Wi-Fi radio before the stream session
scope.launch {
val handle = withContext(Dispatchers.IO) {
NativeBridge.nativeConnect(targetHost, targetPort, w, h, hz)
}
connecting = false
if (handle != 0L) {
onConnected(handle)
} else {
status = "Connection failed — check host/port and logcat"
discovery.start()
}
}
}
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -154,6 +195,24 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) {
Text("punktfunk", style = MaterialTheme.typography.headlineMedium)
Text("Android client", style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(24.dp))
if (discovered.isNotEmpty()) {
Text("Discovered hosts", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(8.dp))
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 220.dp)) {
items(discovered, key = { it.key }) { dh ->
DiscoveredHostRow(dh, enabled = !connecting) {
host = dh.host
port = dh.port.toString()
connect(dh.host, dh.port)
}
}
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
}
OutlinedTextField(
value = host,
onValueChange = { host = it },
@@ -171,21 +230,7 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) {
Spacer(Modifier.height(16.dp))
Button(
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
onClick = {
connecting = true
status = "Connecting to $host:$port"
scope.launch {
val handle = withContext(Dispatchers.IO) {
NativeBridge.nativeConnect(host.trim(), port.toInt(), w, h, hz)
}
connecting = false
if (handle != 0L) {
onConnected(handle)
} else {
status = "Connection failed — check host/port and logcat"
}
}
},
onClick = { connect(host.trim(), port.toInt()) },
) { Text(if (connecting) "Connecting…" else "Connect ($w×$h@$hz)") }
status?.let {
Spacer(Modifier.height(12.dp))
@@ -196,6 +241,25 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) {
}
}
@Composable
private fun DiscoveredHostRow(dh: DiscoveredHost, enabled: Boolean, onTap: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(enabled = enabled, onClick = onTap),
) {
Column(Modifier.padding(12.dp)) {
Text(dh.name, style = MaterialTheme.typography.bodyLarge)
val pairing = if (dh.pairingRequired) "pairing required" else "TOFU"
Text("${dh.host}:${dh.port} · $pairing", style = MaterialTheme.typography.bodySmall)
dh.fingerprint?.let { fp ->
Text("fp ${fp.take(16)}", style = MaterialTheme.typography.labelSmall)
}
}
}
}
@Composable
private fun StreamScreen(handle: Long, onDisconnect: () -> Unit) {
val context = LocalContext.current