Files
punktfunk/clients/android/native/src/discovery.rs
T
enricobuehler 095540efc2
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
feat(android): native mDNS discovery, host naming, touch mouse, stock selects
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>
2026-06-22 23:48:45 +02:00

304 lines
12 KiB
Rust

//! 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");
}
}