diff --git a/clients/android/app/src/main/AndroidManifest.xml b/clients/android/app/src/main/AndroidManifest.xml
index 72d4f8a..266c5f2 100644
--- a/clients/android/app/src/main/AndroidManifest.xml
+++ b/clients/android/app/src/main/AndroidManifest.xml
@@ -8,6 +8,9 @@
+
+
+
diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt
index 297e151..8d3f2c5 100644
--- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt
+++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt
@@ -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>(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
diff --git a/clients/android/kit/build.gradle.kts b/clients/android/kit/build.gradle.kts
index 4ab22e6..ee5c550 100644
--- a/clients/android/kit/build.gradle.kts
+++ b/clients/android/kit/build.gradle.kts
@@ -27,6 +27,10 @@ android {
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
+dependencies {
+ testImplementation("junit:junit:4.13.2") // JVM unit test for the pure TXT parser
+}
+
// ------------------------------------------------------------------------------------------------
// cargo-ndk: cross-compile crates/punktfunk-android into this module's jniLibs// so the
// resulting libpunktfunk_android.so is packaged into the app (and any AAR this module produces).
diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt
new file mode 100644
index 0000000..2dc1b24
--- /dev/null
+++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt
@@ -0,0 +1,194 @@
+package io.unom.punktfunk.kit.discovery
+
+import android.content.Context
+import android.net.nsd.NsdManager
+import android.net.nsd.NsdServiceInfo
+import android.net.wifi.WifiManager
+import android.os.Build
+import android.util.Log
+
+private const val TAG = "PunktfunkNsd"
+
+/** DNS-SD service type punktfunk hosts advertise (host: `_punktfunk._udp.local.`). */
+const val PUNKTFUNK_SERVICE_TYPE = "_punktfunk._udp"
+const val PUNKTFUNK_PROTO = "punktfunk/1"
+
+/** One resolved host fit for the picker. [key] is the stable dedup id. */
+data class DiscoveredHost(
+ val key: String,
+ val name: String,
+ val host: String,
+ val port: Int,
+ val fingerprint: String? = null, // TXT "fp" (host cert SHA-256, advisory — TOFU still verifies)
+ val pairingRequired: Boolean = false,
+)
+
+/** Parsed TXT fields. Pure — unit-testable without Android (see ParseTxtTest). */
+data class TxtFields(
+ val proto: String?,
+ val fp: String?,
+ val pair: String?,
+ val id: String?,
+) {
+ val pairingRequired: Boolean get() = pair == "required"
+ val isPunktfunk: Boolean get() = proto == PUNKTFUNK_PROTO
+}
+
+/**
+ * Pure TXT parser. NSD hands TXT as a `Map` (a null/empty value = present-but-
+ * empty key). Decode UTF-8; missing keys are null, never an error.
+ */
+fun parseTxt(attrs: Map): TxtFields {
+ fun s(k: String): String? = attrs[k]?.takeIf { it.isNotEmpty() }?.toString(Charsets.UTF_8)
+ return TxtFields(proto = s("proto"), fp = s("fp"), pair = s("pair"), id = s("id"))
+}
+
+/**
+ * Browses `_punktfunk._udp` via NsdManager, resolves each service (the reliable
+ * `registerServiceInfoCallback` path on API 34+, legacy `resolveService` on 31–33 where its TXT is
+ * often empty), and pushes the live host set to [onChange] (invoked on the main thread).
+ *
+ * Lifecycle: [start] when the picker appears, [stop] when it leaves / on connect — holds a
+ * MulticastLock while running (an OEM Wi-Fi power-save hedge). Note: the Android emulator's SLIRP
+ * NAT drops multicast, so on the emulator discovery starts but never finds a LAN host.
+ */
+class HostDiscovery(context: Context) {
+ private val appCtx = context.applicationContext
+ private val nsd = appCtx.getSystemService(Context.NSD_SERVICE) as NsdManager
+
+ /** Invoked on the main thread whenever the resolved host set changes. */
+ var onChange: ((List) -> Unit)? = null
+
+ private val resolved = LinkedHashMap() // key -> host
+ private var multicastLock: WifiManager.MulticastLock? = null
+ private var discoveryListener: NsdManager.DiscoveryListener? = null
+ private val infoCallbacks = mutableListOf() // API 34+ registrations
+ private var running = false
+
+ @Synchronized
+ fun start() {
+ if (running) return
+ running = true
+ acquireMulticastLock()
+ val listener = makeDiscoveryListener()
+ discoveryListener = listener
+ runCatching {
+ nsd.discoverServices(PUNKTFUNK_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
+ }.onFailure {
+ Log.e(TAG, "discoverServices failed", it)
+ stop()
+ }
+ }
+
+ @Synchronized
+ fun stop() {
+ if (!running) return
+ running = false
+ discoveryListener?.let { runCatching { nsd.stopServiceDiscovery(it) } }
+ discoveryListener = null
+ if (Build.VERSION.SDK_INT >= 34) {
+ for (cb in infoCallbacks) runCatching { nsd.unregisterServiceInfoCallback(cb) }
+ }
+ infoCallbacks.clear()
+ releaseMulticastLock()
+ resolved.clear()
+ onChange?.invoke(emptyList())
+ }
+
+ private fun publish() {
+ onChange?.invoke(resolved.values.sortedBy { it.name.lowercase() })
+ }
+
+ private fun makeDiscoveryListener() = object : NsdManager.DiscoveryListener {
+ override fun onDiscoveryStarted(type: String) {
+ Log.d(TAG, "discovery started: $type")
+ }
+ override fun onDiscoveryStopped(type: String) {
+ Log.d(TAG, "discovery stopped: $type")
+ }
+ override fun onStartDiscoveryFailed(type: String, code: Int) {
+ Log.e(TAG, "start discovery failed: $code")
+ runCatching { nsd.stopServiceDiscovery(this) }
+ }
+ override fun onStopDiscoveryFailed(type: String, code: Int) {
+ Log.e(TAG, "stop discovery failed: $code")
+ }
+
+ override fun onServiceFound(info: NsdServiceInfo) {
+ Log.d(TAG, "found: ${info.serviceName}")
+ resolve(info)
+ }
+ override fun onServiceLost(info: NsdServiceInfo) {
+ Log.d(TAG, "lost: ${info.serviceName}")
+ // onServiceLost carries no TXT, so drop by the instance-name fallback key only.
+ if (resolved.remove(info.serviceName) != null) publish()
+ }
+ }
+
+ private fun resolve(found: NsdServiceInfo) {
+ if (Build.VERSION.SDK_INT >= 34) resolveViaCallback(found) else resolveViaLegacy(found)
+ }
+
+ private fun resolveViaCallback(found: NsdServiceInfo) {
+ val cb = object : NsdManager.ServiceInfoCallback {
+ override fun onServiceUpdated(info: NsdServiceInfo) = ingest(info)
+ override fun onServiceLost() {}
+ override fun onServiceInfoCallbackRegistrationFailed(code: Int) {
+ Log.e(TAG, "ServiceInfoCallback reg failed: $code")
+ }
+ override fun onServiceInfoCallbackUnregistered() {}
+ }
+ runCatching {
+ nsd.registerServiceInfoCallback(found, appCtx.mainExecutor, cb)
+ infoCallbacks.add(cb)
+ }.onFailure { Log.e(TAG, "registerServiceInfoCallback failed", it) }
+ }
+
+ private fun resolveViaLegacy(found: NsdServiceInfo) {
+ // A ResolveListener can't be reused — allocate one per resolve. TXT may be empty pre-34.
+ val listener = object : NsdManager.ResolveListener {
+ override fun onServiceResolved(info: NsdServiceInfo) = ingest(info)
+ override fun onResolveFailed(info: NsdServiceInfo, code: Int) {
+ Log.e(TAG, "resolve failed: $code")
+ }
+ }
+ runCatching { nsd.resolveService(found, listener) }
+ .onFailure { Log.e(TAG, "resolveService failed", it) }
+ }
+
+ @Suppress("DEPRECATION") // info.host is deprecated at API 34 (replaced by hostAddresses)
+ private fun ingest(info: NsdServiceInfo) {
+ val txt = parseTxt(info.attributes)
+ // Reject an incompatible protocol IF the host advertised one; tolerate empty TXT (pre-34).
+ if (txt.proto != null && !txt.isPunktfunk) {
+ Log.d(TAG, "skip non-punktfunk proto=${txt.proto}")
+ return
+ }
+ val ip = (if (Build.VERSION.SDK_INT >= 34) info.hostAddresses.firstOrNull() else info.host)
+ ?.hostAddress ?: return
+ val key = txt.id?.takeIf { it.isNotBlank() } ?: info.serviceName
+ resolved[key] = DiscoveredHost(
+ key = key,
+ name = info.serviceName.removeSuffix("."),
+ host = ip,
+ port = info.port,
+ fingerprint = txt.fp,
+ pairingRequired = txt.pairingRequired,
+ )
+ Log.d(TAG, "resolved: ${resolved[key]}")
+ publish()
+ }
+
+ private fun acquireMulticastLock() {
+ val wifi = appCtx.getSystemService(Context.WIFI_SERVICE) as WifiManager
+ multicastLock = wifi.createMulticastLock("punktfunk-nsd").apply {
+ setReferenceCounted(true)
+ runCatching { acquire() }
+ }
+ }
+
+ private fun releaseMulticastLock() {
+ multicastLock?.takeIf { it.isHeld }?.let { runCatching { it.release() } }
+ multicastLock = null
+ }
+}
diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/discovery/ParseTxtTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/discovery/ParseTxtTest.kt
new file mode 100644
index 0000000..36c78cc
--- /dev/null
+++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/discovery/ParseTxtTest.kt
@@ -0,0 +1,63 @@
+package io.unom.punktfunk.kit.discovery
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/** Pure JVM test of the mDNS TXT parser (no Android types). Run: `./gradlew :kit:testDebugUnitTest`. */
+class ParseTxtTest {
+ private fun b(s: String): ByteArray = s.toByteArray(Charsets.UTF_8)
+
+ @Test
+ fun parsesFullRecord() {
+ val fp = "a".repeat(64)
+ val t = parseTxt(
+ mapOf(
+ "proto" to b("punktfunk/1"),
+ "fp" to b(fp),
+ "pair" to b("required"),
+ "id" to b("host-123"),
+ ),
+ )
+ assertEquals("punktfunk/1", t.proto)
+ assertEquals(fp, t.fp)
+ assertEquals("host-123", t.id)
+ assertTrue(t.isPunktfunk)
+ assertTrue(t.pairingRequired)
+ }
+
+ @Test
+ fun optionalPairingAndMissingKeys() {
+ val t = parseTxt(mapOf("proto" to b("punktfunk/1"), "pair" to b("optional")))
+ assertFalse(t.pairingRequired)
+ assertNull(t.fp)
+ assertNull(t.id)
+ }
+
+ @Test
+ fun emptyMapYieldsAllNull() {
+ val t = parseTxt(emptyMap())
+ assertNull(t.proto)
+ assertNull(t.fp)
+ assertNull(t.pair)
+ assertNull(t.id)
+ assertFalse(t.isPunktfunk)
+ assertFalse(t.pairingRequired)
+ }
+
+ @Test
+ fun nullAndEmptyValuesTreatedAsAbsent() {
+ // NSD delivers present-but-empty TXT keys as null / empty ByteArray.
+ val t = parseTxt(mapOf("fp" to null, "id" to ByteArray(0), "proto" to b("punktfunk/1")))
+ assertNull(t.fp)
+ assertNull(t.id)
+ assertTrue(t.isPunktfunk)
+ }
+
+ @Test
+ fun nonPunktfunkProtoIsNotAccepted() {
+ assertFalse(parseTxt(mapOf("proto" to b("moonlight/7"))).isPunktfunk)
+ }
+}