feat(android): native mDNS discovery, host naming, touch mouse, stock selects
apple / swift (push) Successful in 1m1s
android / android (push) Successful in 4m14s
ci / web (push) Successful in 39s
ci / docs-site (push) Successful in 54s
windows-host / package (push) Successful in 5m45s
ci / rust (push) Successful in 6m1s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m11s
release / apple (push) Successful in 7m45s
deb / build-publish (push) Successful in 2m40s
decky / build-publish (push) Successful in 11s
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 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m9s
ci / bench (push) Successful in 4m43s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m56s
apple / screenshots (push) Successful in 5m22s
flatpak / build-publish (push) Successful in 6m32s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m32s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m47s
audit / cargo-audit (push) Failing after 1m13s

Discovery: replace the flaky per-OEM NsdManager with the same mdns-sd browse
the Linux/Windows clients use, in the Rust core over JNI and polled by Kotlin
(discovery.rs + nativeDiscovery{Start,Poll,Stop}); Kotlin keeps only the Wi-Fi
MulticastLock + permission UX. IPv4-only (the core can't dial a bare/scoped v6
literal); daemon + fold-thread cleanup on every failure path; field
sanitization so a rogue advert can't corrupt the picker snapshot. Discovery
now starts regardless of NEARBY_WIFI_DEVICES (raw multicast only needs the
MulticastLock) — a denial no longer kills it forever. ParseTxtTest replaced by
ParseRecordTest.

Hosts: hide already-saved hosts from the "Discovered" section (match by
fingerprint, else address:port — mirrors the Apple client); add an optional
Name field to the Add-host sheet and a Rename action on saved cards.

Input: touch -> absolute mouse "direct pointing" like the Apple client — the
host cursor follows the finger (new nativeSendPointerAbs -> MouseMoveAbs). Tap
= left click, two-finger tap = right click, two-finger drag = scroll,
tap-then-drag = left-drag, three-finger tap = HUD toggle.

Settings: revert the dropdowns to the stock ExposedDropdownMenuBox look (a
controller-focus UI will come separately); even out the Add-host field gaps.

