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
+3 -1
View File
@@ -199,7 +199,9 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
`punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl. `punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl.
**HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`), **HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`),
Opus/Oboe audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback Opus/Oboe audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback
(`feedback.rs`), `NsdManager` mDNS discovery, SPAKE2 PIN pairing + TOFU (Keystore identity + (`feedback.rs`), **native `mdns-sd` mDNS discovery** (`discovery.rs`, polled over JNI — the same
browse the Linux/Windows clients use, replacing the flaky per-OEM `NsdManager`; Kotlin keeps only
the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity +
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml` `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
(`ci/play-upload.py`). Next: real-device gamepad/HDR live-verify, presenter/latency polish. (`ci/play-upload.py`). Next: real-device gamepad/HDR live-verify, presenter/latency polish.
Generated
+1
View File
@@ -2547,6 +2547,7 @@ dependencies = [
"jni", "jni",
"libc", "libc",
"log", "log",
"mdns-sd",
"ndk", "ndk",
"opus", "opus",
"punktfunk-core", "punktfunk-core",
+5 -4
View File
@@ -11,8 +11,8 @@ machine, trust logic) instead of re-porting it into Kotlin.
| Side | Owns | | 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 | | **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, `NsdManager` discovery, Keystore identity, permissions | | **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_*`. 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) clients/android/ Gradle project (this dir)
settings.gradle.kts · build.gradle.kts · gradle.properties · gradlew settings.gradle.kts · build.gradle.kts · gradle.properties · gradlew
app/ :app — Compose UI: Connect / Settings / Stream screens (phone + TV) 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 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. - **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 / - **Input** — game controllers (buttons + axes) with rumble and HID feedback; D-pad /
game-controller focus navigation for the couch (TV + phone). 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. Keystore-wrapped client identity and a known-host store.
- **UI** — Compose host list / settings / stream screens, Material You theming. - **UI** — Compose host list / settings / stream screens, Material You theming.
- **Shipping** — built for `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing). - **Shipping** — built for `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing).
@@ -4,11 +4,13 @@
<!-- punktfunk/1 QUIC/UDP data plane. --> <!-- punktfunk/1 QUIC/UDP data plane. -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <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 <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). --> <!-- 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.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_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.
@@ -84,30 +84,33 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
var host by remember { mutableStateOf("") } var host by remember { mutableStateOf("") }
var hostName 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) }
var status by remember { mutableStateOf<String?>(null) } var status by remember { mutableStateOf<String?>(null) }
// The host streams at exactly this mode; "Native" settings resolve from the device display. // The host streams at exactly this mode; "Native" settings resolve from the device display.
val (w, h, hz) = settings.effectiveMode(context) val (w, h, hz) = settings.effectiveMode(context)
// mDNS discovery scoped to this screen; NsdManager callbacks arrive on the main thread, so the // mDNS discovery scoped to this screen, via the native mdns-sd browse (HostDiscovery) — its
// onChange callback can set Compose state directly. (Emulator SLIRP drops multicast → empty.) // onChange fires on the main thread, so it can set Compose state directly. (Emulator SLIRP drops
// NsdManager discovery needs NEARBY_WIFI_DEVICES on Android 13+ (a runtime permission) — without // multicast → empty; that's the network, not the API.) Raw multicast reception only needs the
// it discoverServices silently finds nothing. Request it once, then (re)start discovery on grant. // 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) } val discovery = remember { HostDiscovery(context) }
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) } var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
var nearbyGranted by remember { mutableStateOf(hasNearbyPermission(context)) }
val nearbyLauncher = rememberLauncherForActivityResult( val nearbyLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
) { granted -> nearbyGranted = granted } ) { _ -> /* best-effort hint; discovery runs regardless of the result */ }
LaunchedEffect(Unit) { 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) nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
} }
} }
DisposableEffect(nearbyGranted) { DisposableEffect(Unit) {
discovery.onChange = { discovered = it } discovery.onChange = { discovered = it }
if (nearbyGranted) discovery.start() discovery.start()
onDispose { onDispose {
discovery.onChange = null discovery.onChange = null
discovery.stop() 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). // A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) } 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), // 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 // 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 // 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 // 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. // 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 known = knownHostStore.get(targetHost, targetPort)
val adv = dh?.fingerprint?.lowercase() 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 { when {
// Known host whose advertised fp still matches the pin → silent pinned reconnect. // Known host whose advertised fp still matches the pin → silent pinned reconnect.
known != null && (adv == null || adv == known.fpHex) -> 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) }) { item(span = { GridItemSpan(maxLineSpan) }) {
EmptyHostsState() EmptyHostsState()
} }
@@ -281,16 +298,17 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
knownHostStore.remove(kh.address, kh.port) knownHostStore.remove(kh.address, kh.port)
savedHosts = knownHostStore.all() savedHosts = knownHostStore.all()
}, },
onRename = { renameTarget = kh },
) )
} }
} }
if (discovered.isNotEmpty()) { if (discoveredUnsaved.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
SectionLabel("Discovered on the network") SectionLabel("Discovered on the network")
} }
items(discovered, key = { "disc-${it.host}-${it.port}" }) { dh -> items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh ->
HostCard( HostCard(
name = dh.name, name = dh.name,
address = "${dh.host}:${dh.port}", 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 // Active-discovery hint: discovery runs whenever this screen is up, so while it's
// working rather than looking idle/empty. // scanning but nothing's turned up yet (and we're not mid-connect), show it's working
if (nearbyGranted && discovered.isEmpty()) { // rather than looking idle/empty.
if (!connecting && discovered.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
@@ -363,6 +382,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(Modifier.height(20.dp)) 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( OutlinedTextField(
value = host, value = host,
onValueChange = { host = it }, onValueChange = { host = it },
@@ -370,7 +398,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(16.dp))
OutlinedTextField( OutlinedTextField(
value = port, value = port,
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) }, onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
@@ -385,9 +413,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
onClick = { onClick = {
val h = host.trim() val h = host.trim()
val p = port.toIntOrNull() ?: 9777 val p = port.toIntOrNull() ?: 9777
val n = hostName
scope.launch { sheetState.hide() }.invokeOnCompletion { scope.launch { sheetState.hide() }.invokeOnCompletion {
showManualSheet = false showManualSheet = false
connect(h, p) connect(h, p, manualName = n)
} }
}, },
modifier = Modifier.fillMaxWidth(), 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 = fun hasNearbyPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) == ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
PackageManager.PERMISSION_GRANTED 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.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row 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.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.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.MaterialTheme
import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -33,7 +31,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -174,12 +171,8 @@ private fun ToggleRow(
} }
} }
/** /** A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */
* A labelled value that opens a menu on click. Uses a clickable [Surface] + [DropdownMenu] rather @OptIn(ExperimentalMaterial3Api::class)
* 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.
*/
@Composable @Composable
private fun <T> SettingDropdown( private fun <T> SettingDropdown(
label: String, label: String,
@@ -188,35 +181,20 @@ private fun <T> SettingDropdown(
onSelect: (T) -> Unit, onSelect: (T) -> Unit,
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var focused by remember { mutableStateOf(false) }
val selectedLabel = options.firstOrNull { it.first == selected }?.second val selectedLabel = options.firstOrNull { it.first == selected }?.second
?: options.firstOrNull()?.second.orEmpty() ?: options.firstOrNull()?.second.orEmpty()
Box(modifier = Modifier.fillMaxWidth()) { ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
Surface( OutlinedTextField(
onClick = { expanded = true }, value = selectedLabel,
shape = MaterialTheme.shapes.small, onValueChange = {},
color = MaterialTheme.colorScheme.surfaceVariant, readOnly = true,
border = if (focused) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.onFocusChanged { focused = it.isFocused }, .fillMaxWidth(),
) { )
Row( ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
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 }) {
options.forEach { (value, lbl) -> options.forEach { (value, lbl) ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(lbl) }, text = { Text(lbl) },
@@ -26,7 +26,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -44,6 +43,13 @@ import kotlinx.coroutines.delay
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt 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 @Composable
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
@@ -139,41 +145,108 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
if (showStats) { if (showStats) {
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) } stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
} }
// Touch virtual-trackpad overlay: 1-finger drag → relative mouse move; tap → left click; // Touch → mouse, absolute "direct pointing" like the Apple client: the host cursor follows
// 2-finger drag → scroll; 3-finger tap → toggle the stats HUD. (Physical-mouse pointer // your finger (MouseMoveAbs, host-normalized against the overlay size — which fills the video,
// capture comes in a later increment.) // 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( Box(
Modifier.fillMaxSize().pointerInput(handle) { 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 { 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 moved = false
var maxFingers = 1 var maxFingers = 1
var scrolling = false
var prevCx = startX
var prevCy = startY
var upTime = down.uptimeMillis
while (true) { while (true) {
val ev = awaitPointerEvent() val ev = awaitPointerEvent()
val fingers = ev.changes.count { it.pressed } val pressed = ev.changes.filter { it.pressed }
if (fingers == 0) break if (pressed.isEmpty()) {
if (fingers > maxFingers) maxFingers = fingers upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
val primary = ev.changes.firstOrNull { it.id == first.id } ?: ev.changes.first() break
val d = primary.positionChange() }
if (abs(d.x) > 0.5f || abs(d.y) > 0.5f) { if (pressed.size > maxFingers) maxFingers = pressed.size
moved = true
if (fingers >= 2) { if (pressed.size >= 2) {
// screen +y down → wire +up, so negate y. Coarse divisor; tune live. // Two fingers → scroll by the centroid delta; never move the cursor.
val sy = (-d.y / 4f).toInt() val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
val sx = (d.x / 4f).toInt() val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
if (sy != 0) NativeBridge.nativeSendScroll(handle, 0, sy * 120) if (!scrolling) {
if (sx != 0) NativeBridge.nativeSendScroll(handle, 1, sx * 120) scrolling = true
} else { prevCx = cx
NativeBridge.nativeSendPointerMove(handle, d.x.toInt(), d.y.toInt()) 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() } ev.changes.forEach { it.consume() }
} }
if (!moved && maxFingers == 1) {
NativeBridge.nativeSendPointerButton(handle, 1, true) if (isDrag) {
NativeBridge.nativeSendPointerButton(handle, 1, false) NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
} else if (!moved && maxFingers >= 3) { } else if (!moved) {
showStats = !showStats // quick in-stream HUD toggle 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 * 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 @Composable
fun HostCard( fun HostCard(
@@ -59,6 +59,7 @@ fun HostCard(
enabled: Boolean, enabled: Boolean,
onConnect: () -> Unit, onConnect: () -> Unit,
onForget: (() -> Unit)?, onForget: (() -> Unit)?,
onRename: (() -> Unit)? = null,
) { ) {
// D-pad / controller focus highlight: a clickable card is focusable, but the default state // 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. // 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) StatusPill(status)
} }
if (onForget != null) { if (onForget != null || onRename != null) {
var menu by remember { mutableStateOf(false) } var menu by remember { mutableStateOf(false) }
Box(modifier = Modifier.align(Alignment.TopEnd)) { Box(modifier = Modifier.align(Alignment.TopEnd)) {
IconButton(enabled = enabled, onClick = { menu = true }) { IconButton(enabled = enabled, onClick = { menu = true }) {
@@ -118,13 +119,24 @@ fun HostCard(
) )
} }
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem( if (onRename != null) {
text = { Text("Forget") }, DropdownMenuItem(
onClick = { text = { Text("Rename") },
menu = false onClick = {
onForget() 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`. */ /** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
external fun nativeClose(handle: Long) 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 * 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. * 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). */ /** Relative mouse move; dx/dy are device-pixel deltas (screen +y down). */
external fun nativeSendPointerMove(handle: Long, dx: Int, dy: Int) 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. */ /** One mouse-button transition. button: 1=left 2=middle 3=right 4=X1 5=X2. */
external fun nativeSendPointerButton(handle: Long, button: Int, down: Boolean) external fun nativeSendPointerButton(handle: Long, button: Int, down: Boolean)
@@ -1,17 +1,13 @@
package io.unom.punktfunk.kit.discovery package io.unom.punktfunk.kit.discovery
import android.content.Context import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
import android.os.Build import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import io.unom.punktfunk.kit.NativeBridge
private const val TAG = "PunktfunkNsd" private const val TAG = "PunktfunkMdns"
/** 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. */ /** One resolved host fit for the picker. [key] is the stable dedup id. */
data class DiscoveredHost( data class DiscoveredHost(
@@ -23,165 +19,115 @@ data class DiscoveredHost(
val pairingRequired: Boolean = false, val pairingRequired: Boolean = false,
) )
/** Parsed TXT fields. Pure — unit-testable without Android (see ParseTxtTest). */ /** Field separator the native browse uses inside one record (ASCII Unit Separator). */
data class TxtFields( private const val FIELD_SEP = '\u001F'
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- * Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair`), or null
* empty key). Decode UTF-8; missing keys are null, never an error. * 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 parseHostRecord(record: String): DiscoveredHost? {
fun s(k: String): String? = attrs[k]?.takeIf { it.isNotEmpty() }?.toString(Charsets.UTF_8) val f = record.split(FIELD_SEP)
return TxtFields(proto = s("proto"), fp = s("fp"), pair = s("pair"), id = s("id")) 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 * Browses `_punktfunk._udp` for punktfunk/1 hosts via the native `mdns-sd` core (the same browse the
* `registerServiceInfoCallback` path on API 34+, legacy `resolveService` on 3133 where its TXT is * Linux/Windows clients use), exposed over JNI — *not* `NsdManager`, whose per-OEM system daemon
* often empty), and pushes the live host set to [onChange] (invoked on the main thread). * 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 * We hold a Wi-Fi [WifiManager.MulticastLock] for the browse lifetime — raw multicast *reception*
* MulticastLock while running (an OEM Wi-Fi power-save hedge). Note: the Android emulator's SLIRP * needs it. (The Android emulator's SLIRP NAT drops multicast, so on the emulator discovery starts
* NAT drops multicast, so on the emulator discovery starts but never finds a LAN host. * but never finds a LAN host — same as before; that's the network, not the API.)
*/ */
class HostDiscovery(context: Context) { class HostDiscovery(context: Context) {
private val appCtx = context.applicationContext 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. */ /** Invoked on the main thread whenever the resolved host set changes. */
var onChange: ((List<DiscoveredHost>) -> Unit)? = null 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 multicastLock: WifiManager.MulticastLock? = null
private var discoveryListener: NsdManager.DiscoveryListener? = null private var nativeHandle = 0L
private val infoCallbacks = mutableListOf<NsdManager.ServiceInfoCallback>() // API 34+ registrations
private var running = false private var running = false
private var last: List<DiscoveredHost> = emptyList()
@Synchronized private val poll = object : Runnable {
fun start() { override fun run() {
if (running) return if (!running) return
running = true val hosts = snapshot()
acquireMulticastLock() if (hosts != last) {
val listener = makeDiscoveryListener() last = hosts
discoveryListener = listener onChange?.invoke(hosts)
runCatching { }
nsd.discoverServices(PUNKTFUNK_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) handler.postDelayed(this, POLL_MS)
}.onFailure {
Log.e(TAG, "discoverServices failed", it)
stop()
} }
} }
@Synchronized fun start() {
fun stop() { if (running) return
if (!running) return acquireMulticastLock()
running = false val h = runCatching { NativeBridge.nativeDiscoveryStart() }
discoveryListener?.let { runCatching { nsd.stopServiceDiscovery(it) } } .onFailure { Log.e(TAG, "nativeDiscoveryStart threw", it) }
discoveryListener = null .getOrDefault(0L)
if (Build.VERSION.SDK_INT >= 34) { if (h == 0L) {
for (cb in infoCallbacks) runCatching { nsd.unregisterServiceInfoCallback(cb) } 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() releaseMulticastLock()
resolved.clear() last = emptyList()
onChange?.invoke(emptyList()) onChange?.invoke(emptyList())
} }
private fun publish() { private fun snapshot(): List<DiscoveredHost> {
onChange?.invoke(resolved.values.sortedBy { it.name.lowercase() }) val h = nativeHandle
} if (h == 0L) return emptyList()
// getOrNull (not getOrDefault): the JNI returns a platform String!, so a (near-impossible)
private fun makeDiscoveryListener() = object : NsdManager.DiscoveryListener { // native null is a *success* value here — coalesce it so the main-thread poll can't NPE.
override fun onDiscoveryStarted(type: String) { val blob = runCatching { NativeBridge.nativeDiscoveryPoll(h) }
Log.d(TAG, "discovery started: $type") .onFailure { Log.e(TAG, "nativeDiscoveryPoll threw", it) }
} .getOrNull() ?: ""
override fun onDiscoveryStopped(type: String) { if (blob.isEmpty()) return emptyList()
Log.d(TAG, "discovery stopped: $type") return blob.split('\n')
} .filter { it.isNotBlank() }
override fun onStartDiscoveryFailed(type: String, code: Int) { .mapNotNull { parseHostRecord(it) }
Log.e(TAG, "start discovery failed: $code") .associateBy { it.key } // dedup by stable key (id, or addr:port)
runCatching { nsd.stopServiceDiscovery(this) } .values
} .sortedBy { it.name.lowercase() }
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() { private fun acquireMulticastLock() {
val wifi = appCtx.getSystemService(Context.WIFI_SERVICE) as WifiManager val wifi = appCtx.getSystemService(Context.WIFI_SERVICE) as WifiManager
multicastLock = wifi.createMulticastLock("punktfunk-nsd").apply { multicastLock = wifi.createMulticastLock("punktfunk-mdns").apply {
setReferenceCounted(true) setReferenceCounted(true)
runCatching { acquire() } runCatching { acquire() }
} }
@@ -191,4 +137,8 @@ class HostDiscovery(context: Context) {
multicastLock?.takeIf { it.isHeld }?.let { runCatching { it.release() } } multicastLock?.takeIf { it.isHeld }?.let { runCatching { it.release() } }
multicastLock = null 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() 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. */ /** All trusted hosts, name-sorted — backs the saved-hosts list. */
fun all(): List<KnownHost> = fun all(): List<KnownHost> =
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() } 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"] } punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
jni = "0.21" jni = "0.21"
log = "0.4" 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 # 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 # 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 //! 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 //! 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 //! the whole `punktfunk/1` protocol through [`punktfunk_core::client::NativeClient`]; Kotlin owns
//! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture, //! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture, the
//! `NsdManager` discovery, Keystore). The JNI seam below is the one place the two languages meet. //! 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 //! 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 //! 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 //! 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 //! 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 //! (`clients/android`). The current surface is the scaffold's native-link proof
@@ -25,6 +29,9 @@ use jni::JNIEnv;
mod audio; mod audio;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
mod decode; 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; mod feedback;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
mod mic; 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. /// `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. /// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
#[no_mangle] #[no_mangle]
+1 -1
View File
@@ -58,7 +58,7 @@ Linux, Windows, and Android.
| **macOS / iOS / iPadOS / tvOS** | VideoToolbox HEVC decode, GameController capture, full DualSense feedback, mDNS discovery, PIN pairing + TOFU, network speed test, latency HUD. Stage-2 presenter (`VTDecompressionSession``CAMetalLayer`, ~11 ms p50 capture→present) is built and validated on glass behind an opt-in flag, becoming the default. Ships as one universal TestFlight build / App Store listing. | | **macOS / iOS / iPadOS / tvOS** | VideoToolbox HEVC decode, GameController capture, full DualSense feedback, mDNS discovery, PIN pairing + TOFU, network speed test, latency HUD. Stage-2 presenter (`VTDecompressionSession``CAMetalLayer`, ~11 ms p50 capture→present) is built and validated on glass behind an opt-in flag, becoming the default. Ships as one universal TestFlight build / App Store listing. |
| **Linux** (`punktfunk-client`) | GTK4/libadwaita. FFmpeg decode with VAAPI → DRM-PRIME dmabuf zero-copy (Intel/AMD; software fallback on NVIDIA), PipeWire audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, PIN pairing + TOFU, speed test. Ships as Flatpak, apt, rpm, and Arch packages. | | **Linux** (`punktfunk-client`) | GTK4/libadwaita. FFmpeg decode with VAAPI → DRM-PRIME dmabuf zero-copy (Intel/AMD; software fallback on NVIDIA), PipeWire audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, PIN pairing + TOFU, speed test. Ships as Flatpak, apt, rpm, and Arch packages. |
| **Windows** (`punktfunk-client`) | WinUI 3. D3D11VA zero-copy decode, HDR10, WASAPI audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, and the full PIN/TOFU trust surface are all implemented. Ships as a signed MSIX (x86_64 + ARM64). **Stage 1 complete; D3D11VA decode, HDR present, and the GUI are pending on-glass validation on real GPU hardware.** | | **Windows** (`punktfunk-client`) | WinUI 3. D3D11VA zero-copy decode, HDR10, WASAPI audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, and the full PIN/TOFU trust surface are all implemented. Ships as a signed MSIX (x86_64 + ARM64). **Stage 1 complete; D3D11VA decode, HDR present, and the GUI are pending on-glass validation on real GPU hardware.** |
| **Android** (phone + Android TV) | Kotlin app with a Rust core over JNI. NDK `AMediaCodec` hardware HEVC decode + HDR10 (Main10/BT.2020 PQ), Opus/Oboe audio + mic, gamepad input with rumble/HID feedback, NsdManager mDNS discovery, PIN pairing + TOFU (Keystore identity), live stats HUD, and D-pad/controller focus navigation for TV. Ships to the Google Play Internal Testing track. | | **Android** (phone + Android TV) | Kotlin app with a Rust core over JNI. NDK `AMediaCodec` hardware HEVC decode + HDR10 (Main10/BT.2020 PQ), Opus/Oboe audio + mic, gamepad input with rumble/HID feedback, mDNS discovery, PIN pairing + TOFU (Keystore identity), live stats HUD, and D-pad/controller focus navigation for TV. Ships to the Google Play Internal Testing track. |
`punktfunk-probe` is a headless reference and measurement client (for testing and `punktfunk-probe` is a headless reference and measurement client (for testing and
benchmarking, not everyday use). benchmarking, not everyday use).