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:
@@ -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/<abi>/ so the
|
||||
// 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