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.
|
`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
@@ -2547,6 +2547,7 @@ dependencies = [
|
|||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
|
"mdns-sd",
|
||||||
"ndk",
|
"ndk",
|
||||||
"opus",
|
"opus",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
|
|||||||
@@ -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(
|
|
||||||
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)
|
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
}
|
|
||||||
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) {
|
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
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() }
|
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, true)
|
||||||
NativeBridge.nativeSendPointerButton(handle, 1, false)
|
NativeBridge.nativeSendPointerButton(handle, 1, false)
|
||||||
} else if (!moved && maxFingers >= 3) {
|
lastTapUp = upTime
|
||||||
showStats = !showStats // quick in-stream HUD toggle
|
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,6 +119,16 @@ fun HostCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||||
|
if (onRename != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Rename") },
|
||||||
|
onClick = {
|
||||||
|
menu = false
|
||||||
|
onRename()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (onForget != null) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Forget") },
|
text = { Text("Forget") },
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -131,6 +142,7 @@ fun HostCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A circular avatar with the host's first letter (Apple-contact style). */
|
/** A circular avatar with the host's first letter (Apple-contact style). */
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+84
-134
@@ -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 31–33 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()
|
||||||
|
|
||||||
|
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() {
|
fun start() {
|
||||||
if (running) return
|
if (running) return
|
||||||
running = true
|
|
||||||
acquireMulticastLock()
|
acquireMulticastLock()
|
||||||
val listener = makeDiscoveryListener()
|
val h = runCatching { NativeBridge.nativeDiscoveryStart() }
|
||||||
discoveryListener = listener
|
.onFailure { Log.e(TAG, "nativeDiscoveryStart threw", it) }
|
||||||
runCatching {
|
.getOrDefault(0L)
|
||||||
nsd.discoverServices(PUNKTFUNK_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
|
if (h == 0L) {
|
||||||
}.onFailure {
|
Log.e(TAG, "native mDNS discovery failed to start")
|
||||||
Log.e(TAG, "discoverServices failed", it)
|
releaseMulticastLock()
|
||||||
stop()
|
return
|
||||||
}
|
}
|
||||||
|
nativeHandle = h
|
||||||
|
running = true
|
||||||
|
last = emptyList()
|
||||||
|
handler.post(poll)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
if (!running) return
|
if (!running && nativeHandle == 0L) return
|
||||||
running = false
|
running = false
|
||||||
discoveryListener?.let { runCatching { nsd.stopServiceDiscovery(it) } }
|
handler.removeCallbacks(poll)
|
||||||
discoveryListener = null
|
val h = nativeHandle
|
||||||
if (Build.VERSION.SDK_INT >= 34) {
|
nativeHandle = 0L
|
||||||
for (cb in infoCallbacks) runCatching { nsd.unregisterServiceInfoCallback(cb) }
|
if (h != 0L) runCatching { NativeBridge.nativeDiscoveryStop(h) }
|
||||||
}
|
.onFailure { Log.e(TAG, "nativeDiscoveryStop threw", it) }
|
||||||
infoCallbacks.clear()
|
|
||||||
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() }
|
||||||
|
|||||||
+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"] }
|
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
|
||||||
|
|||||||
@@ -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
|
//! 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;
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user