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) + } +}