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
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:
@@ -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.
|
||||
**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
|
||||
(`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
|
||||
`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.
|
||||
|
||||
Generated
+1
@@ -2547,6 +2547,7 @@ dependencies = [
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
"mdns-sd",
|
||||
"ndk",
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
|
||||
@@ -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,
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
Text(selectedLabel, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
|
||||
}
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
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) {
|
||||
|
||||
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)
|
||||
} else if (!moved && maxFingers >= 3) {
|
||||
showStats = !showStats // quick in-stream HUD toggle
|
||||
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,6 +119,16 @@ fun HostCard(
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
if (onRename != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
onClick = {
|
||||
menu = false
|
||||
onRename()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (onForget != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Forget") },
|
||||
onClick = {
|
||||
@@ -130,6 +141,7 @@ fun HostCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A circular avatar with the host's first letter (Apple-contact style). */
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+84
-134
@@ -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 31–33 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()
|
||||
|
||||
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 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()
|
||||
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
|
||||
}
|
||||
nativeHandle = h
|
||||
running = true
|
||||
last = emptyList()
|
||||
handler.post(poll)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
if (!running) return
|
||||
if (!running && nativeHandle == 0L) return
|
||||
running = false
|
||||
discoveryListener?.let { runCatching { nsd.stopServiceDiscovery(it) } }
|
||||
discoveryListener = null
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
for (cb in infoCallbacks) runCatching { nsd.unregisterServiceInfoCallback(cb) }
|
||||
}
|
||||
infoCallbacks.clear()
|
||||
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() }
|
||||
|
||||
+62
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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. |
|
||||
| **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.** |
|
||||
| **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
|
||||
benchmarking, not everyday use).
|
||||
|
||||
Reference in New Issue
Block a user