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
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:
@@ -8,6 +8,9 @@
|
|||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
android:usesPermissionFlags="neverForLocation" />
|
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.
|
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
|
||||||
Harmless to declare on earlier releases. -->
|
Harmless to declare on earlier releases. -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
<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.BackHandler
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
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.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
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.GamepadFeedback
|
||||||
import io.unom.punktfunk.kit.Keymap
|
import io.unom.punktfunk.kit.Keymap
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
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 kotlin.math.abs
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -139,6 +148,7 @@ private fun App() {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ConnectScreen(onConnected: (Long) -> Unit) {
|
private fun ConnectScreen(onConnected: (Long) -> Unit) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
var host by remember { mutableStateOf("") }
|
var host by remember { mutableStateOf("") }
|
||||||
var port by remember { mutableStateOf("9777") }
|
var port by remember { mutableStateOf("9777") }
|
||||||
var connecting by remember { mutableStateOf(false) }
|
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 abi = remember { runCatching { NativeBridge.abiVersion() }.getOrDefault(-1) }
|
||||||
val (w, h, hz) = REQUEST_MODE
|
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(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -154,6 +195,24 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) {
|
|||||||
Text("punktfunk", style = MaterialTheme.typography.headlineMedium)
|
Text("punktfunk", style = MaterialTheme.typography.headlineMedium)
|
||||||
Text("Android client", style = MaterialTheme.typography.bodyMedium)
|
Text("Android client", style = MaterialTheme.typography.bodyMedium)
|
||||||
Spacer(Modifier.height(24.dp))
|
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(
|
OutlinedTextField(
|
||||||
value = host,
|
value = host,
|
||||||
onValueChange = { host = it },
|
onValueChange = { host = it },
|
||||||
@@ -171,21 +230,7 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) {
|
|||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Button(
|
Button(
|
||||||
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
|
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
|
||||||
onClick = {
|
onClick = { connect(host.trim(), port.toInt()) },
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { Text(if (connecting) "Connecting…" else "Connect ($w×$h@$hz)") }
|
) { Text(if (connecting) "Connecting…" else "Connect ($w×$h@$hz)") }
|
||||||
status?.let {
|
status?.let {
|
||||||
Spacer(Modifier.height(12.dp))
|
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
|
@Composable
|
||||||
private fun StreamScreen(handle: Long, onDisconnect: () -> Unit) {
|
private fun StreamScreen(handle: Long, onDisconnect: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ android {
|
|||||||
|
|
||||||
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
|
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/<abi>/ so the
|
// cargo-ndk: cross-compile crates/punktfunk-android into this module's jniLibs/<abi>/ so the
|
||||||
// resulting libpunktfunk_android.so is packaged into the app (and any AAR this module produces).
|
// resulting libpunktfunk_android.so is packaged into the app (and any AAR this module produces).
|
||||||
|
|||||||
@@ -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<String, ByteArray?>` (a null/empty value = present-but-
|
||||||
|
* empty key). Decode UTF-8; missing keys are null, never an error.
|
||||||
|
*/
|
||||||
|
fun parseTxt(attrs: Map<String, ByteArray?>): 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<DiscoveredHost>) -> Unit)? = null
|
||||||
|
|
||||||
|
private val resolved = LinkedHashMap<String, DiscoveredHost>() // key -> host
|
||||||
|
private var multicastLock: WifiManager.MulticastLock? = null
|
||||||
|
private var discoveryListener: NsdManager.DiscoveryListener? = null
|
||||||
|
private val infoCallbacks = mutableListOf<NsdManager.ServiceInfoCallback>() // 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user