Docs updated (CLAUDE.md, client READMEs, docs-site status).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 23:48:45 +02:00
parent de232ec2f7
commit 095540efc2
18 changed files with 782 additions and 306 deletions
+5 -4
View File
@@ -11,8 +11,8 @@ machine, trust logic) instead of re-porting it into Kotlin.
| Side | Owns |
|------|------|
| **Rust** (`clients/android/native``libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing |
| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, `NsdManager` discovery, Keystore identity, permissions |
| **Rust** (`clients/android/native``libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing, **mDNS discovery** (`mdns-sd`, the same browse the Linux/Windows clients use) |
| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, the Wi-Fi `MulticastLock` + permission UX, Keystore identity, permissions |
The single seam is `io.unom.punktfunk.kit.NativeBridge``Java_io_unom_punktfunk_kit_NativeBridge_*`.
@@ -30,7 +30,7 @@ clients/android/native/ Rust cdylib (workspace member) — links punktf
clients/android/ Gradle project (this dir)
settings.gradle.kts · build.gradle.kts · gradle.properties · gradlew
app/ :app — Compose UI: Connect / Settings / Stream screens (phone + TV)
kit/ :kit — NativeBridge · discovery (NsdManager) · Gamepad · Keymap ·
kit/ :kit — NativeBridge · discovery (native mdns-sd, polled) · Gamepad · Keymap ·
security (Keystore identity + known-host store) · cargo-ndk build
```
@@ -74,7 +74,8 @@ streaming experience:
- **Audio** — Opus + Oboe playback with a jitter ring, plus mic uplink to the host.
- **Input** — game controllers (buttons + axes) with rumble and HID feedback; D-pad /
game-controller focus navigation for the couch (TV + phone).
- **Discovery & trust** — `NsdManager` mDNS host list, SPAKE2 PIN pairing and TOFU, with a
- **Discovery & trust** — native `mdns-sd` mDNS host list (polled over JNI; the same browse the
Linux/Windows clients use, not `NsdManager`), SPAKE2 PIN pairing and TOFU, with a
Keystore-wrapped client identity and a known-host store.
- **UI** — Compose host list / settings / stream screens, Material You theming.
- **Shipping** — built for `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing).
@@ -4,11 +4,13 @@
<!-- punktfunk/1 QUIC/UDP data plane. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- mDNS discovery of _punktfunk._udp on the LAN (NsdManager). -->
<!-- mDNS discovery of _punktfunk._udp on the LAN (native mdns-sd browse). Requested
opportunistically — raw multicast reception needs only the MulticastLock, not this. -->
<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). -->
<!-- HostDiscovery holds a MulticastLock while the native mDNS browse runs — raw multicast
reception needs it (also an 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.
@@ -84,30 +84,33 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var host by remember { mutableStateOf("") }
var hostName by remember { mutableStateOf("") }
var port by remember { mutableStateOf("9777") }
var connecting by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(null) }
// The host streams at exactly this mode; "Native" settings resolve from the device display.
val (w, h, hz) = settings.effectiveMode(context)
// 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.)
// NsdManager discovery needs NEARBY_WIFI_DEVICES on Android 13+ (a runtime permission) — without
// it discoverServices silently finds nothing. Request it once, then (re)start discovery on grant.
// mDNS discovery scoped to this screen, via the native mdns-sd browse (HostDiscovery) — its
// onChange fires on the main thread, so it can set Compose state directly. (Emulator SLIRP drops
// multicast → empty; that's the network, not the API.) Raw multicast reception only needs the
// Wi-Fi MulticastLock (HostDiscovery holds it), NOT NEARBY_WIFI_DEVICES — that gated the old
// NsdManager path. We still request NEARBY_WIFI_DEVICES opportunistically (some OEMs filter
// multicast without it; harmless where it isn't), but never block discovery on the grant — a
// denial used to leave discovery dead forever.
val discovery = remember { HostDiscovery(context) }
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
var nearbyGranted by remember { mutableStateOf(hasNearbyPermission(context)) }
val nearbyLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> nearbyGranted = granted }
) { _ -> /* best-effort hint; discovery runs regardless of the result */ }
LaunchedEffect(Unit) {
if (!nearbyGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNearbyPermission(context)) {
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
}
}
DisposableEffect(nearbyGranted) {
DisposableEffect(Unit) {
discovery.onChange = { discovered = it }
if (nearbyGranted) discovery.start()
discovery.start()
onDispose {
discovery.onChange = null
discovery.stop()
@@ -127,6 +130,13 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
}
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
// A saved host whose label is being edited (the Rename dialog).
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
@@ -176,10 +186,17 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
// keyed by address:port, so a discovered and a manually-typed connection to the same host share
// one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a
// pair=required host, or a manual/unknown-policy host, must pair by PIN.
fun connect(targetHost: String, targetPort: Int, dh: DiscoveredHost? = null) {
fun connect(
targetHost: String,
targetPort: Int,
dh: DiscoveredHost? = null,
manualName: String? = null,
) {
val known = knownHostStore.get(targetHost, targetPort)
val adv = dh?.fingerprint?.lowercase()
val name = dh?.name ?: targetHost
// Label precedence: a saved host keeps its (possibly user-renamed) name; else the discovered
// mDNS name; else the name typed in the Add-host sheet; else the bare address.
val name = known?.name ?: dh?.name ?: manualName?.trim()?.takeIf { it.isNotEmpty() } ?: targetHost
when {
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
known != null && (adv == null || adv == known.fpHex) ->
@@ -260,7 +277,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
}
}
if (savedHosts.isEmpty() && discovered.isEmpty()) {
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
EmptyHostsState()
}
@@ -281,16 +298,17 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
knownHostStore.remove(kh.address, kh.port)
savedHosts = knownHostStore.all()
},
onRename = { renameTarget = kh },
)
}
}
if (discovered.isNotEmpty()) {
if (discoveredUnsaved.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(12.dp))
SectionLabel("Discovered on the network")
}
items(discovered, key = { "disc-${it.host}-${it.port}" }) { dh ->
items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh ->
HostCard(
name = dh.name,
address = "${dh.host}:${dh.port}",
@@ -302,9 +320,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
}
}
// Active-discovery hint: when we're scanning but nothing's turned up yet, show it's
// working rather than looking idle/empty.
if (nearbyGranted && discovered.isEmpty()) {
// Active-discovery hint: discovery runs whenever this screen is up, so while it's
// scanning but nothing's turned up yet (and we're not mid-connect), show it's working
// rather than looking idle/empty.
if (!connecting && discovered.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
@@ -363,6 +382,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(20.dp))
OutlinedTextField(
value = hostName,
onValueChange = { hostName = it },
label = { Text("Name (optional)") },
placeholder = { Text("e.g. Living Room") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = host,
onValueChange = { host = it },
@@ -370,7 +398,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = port,
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
@@ -385,9 +413,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
onClick = {
val h = host.trim()
val p = port.toIntOrNull() ?: 9777
val n = hostName
scope.launch { sheetState.hide() }.invokeOnCompletion {
showManualSheet = false
connect(h, p)
connect(h, p, manualName = n)
}
},
modifier = Modifier.fillMaxWidth(),
@@ -507,10 +536,57 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
}
}
}
// Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
// friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
renameTarget?.let { kh ->
var newName by remember(kh) { mutableStateOf(kh.name) }
AlertDialog(
onDismissRequest = { renameTarget = null },
title = { Text("Rename host") },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text("Name") },
placeholder = { Text(kh.address) },
singleLine = true,
)
},
confirmButton = {
TextButton(
enabled = newName.isNotBlank(),
onClick = {
knownHostStore.rename(kh.address, kh.port, newName.trim())
savedHosts = knownHostStore.all()
renameTarget = null
},
) { Text("Save") }
},
dismissButton = {
TextButton(onClick = { renameTarget = null }) { Text("Cancel") }
},
)
}
}
/** NsdManager discovery needs NEARBY_WIFI_DEVICES on API 33+; below that it doesn't apply. */
/**
* Whether NEARBY_WIFI_DEVICES is held (API 33+; not applicable below). We request it opportunistically
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
* the native core + MulticastLock) does not depend on it.
*/
fun hasNearbyPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
PackageManager.PERMISSION_GRANTED
/**
* True when a saved host and a discovered advert are the same machine — matched by certificate
* fingerprint when both carry it (so it survives a DHCP address change), else by address:port.
* Mirrors the Apple client's `StoredHost.matches`; de-dupes "Discovered" against "Saved hosts".
*/
private fun KnownHost.matches(dh: DiscoveredHost): Boolean {
val advFp = dh.fingerprint?.lowercase()
if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true
return address == dh.host && port == dh.port
}
@@ -5,9 +5,7 @@ import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
@@ -16,14 +14,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -33,7 +31,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -174,12 +171,8 @@ private fun ToggleRow(
}
}
/**
* A labelled value that opens a menu on click. Uses a clickable [Surface] + [DropdownMenu] rather
* than `ExposedDropdownMenuBox` — that component's read-only text field traps D-pad / controller
* focus (directional keys never leave it), so you can't navigate past it on a TV. Calls [onSelect]
* on a pick. A primary-colour border marks D-pad focus.
*/
/** A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> SettingDropdown(
label: String,
@@ -188,35 +181,20 @@ private fun <T> SettingDropdown(
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
var focused by remember { mutableStateOf(false) }
val selectedLabel = options.firstOrNull { it.first == selected }?.second
?: options.firstOrNull()?.second.orEmpty()
Box(modifier = Modifier.fillMaxWidth()) {
Surface(
onClick = { expanded = true },
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant,
border = if (focused) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = selectedLabel,
onValueChange = {},
readOnly = true,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focused = it.isFocused },
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(selectedLabel, style = MaterialTheme.typography.bodyLarge)
}
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
}
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { (value, lbl) ->
DropdownMenuItem(
text = { Text(lbl) },
@@ -26,7 +26,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
@@ -44,6 +43,13 @@ import kotlinx.coroutines.delay
import kotlin.math.abs
import kotlin.math.roundToInt
// Touch-gesture tuning (px / ms). TAP_SLOP: movement under this still counts as a tap, not a drag.
// TAP_DRAG_MS: a new touch within this long after a tap starts a left-button drag. SCROLL_DIV: px of
// two-finger pan per wheel notch (smaller = faster scroll).
private const val TAP_SLOP = 12f
private const val TAP_DRAG_MS = 250L
private const val SCROLL_DIV = 4f
@Composable
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
val context = LocalContext.current
@@ -139,41 +145,108 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
if (showStats) {
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
}
// Touch virtual-trackpad overlay: 1-finger drag → relative mouse move; tap → left click;
// 2-finger drag → scroll; 3-finger tap → toggle the stats HUD. (Physical-mouse pointer
// capture comes in a later increment.)
// Touch → mouse, absolute "direct pointing" like the Apple client: the host cursor follows
// your finger (MouseMoveAbs, host-normalized against the overlay size — which fills the video,
// so finger position maps straight onto the remote screen). Gestures: tap = left click;
// two-finger tap = right click; two-finger drag = scroll; tap-then-press-and-drag = left-drag
// (text selection / moving windows); three-finger tap = toggle the stats HUD.
Box(
Modifier.fillMaxSize().pointerInput(handle) {
var lastTapUp = 0L
var lastTapX = 0f
var lastTapY = 0f
fun moveAbs(x: Float, y: Float) {
val sw = size.width
val sh = size.height
if (sw <= 0 || sh <= 0) return
NativeBridge.nativeSendPointerAbs(
handle,
x.coerceIn(0f, (sw - 1).toFloat()).roundToInt(),
y.coerceIn(0f, (sh - 1).toFloat()).roundToInt(),
sw,
sh,
)
}
awaitEachGesture {
val first = awaitFirstDown(requireUnconsumed = false)
val down = awaitFirstDown(requireUnconsumed = false)
val startX = down.position.x
val startY = down.position.y
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
// button for this whole gesture (laptop-trackpad convention).
val isDrag = down.uptimeMillis - lastTapUp < TAP_DRAG_MS &&
abs(startX - lastTapX) < TAP_SLOP && abs(startY - lastTapY) < TAP_SLOP
lastTapUp = 0L // consume the arming either way
moveAbs(startX, startY) // cursor jumps to the finger immediately
if (isDrag) NativeBridge.nativeSendPointerButton(handle, 1, true)
var moved = false
var maxFingers = 1
var scrolling = false
var prevCx = startX
var prevCy = startY
var upTime = down.uptimeMillis
while (true) {
val ev = awaitPointerEvent()
val fingers = ev.changes.count { it.pressed }
if (fingers == 0) break
if (fingers > maxFingers) maxFingers = fingers
val primary = ev.changes.firstOrNull { it.id == first.id } ?: ev.changes.first()
val d = primary.positionChange()
if (abs(d.x) > 0.5f || abs(d.y) > 0.5f) {
moved = true
if (fingers >= 2) {
// screen +y down → wire +up, so negate y. Coarse divisor; tune live.
val sy = (-d.y / 4f).toInt()
val sx = (d.x / 4f).toInt()
if (sy != 0) NativeBridge.nativeSendScroll(handle, 0, sy * 120)
if (sx != 0) NativeBridge.nativeSendScroll(handle, 1, sx * 120)
} else {
NativeBridge.nativeSendPointerMove(handle, d.x.toInt(), d.y.toInt())
val pressed = ev.changes.filter { it.pressed }
if (pressed.isEmpty()) {
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
break
}
if (pressed.size > maxFingers) maxFingers = pressed.size
if (pressed.size >= 2) {
// Two fingers → scroll by the centroid delta; never move the cursor.
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
if (!scrolling) {
scrolling = true
prevCx = cx
prevCy = cy
}
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
if (sy != 0) {
NativeBridge.nativeSendScroll(handle, 0, sy * 120)
prevCy = cy
moved = true
}
if (sx != 0) {
NativeBridge.nativeSendScroll(handle, 1, sx * 120)
prevCx = cx
moved = true
}
} else if (!scrolling) {
// One finger → the cursor follows it (skipped once a gesture turned into
// a scroll, so dropping back to one finger doesn't jerk the cursor).
val p = pressed.firstOrNull { it.id == down.id } ?: pressed.first()
if (abs(p.position.x - startX) > TAP_SLOP ||
abs(p.position.y - startY) > TAP_SLOP
) {
moved = true
}
moveAbs(p.position.x, p.position.y)
}
ev.changes.forEach { it.consume() }
}
if (!moved && maxFingers == 1) {
NativeBridge.nativeSendPointerButton(handle, 1, true)
NativeBridge.nativeSendPointerButton(handle, 1, false)
} else if (!moved && maxFingers >= 3) {
showStats = !showStats // quick in-stream HUD toggle
if (isDrag) {
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
} else if (!moved) {
when {
maxFingers >= 3 -> showStats = !showStats // in-stream HUD toggle
maxFingers == 2 -> { // two-finger tap → right click
NativeBridge.nativeSendPointerButton(handle, 3, true)
NativeBridge.nativeSendPointerButton(handle, 3, false)
}
else -> { // tap → left click, and arm tap-and-drag
NativeBridge.nativeSendPointerButton(handle, 1, true)
NativeBridge.nativeSendPointerButton(handle, 1, false)
lastTapUp = upTime
lastTapX = startX
lastTapY = startY
}
}
}
}
},
@@ -49,7 +49,7 @@ fun SectionLabel(text: String) {
/**
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
* saved hosts) an overflow menu with Forget. Tapping the card connects.
* saved hosts) an overflow menu with Rename / Forget. Tapping the card connects.
*/
@Composable
fun HostCard(
@@ -59,6 +59,7 @@ fun HostCard(
enabled: Boolean,
onConnect: () -> Unit,
onForget: (() -> Unit)?,
onRename: (() -> Unit)? = null,
) {
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
@@ -106,7 +107,7 @@ fun HostCard(
StatusPill(status)
}
if (onForget != null) {
if (onForget != null || onRename != null) {
var menu by remember { mutableStateOf(false) }
Box(modifier = Modifier.align(Alignment.TopEnd)) {
IconButton(enabled = enabled, onClick = { menu = true }) {
@@ -118,13 +119,24 @@ fun HostCard(
)
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(
text = { Text("Forget") },
onClick = {
menu = false
onForget()
},
)
if (onRename != null) {
DropdownMenuItem(
text = { Text("Rename") },
onClick = {
menu = false
onRename()
},
)
}
if (onForget != null) {
DropdownMenuItem(
text = { Text("Forget") },
onClick = {
menu = false
onForget()
},
)
}
}
}
}
@@ -67,6 +67,27 @@ object NativeBridge {
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
external fun nativeClose(handle: Long)
// ---- LAN discovery: mDNS browse of `_punktfunk._udp` in Rust (mdns-sd), polled by Kotlin ----
// Replaces NsdManager. The caller holds the Wi-Fi MulticastLock for the browse lifetime; raw
// multicast *reception* needs it. See io.unom.punktfunk.kit.discovery.HostDiscovery.
/**
* Start browsing `_punktfunk._udp` on the LAN. Returns an opaque discovery handle, or `0` on
* failure. Pair with exactly one [nativeDiscoveryStop]. Cheap + non-blocking (spawns the mDNS
* daemon + a fold thread).
*/
external fun nativeDiscoveryStart(): Long
/**
* The current resolved-host snapshot for [handle]: newline-joined records, each
* `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz;
* cheap (a lock + string build), safe to call on the main thread.
*/
external fun nativeDiscoveryPoll(handle: Long): String
/** Stop the browse, shut the mDNS daemon down and join its thread. No-op on `0`. */
external fun nativeDiscoveryStop(handle: Long)
/**
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
@@ -108,6 +129,13 @@ object NativeBridge {
/** Relative mouse move; dx/dy are device-pixel deltas (screen +y down). */
external fun nativeSendPointerMove(handle: Long, dx: Int, dy: Int)
/**
* Absolute mouse position — the host moves the cursor to (x, y) in a [surfaceWidth]×[surfaceHeight]
* pixel space (it normalizes against that size and maps into the output region). Touch
* "direct pointing": the cursor jumps to the finger. Parity with the Apple client's absolute touch.
*/
external fun nativeSendPointerAbs(handle: Long, x: Int, y: Int, surfaceWidth: Int, surfaceHeight: Int)
/** One mouse-button transition. button: 1=left 2=middle 3=right 4=X1 5=X2. */
external fun nativeSendPointerButton(handle: Long, button: Int, down: Boolean)
@@ -1,17 +1,13 @@
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.os.Handler
import android.os.Looper
import android.util.Log
import io.unom.punktfunk.kit.NativeBridge
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"
private const val TAG = "PunktfunkMdns"
/** One resolved host fit for the picker. [key] is the stable dedup id. */
data class DiscoveredHost(
@@ -23,165 +19,115 @@ data class DiscoveredHost(
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
}
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
private const val FIELD_SEP = '\u001F'
/**
* 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.
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair`), or null
* if it's malformed. Pure — unit-tested without Android (see ParseRecordTest). The native side
* already applied the protocol gate and address selection, so this is just field marshaling.
*/
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"))
fun parseHostRecord(record: String): DiscoveredHost? {
val f = record.split(FIELD_SEP)
if (f.size < 6) return null
val addr = f[2]
val port = f[3].toIntOrNull() ?: return null
if (addr.isBlank() || port !in 1..65535) return null
return DiscoveredHost(
key = f[0].ifBlank { "$addr:$port" },
name = f[1].ifBlank { addr },
host = addr,
port = port,
fingerprint = f[4].ifBlank { null },
pairingRequired = f[5] == "required",
)
}
/**
* Browses `_punktfunk._udp` via NsdManager, resolves each service (the reliable
* `registerServiceInfoCallback` path on API 34+, legacy `resolveService` on 3133 where its TXT is
* often empty), and pushes the live host set to [onChange] (invoked on the main thread).
* Browses `_punktfunk._udp` for punktfunk/1 hosts via the native `mdns-sd` core (the same browse the
* Linux/Windows clients use), exposed over JNI — *not* `NsdManager`, whose per-OEM system daemon
* made discovery "mostly broken". [start] spins up the native browse and polls it ~1 Hz on the main
* thread, pushing the live host set to [onChange] (also on the main thread, only when it changes);
* [stop] tears it down.
*
* 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.
* We hold a Wi-Fi [WifiManager.MulticastLock] for the browse lifetime — raw multicast *reception*
* needs it. (The Android emulator's SLIRP NAT drops multicast, so on the emulator discovery starts
* but never finds a LAN host — same as before; that's the network, not the API.)
*/
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 val handler = Handler(Looper.getMainLooper())
private var multicastLock: WifiManager.MulticastLock? = null
private var discoveryListener: NsdManager.DiscoveryListener? = null
private val infoCallbacks = mutableListOf<NsdManager.ServiceInfoCallback>() // API 34+ registrations
private var nativeHandle = 0L
private var running = false
private var last: List<DiscoveredHost> = emptyList()
@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()
private val poll = object : Runnable {
override fun run() {
if (!running) return
val hosts = snapshot()
if (hosts != last) {
last = hosts
onChange?.invoke(hosts)
}
handler.postDelayed(this, POLL_MS)
}
}
@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) }
fun start() {
if (running) return
acquireMulticastLock()
val h = runCatching { NativeBridge.nativeDiscoveryStart() }
.onFailure { Log.e(TAG, "nativeDiscoveryStart threw", it) }
.getOrDefault(0L)
if (h == 0L) {
Log.e(TAG, "native mDNS discovery failed to start")
releaseMulticastLock()
return
}
infoCallbacks.clear()
nativeHandle = h
running = true
last = emptyList()
handler.post(poll)
}
fun stop() {
if (!running && nativeHandle == 0L) return
running = false
handler.removeCallbacks(poll)
val h = nativeHandle
nativeHandle = 0L
if (h != 0L) runCatching { NativeBridge.nativeDiscoveryStop(h) }
.onFailure { Log.e(TAG, "nativeDiscoveryStop threw", it) }
releaseMulticastLock()
resolved.clear()
last = emptyList()
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 snapshot(): List<DiscoveredHost> {
val h = nativeHandle
if (h == 0L) return emptyList()
// getOrNull (not getOrDefault): the JNI returns a platform String!, so a (near-impossible)
// native null is a *success* value here — coalesce it so the main-thread poll can't NPE.
val blob = runCatching { NativeBridge.nativeDiscoveryPoll(h) }
.onFailure { Log.e(TAG, "nativeDiscoveryPoll threw", it) }
.getOrNull() ?: ""
if (blob.isEmpty()) return emptyList()
return blob.split('\n')
.filter { it.isNotBlank() }
.mapNotNull { parseHostRecord(it) }
.associateBy { it.key } // dedup by stable key (id, or addr:port)
.values
.sortedBy { it.name.lowercase() }
}
private fun acquireMulticastLock() {
val wifi = appCtx.getSystemService(Context.WIFI_SERVICE) as WifiManager
multicastLock = wifi.createMulticastLock("punktfunk-nsd").apply {
multicastLock = wifi.createMulticastLock("punktfunk-mdns").apply {
setReferenceCounted(true)
runCatching { acquire() }
}
@@ -191,4 +137,8 @@ class HostDiscovery(context: Context) {
multicastLock?.takeIf { it.isHeld }?.let { runCatching { it.release() } }
multicastLock = null
}
private companion object {
const val POLL_MS = 1000L
}
}
@@ -50,6 +50,12 @@ class KnownHostStore(context: Context) {
prefs.edit().remove(key(address, port)).apply()
}
/** Set a saved host's display name, keeping its pin + paired flag. No-op if not saved. */
fun rename(address: String, port: Int, newName: String) {
val h = get(address, port) ?: return
save(h.copy(name = newName))
}
/** All trusted hosts, name-sorted — backs the saved-hosts list. */
fun all(): List<KnownHost> =
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
@@ -0,0 +1,62 @@
package io.unom.punktfunk.kit.discovery
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure JVM test of the native-record parser (`key␟name␟addr␟port␟fp␟pair`), the Kotlin half of the
* discovery JNI seam. No Android types. Run: `./gradlew :kit:testDebugUnitTest`.
*/
class ParseRecordTest {
private val s = '\u001F' // field separator (must match the Rust side, discovery.rs FIELD_SEP)
private fun rec(vararg f: String) = f.joinToString(s.toString())
@Test
fun parsesFullRecord() {
val fp = "a".repeat(64)
val h = parseHostRecord(rec("host-123", "home-worker-2", "192.168.1.70", "9777", fp, "required"))!!
assertEquals("host-123", h.key)
assertEquals("home-worker-2", h.name)
assertEquals("192.168.1.70", h.host)
assertEquals(9777, h.port)
assertEquals(fp, h.fingerprint)
assertTrue(h.pairingRequired)
}
@Test
fun optionalPairingAndEmptyFingerprint() {
val h = parseHostRecord(rec("id", "name", "10.0.0.5", "9777", "", "optional"))!!
assertNull(h.fingerprint)
assertEquals(false, h.pairingRequired)
}
@Test
fun emptyKeyFallsBackToAddrPort() {
// Host advertised no `id` TXT → the native side leaves the key blank; we synthesize addr:port.
val h = parseHostRecord(rec("", "name", "10.0.0.5", "9777", "", "required"))!!
assertEquals("10.0.0.5:9777", h.key)
}
@Test
fun emptyNameFallsBackToAddr() {
val h = parseHostRecord(rec("k", "", "10.0.0.5", "9777", "", "optional"))!!
assertEquals("10.0.0.5", h.name)
}
@Test
fun rejectsTooFewFields() {
assertNull(parseHostRecord("only${'\u001F'}three${'\u001F'}fields"))
assertNull(parseHostRecord(""))
}
@Test
fun rejectsBadPortOrAddress() {
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "notaport", "", "required")))
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "0", "", "required")))
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "70000", "", "required")))
assertNull(parseHostRecord(rec("k", "n", "", "9777", "", "required")))
}
}
@@ -1,63 +0,0 @@
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)
}
}
+6
View File
@@ -19,6 +19,12 @@ crate-type = ["cdylib"]
punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
jni = "0.21"
log = "0.4"
# LAN host discovery: browse the host's `_punktfunk._udp` mDNS advert — the SAME crate + service the
# Linux/Windows clients use (`clients/linux/src/discovery.rs`), replacing Android's per-OEM
# `NsdManager` system daemon with one tested browse path. Pure Rust (socket2/if-addrs/mio), so it
# cross-compiles to the Android targets AND builds on the host (the JNI seam links into
# `cargo build --workspace`). Kotlin keeps only the Wi-Fi `MulticastLock` + permission UX.
mdns-sd = "0.20"
# Android-only deps. Gated so `cargo build --workspace` on the Linux/macOS dev boxes + CI still
# compiles this crate (as a host cdylib) — the Android-framework glue (logging now; AMediaCodec via
+303
View File
@@ -0,0 +1,303 @@
//! LAN host discovery over mDNS, in Rust via `mdns-sd` — the same crate + service type the
//! Linux/Windows clients use (`clients/linux/src/discovery.rs`), exposed to Kotlin over JNI.
//!
//! Why not `NsdManager`: that API delegates to a per-OEM system mDNS daemon whose reliability
//! varies wildly (the Android client's discovery was "mostly broken"). Browsing in our own Rust
//! core — the crate is already linked for the whole protocol — gives one tested code path across
//! every desktop + mobile client and removes the system-daemon dependency. Kotlin still holds the
//! Wi-Fi `MulticastLock` for the browse lifetime (raw multicast *reception* needs it) and owns the
//! permission UX; this module owns the socket + resolve.
//!
//! Shape: [`Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart`] spins up a
//! [`ServiceDaemon`] browsing `_punktfunk._udp.local.` on a background thread that folds
//! resolve/remove events into a shared map; Kotlin polls `nativeDiscoveryPoll` ~1 Hz for a
//! newline-joined snapshot and calls `nativeDiscoveryStop` to tear it down. Polling (not a JVM
//! callback) mirrors `nativeVideoStats`: no `AttachCurrentThread`/global-ref lifecycle to get
//! wrong, and 1 Hz is plenty for a host picker.
use crate::session::jni_guard;
use jni::objects::JObject;
use jni::sys::jlong;
use jni::JNIEnv;
use mdns_sd::{ResolvedService, ServiceDaemon, ServiceEvent};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`).
const SERVICE_TYPE: &str = "_punktfunk._udp.local.";
/// Wire protocol id in the `proto` TXT record; a host advertising anything else is skipped.
const PROTO: &str = "punktfunk/1";
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
const FIELD_SEP: char = '\u{1f}';
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair` (`␟` = [`FIELD_SEP`]).
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from
/// every field so no value can break it.
#[derive(Clone, PartialEq)]
struct Host {
key: String,
name: String,
addr: String,
port: u16,
fp: String,
pair: String,
}
impl Host {
fn encode(&self) -> String {
// mDNS instance labels + TXT values are arbitrary UTF-8 from an UNauthenticated source, so
// strip the field/record separators: a rogue advert that smuggled '\n'/U+001F could otherwise
// inject or suppress picker rows. (Trust is still gated on connect — this only protects the
// list's integrity.)
fn clean(s: &str) -> String {
s.replace(['\n', '\r', FIELD_SEP], "")
}
format!(
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
clean(&self.key),
clean(&self.name),
clean(&self.addr),
self.port,
clean(&self.fp),
clean(&self.pair),
)
}
}
/// A running browse behind the `jlong` handle: the daemon, the shared resolved-host map keyed by
/// mDNS fullname (stable across re-announce and present on both resolve *and* remove — which fixes
/// the old `NsdManager` key mismatch that leaked stale hosts), and the event-fold thread.
struct Discovery {
daemon: ServiceDaemon,
hosts: Arc<Mutex<HashMap<String, Host>>>,
thread: Option<JoinHandle<()>>,
}
impl Discovery {
fn start() -> Option<Discovery> {
let daemon = match ServiceDaemon::new() {
Ok(d) => d,
Err(e) => {
log::error!("mDNS daemon failed — discovery disabled: {e}");
return None;
}
};
let rx = match daemon.browse(SERVICE_TYPE) {
Ok(r) => r,
Err(e) => {
log::error!("mDNS browse failed — discovery disabled: {e}");
let _ = daemon.shutdown();
return None;
}
};
let hosts: Arc<Mutex<HashMap<String, Host>>> = Arc::new(Mutex::new(HashMap::new()));
let map = hosts.clone();
let spawned = std::thread::Builder::new()
.name("pf-mdns".into())
.spawn(move || {
// Exits when the daemon is shut down (the browse channel closes → recv errors).
while let Ok(event) = rx.recv() {
match event {
ServiceEvent::ServiceResolved(info) => {
if let Some(host) = resolve(&info) {
map.lock()
.unwrap()
.insert(info.get_fullname().to_string(), host);
}
}
ServiceEvent::ServiceRemoved(_ty, fullname) => {
map.lock().unwrap().remove(&fullname);
}
_ => {}
}
}
});
let thread = match spawned {
Ok(t) => t,
Err(e) => {
// The daemon thread + bound :5353 socket outlive a dropped handle (no Drop impl), so
// shut it down explicitly — same cleanup as the browse-failure path above.
log::error!("mDNS fold thread spawn failed: {e}");
let _ = daemon.shutdown();
return None;
}
};
log::info!("native mDNS discovery started ({SERVICE_TYPE})");
Some(Discovery {
daemon,
hosts,
thread: Some(thread),
})
}
/// Current resolved-host set, newline-joined (empty string = none). Sorted for a stable order
/// across polls; Kotlin re-sorts by display name.
fn snapshot(&self) -> String {
let mut records: Vec<String> = self
.hosts
.lock()
.unwrap()
.values()
.map(Host::encode)
.collect();
records.sort();
records.join("\n")
}
fn stop(mut self) {
let _ = self.daemon.shutdown(); // closes the browse channel → the fold thread exits
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// Build a [`Host`] from a resolved mDNS record, or `None` if it isn't a usable punktfunk host
/// (incompatible advertised proto, or no IPv4 address). IPv4 only on purpose: the core dials with
/// `format!("{host}:{port}").parse::<SocketAddr>()`, which can't parse a bare/scoped IPv6 literal
/// (it needs the `[addr%scope]:port` form), so surfacing a v6-only host would present a card that
/// fails on every tap. Dropping it shows the honest "not found" instead.
fn resolve(info: &ResolvedService) -> Option<Host> {
let val = |k: &str| info.get_property_val_str(k).unwrap_or("").to_string();
let proto = val("proto");
if !proto.is_empty() && proto != PROTO {
return None; // some other DNS-SD service sharing the type — ignore
}
let addr = info
.get_addresses_v4()
.iter()
.next()
.map(|a| a.to_string())?;
let id = val("id");
let fullname = info.get_fullname();
Some(Host {
key: if id.is_empty() {
fullname.to_string()
} else {
id
},
name: fullname.split('.').next().unwrap_or("?").to_string(),
addr,
port: info.get_port(),
fp: val("fp"),
pair: val("pair"),
})
}
/// `NativeBridge.nativeDiscoveryStart(): Long` — start browsing `_punktfunk._udp`; returns an opaque
/// handle, or `0` on failure (logged). Pair with exactly one [`nativeDiscoveryStop`]. Kotlin must
/// hold the Wi-Fi `MulticastLock` for the browse lifetime.
///
/// [`nativeDiscoveryStop`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart(
_env: JNIEnv,
_this: JObject,
) -> jlong {
jni_guard(0, || match Discovery::start() {
Some(d) => Box::into_raw(Box::new(d)) as jlong,
None => 0,
})
}
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts /
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jni::sys::jstring {
jni_guard(std::ptr::null_mut(), || {
let out = if handle == 0 {
String::new()
} else {
// SAFETY: live handle per the start/stop contract — Kotlin owns the lifecycle and never
// polls after stop (it nulls the handle first).
let d = unsafe { &*(handle as *const Discovery) };
d.snapshot()
};
match env.new_string(out) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
})
}
/// `NativeBridge.nativeDiscoveryStop(handle)` — stop the browse, shut the daemon down and join its
/// thread. No-op on `0`.
///
/// # Safety contract
/// `handle` must be `0` or a live handle from [`nativeDiscoveryStart`], stopped exactly once and not
/// concurrently with [`nativeDiscoveryPoll`] (Kotlin owns this; all calls are on the main thread).
///
/// [`nativeDiscoveryStart`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart
/// [`nativeDiscoveryPoll`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) {
jni_guard((), || {
if handle != 0 {
// SAFETY: live handle from nativeDiscoveryStart, stopped exactly once per the contract.
let d = unsafe { Box::from_raw(handle as *mut Discovery) };
d.stop();
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_round_trips_all_fields_with_unit_separator() {
let h = Host {
key: "host-123".into(),
name: "home-worker-2".into(),
addr: "192.168.1.70".into(),
port: 9777,
fp: "ab".repeat(32),
pair: "required".into(),
};
let encoded = h.encode();
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
assert_eq!(fields.len(), 6);
assert_eq!(fields[0], "host-123");
assert_eq!(fields[1], "home-worker-2");
assert_eq!(fields[2], "192.168.1.70");
assert_eq!(fields[3], "9777");
assert_eq!(fields[4], "ab".repeat(32));
assert_eq!(fields[5], "required");
assert!(
!encoded.contains('\n'),
"a record must never contain the record separator"
);
}
#[test]
fn encode_strips_injected_separators_from_a_hostile_advert() {
// A rogue advert could carry framing bytes in its instance label / TXT; encode must strip
// them so the snapshot stays exactly one record of exactly six fields.
let h = Host {
key: "k\u{1f}injected".into(),
name: "evil\nhost\r".into(),
addr: "10.0.0.5".into(),
port: 9777,
fp: "ab\u{1f}cd".into(),
pair: "required\n".into(),
};
let encoded = h.encode();
assert_eq!(encoded.matches(FIELD_SEP).count(), 5, "exactly six fields");
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
assert_eq!(fields[0], "kinjected");
assert_eq!(fields[1], "evilhost");
assert_eq!(fields[4], "abcd");
assert_eq!(fields[5], "required");
}
}
+10 -3
View File
@@ -3,13 +3,17 @@
//! Architecture: the **Rust-heavy** client model (like `punktfunk-client-linux`, *not* the
//! thin-native-over-C-ABI Apple model). This `cdylib` links `punktfunk-core` directly and drives
//! the whole `punktfunk/1` protocol through [`punktfunk_core::client::NativeClient`]; Kotlin owns
//! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture,
//! `NsdManager` discovery, Keystore). The JNI seam below is the one place the two languages meet.
//! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture, the
//! Wi-Fi `MulticastLock` + permission UX, Keystore). The JNI seam below is the one place the two
//! languages meet.
//!
//! Why Rust-heavy: Kotlin cannot `import` the cbindgen C header the way Swift can, so a native
//! bridge is unavoidable. Writing it in Rust lets the Android client reuse the Linux client's
//! orchestration verbatim — audio jitter ring, the VK keymap inverse, latency/skew math, the
//! input capture state machine, trust/pairing logic — instead of re-porting it into Kotlin.
//! input capture state machine, trust/pairing logic, **mDNS discovery** ([`discovery`], the same
//! `mdns-sd` browse the Linux/Windows clients use) — instead of re-porting it into Kotlin. Kotlin
//! keeps only the Android-framework surface it must (Compose UI, `SurfaceView`, input capture, the
//! Wi-Fi `MulticastLock` + permission UX, Keystore identity).
//!
//! JNI symbols map to `io.unom.punktfunk.kit.NativeBridge` in the `:kit` Gradle module
//! (`clients/android`). The current surface is the scaffold's native-link proof
@@ -25,6 +29,9 @@ use jni::JNIEnv;
mod audio;
#[cfg(target_os = "android")]
mod decode;
// Ungated: pure `mdns-sd` + `jni`, so the browse + its JNI seam link into the host workspace build
// (and its unit test runs there) exactly like `session`/`stats`. Kotlin only ever calls it on device.
mod discovery;
mod feedback;
#[cfg(target_os = "android")]
mod mic;
+32
View File
@@ -557,6 +557,38 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
});
}
/// `NativeBridge.nativeSendPointerAbs(handle, x, y, surfaceWidth, surfaceHeight)` — absolute cursor
/// position: the host moves the pointer to `x`/`y` in a `surfaceWidth`×`surfaceHeight` pixel space,
/// normalizing against the size packed into `flags` as `(w << 16) | h` and mapping into the output
/// region (it drops the event if that size is zero). This is the touch "direct pointing" path — the
/// cursor jumps to the finger — and matches the Apple client's absolute touch forwarding.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerAbs(
_env: JNIEnv,
_this: JObject,
handle: jlong,
x: jint,
y: jint,
surface_width: jint,
surface_height: jint,
) {
if handle == 0 {
return;
}
// SAFETY: live handle per the contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let w = (surface_width.max(0) as u32) & 0xffff;
let ht = (surface_height.max(0) as u32) & 0xffff;
let _ = h.client.send_input(&InputEvent {
kind: InputKind::MouseMoveAbs,
_pad: [0; 3],
code: 0,
x,
y,
flags: (w << 16) | ht,
});
}
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
#[no_mangle]