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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user