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:
@@ -19,6 +19,12 @@ crate-type = ["cdylib"]
|
||||
punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
|
||||
jni = "0.21"
|
||||
log = "0.4"
|
||||
# LAN host discovery: browse the host's `_punktfunk._udp` mDNS advert — the SAME crate + service the
|
||||
# Linux/Windows clients use (`clients/linux/src/discovery.rs`), replacing Android's per-OEM
|
||||
# `NsdManager` system daemon with one tested browse path. Pure Rust (socket2/if-addrs/mio), so it
|
||||
# cross-compiles to the Android targets AND builds on the host (the JNI seam links into
|
||||
# `cargo build --workspace`). Kotlin keeps only the Wi-Fi `MulticastLock` + permission UX.
|
||||
mdns-sd = "0.20"
|
||||
|
||||
# Android-only deps. Gated so `cargo build --workspace` on the Linux/macOS dev boxes + CI still
|
||||
# compiles this crate (as a host cdylib) — the Android-framework glue (logging now; AMediaCodec via
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
//! LAN host discovery over mDNS, in Rust via `mdns-sd` — the same crate + service type the
|
||||
//! Linux/Windows clients use (`clients/linux/src/discovery.rs`), exposed to Kotlin over JNI.
|
||||
//!
|
||||
//! Why not `NsdManager`: that API delegates to a per-OEM system mDNS daemon whose reliability
|
||||
//! varies wildly (the Android client's discovery was "mostly broken"). Browsing in our own Rust
|
||||
//! core — the crate is already linked for the whole protocol — gives one tested code path across
|
||||
//! every desktop + mobile client and removes the system-daemon dependency. Kotlin still holds the
|
||||
//! Wi-Fi `MulticastLock` for the browse lifetime (raw multicast *reception* needs it) and owns the
|
||||
//! permission UX; this module owns the socket + resolve.
|
||||
//!
|
||||
//! Shape: [`Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart`] spins up a
|
||||
//! [`ServiceDaemon`] browsing `_punktfunk._udp.local.` on a background thread that folds
|
||||
//! resolve/remove events into a shared map; Kotlin polls `nativeDiscoveryPoll` ~1 Hz for a
|
||||
//! newline-joined snapshot and calls `nativeDiscoveryStop` to tear it down. Polling (not a JVM
|
||||
//! callback) mirrors `nativeVideoStats`: no `AttachCurrentThread`/global-ref lifecycle to get
|
||||
//! wrong, and 1 Hz is plenty for a host picker.
|
||||
|
||||
use crate::session::jni_guard;
|
||||
use jni::objects::JObject;
|
||||
use jni::sys::jlong;
|
||||
use jni::JNIEnv;
|
||||
use mdns_sd::{ResolvedService, ServiceDaemon, ServiceEvent};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`).
|
||||
const SERVICE_TYPE: &str = "_punktfunk._udp.local.";
|
||||
/// Wire protocol id in the `proto` TXT record; a host advertising anything else is skipped.
|
||||
const PROTO: &str = "punktfunk/1";
|
||||
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
||||
const FIELD_SEP: char = '\u{1f}';
|
||||
|
||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair` (`␟` = [`FIELD_SEP`]).
|
||||
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from
|
||||
/// every field so no value can break it.
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct Host {
|
||||
key: String,
|
||||
name: String,
|
||||
addr: String,
|
||||
port: u16,
|
||||
fp: String,
|
||||
pair: String,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
fn encode(&self) -> String {
|
||||
// mDNS instance labels + TXT values are arbitrary UTF-8 from an UNauthenticated source, so
|
||||
// strip the field/record separators: a rogue advert that smuggled '\n'/U+001F could otherwise
|
||||
// inject or suppress picker rows. (Trust is still gated on connect — this only protects the
|
||||
// list's integrity.)
|
||||
fn clean(s: &str) -> String {
|
||||
s.replace(['\n', '\r', FIELD_SEP], "")
|
||||
}
|
||||
format!(
|
||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||
clean(&self.key),
|
||||
clean(&self.name),
|
||||
clean(&self.addr),
|
||||
self.port,
|
||||
clean(&self.fp),
|
||||
clean(&self.pair),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A running browse behind the `jlong` handle: the daemon, the shared resolved-host map keyed by
|
||||
/// mDNS fullname (stable across re-announce and present on both resolve *and* remove — which fixes
|
||||
/// the old `NsdManager` key mismatch that leaked stale hosts), and the event-fold thread.
|
||||
struct Discovery {
|
||||
daemon: ServiceDaemon,
|
||||
hosts: Arc<Mutex<HashMap<String, Host>>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
fn start() -> Option<Discovery> {
|
||||
let daemon = match ServiceDaemon::new() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::error!("mDNS daemon failed — discovery disabled: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let rx = match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("mDNS browse failed — discovery disabled: {e}");
|
||||
let _ = daemon.shutdown();
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let hosts: Arc<Mutex<HashMap<String, Host>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
let map = hosts.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pf-mdns".into())
|
||||
.spawn(move || {
|
||||
// Exits when the daemon is shut down (the browse channel closes → recv errors).
|
||||
while let Ok(event) = rx.recv() {
|
||||
match event {
|
||||
ServiceEvent::ServiceResolved(info) => {
|
||||
if let Some(host) = resolve(&info) {
|
||||
map.lock()
|
||||
.unwrap()
|
||||
.insert(info.get_fullname().to_string(), host);
|
||||
}
|
||||
}
|
||||
ServiceEvent::ServiceRemoved(_ty, fullname) => {
|
||||
map.lock().unwrap().remove(&fullname);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
let thread = match spawned {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
// The daemon thread + bound :5353 socket outlive a dropped handle (no Drop impl), so
|
||||
// shut it down explicitly — same cleanup as the browse-failure path above.
|
||||
log::error!("mDNS fold thread spawn failed: {e}");
|
||||
let _ = daemon.shutdown();
|
||||
return None;
|
||||
}
|
||||
};
|
||||
log::info!("native mDNS discovery started ({SERVICE_TYPE})");
|
||||
Some(Discovery {
|
||||
daemon,
|
||||
hosts,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
|
||||
/// Current resolved-host set, newline-joined (empty string = none). Sorted for a stable order
|
||||
/// across polls; Kotlin re-sorts by display name.
|
||||
fn snapshot(&self) -> String {
|
||||
let mut records: Vec<String> = self
|
||||
.hosts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.map(Host::encode)
|
||||
.collect();
|
||||
records.sort();
|
||||
records.join("\n")
|
||||
}
|
||||
|
||||
fn stop(mut self) {
|
||||
let _ = self.daemon.shutdown(); // closes the browse channel → the fold thread exits
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`Host`] from a resolved mDNS record, or `None` if it isn't a usable punktfunk host
|
||||
/// (incompatible advertised proto, or no IPv4 address). IPv4 only on purpose: the core dials with
|
||||
/// `format!("{host}:{port}").parse::<SocketAddr>()`, which can't parse a bare/scoped IPv6 literal
|
||||
/// (it needs the `[addr%scope]:port` form), so surfacing a v6-only host would present a card that
|
||||
/// fails on every tap. Dropping it shows the honest "not found" instead.
|
||||
fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||
let val = |k: &str| info.get_property_val_str(k).unwrap_or("").to_string();
|
||||
let proto = val("proto");
|
||||
if !proto.is_empty() && proto != PROTO {
|
||||
return None; // some other DNS-SD service sharing the type — ignore
|
||||
}
|
||||
let addr = info
|
||||
.get_addresses_v4()
|
||||
.iter()
|
||||
.next()
|
||||
.map(|a| a.to_string())?;
|
||||
let id = val("id");
|
||||
let fullname = info.get_fullname();
|
||||
Some(Host {
|
||||
key: if id.is_empty() {
|
||||
fullname.to_string()
|
||||
} else {
|
||||
id
|
||||
},
|
||||
name: fullname.split('.').next().unwrap_or("?").to_string(),
|
||||
addr,
|
||||
port: info.get_port(),
|
||||
fp: val("fp"),
|
||||
pair: val("pair"),
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDiscoveryStart(): Long` — start browsing `_punktfunk._udp`; returns an opaque
|
||||
/// handle, or `0` on failure (logged). Pair with exactly one [`nativeDiscoveryStop`]. Kotlin must
|
||||
/// hold the Wi-Fi `MulticastLock` for the browse lifetime.
|
||||
///
|
||||
/// [`nativeDiscoveryStop`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
) -> jlong {
|
||||
jni_guard(0, || match Discovery::start() {
|
||||
Some(d) => Box::into_raw(Box::new(d)) as jlong,
|
||||
None => 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jni::sys::jstring {
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
let out = if handle == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
// SAFETY: live handle per the start/stop contract — Kotlin owns the lifecycle and never
|
||||
// polls after stop (it nulls the handle first).
|
||||
let d = unsafe { &*(handle as *const Discovery) };
|
||||
d.snapshot()
|
||||
};
|
||||
match env.new_string(out) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDiscoveryStop(handle)` — stop the browse, shut the daemon down and join its
|
||||
/// thread. No-op on `0`.
|
||||
///
|
||||
/// # Safety contract
|
||||
/// `handle` must be `0` or a live handle from [`nativeDiscoveryStart`], stopped exactly once and not
|
||||
/// concurrently with [`nativeDiscoveryPoll`] (Kotlin owns this; all calls are on the main thread).
|
||||
///
|
||||
/// [`nativeDiscoveryStart`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart
|
||||
/// [`nativeDiscoveryPoll`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle from nativeDiscoveryStart, stopped exactly once per the contract.
|
||||
let d = unsafe { Box::from_raw(handle as *mut Discovery) };
|
||||
d.stop();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encode_round_trips_all_fields_with_unit_separator() {
|
||||
let h = Host {
|
||||
key: "host-123".into(),
|
||||
name: "home-worker-2".into(),
|
||||
addr: "192.168.1.70".into(),
|
||||
port: 9777,
|
||||
fp: "ab".repeat(32),
|
||||
pair: "required".into(),
|
||||
};
|
||||
let encoded = h.encode();
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields.len(), 6);
|
||||
assert_eq!(fields[0], "host-123");
|
||||
assert_eq!(fields[1], "home-worker-2");
|
||||
assert_eq!(fields[2], "192.168.1.70");
|
||||
assert_eq!(fields[3], "9777");
|
||||
assert_eq!(fields[4], "ab".repeat(32));
|
||||
assert_eq!(fields[5], "required");
|
||||
assert!(
|
||||
!encoded.contains('\n'),
|
||||
"a record must never contain the record separator"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_strips_injected_separators_from_a_hostile_advert() {
|
||||
// A rogue advert could carry framing bytes in its instance label / TXT; encode must strip
|
||||
// them so the snapshot stays exactly one record of exactly six fields.
|
||||
let h = Host {
|
||||
key: "k\u{1f}injected".into(),
|
||||
name: "evil\nhost\r".into(),
|
||||
addr: "10.0.0.5".into(),
|
||||
port: 9777,
|
||||
fp: "ab\u{1f}cd".into(),
|
||||
pair: "required\n".into(),
|
||||
};
|
||||
let encoded = h.encode();
|
||||
assert_eq!(encoded.matches(FIELD_SEP).count(), 5, "exactly six fields");
|
||||
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields[0], "kinjected");
|
||||
assert_eq!(fields[1], "evilhost");
|
||||
assert_eq!(fields[4], "abcd");
|
||||
assert_eq!(fields[5], "required");
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,17 @@
|
||||
//! Architecture: the **Rust-heavy** client model (like `punktfunk-client-linux`, *not* the
|
||||
//! thin-native-over-C-ABI Apple model). This `cdylib` links `punktfunk-core` directly and drives
|
||||
//! the whole `punktfunk/1` protocol through [`punktfunk_core::client::NativeClient`]; Kotlin owns
|
||||
//! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture,
|
||||
//! `NsdManager` discovery, Keystore). The JNI seam below is the one place the two languages meet.
|
||||
//! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture, the
|
||||
//! Wi-Fi `MulticastLock` + permission UX, Keystore). The JNI seam below is the one place the two
|
||||
//! languages meet.
|
||||
//!
|
||||
//! Why Rust-heavy: Kotlin cannot `import` the cbindgen C header the way Swift can, so a native
|
||||
//! bridge is unavoidable. Writing it in Rust lets the Android client reuse the Linux client's
|
||||
//! orchestration verbatim — audio jitter ring, the VK keymap inverse, latency/skew math, the
|
||||
//! input capture state machine, trust/pairing logic — instead of re-porting it into Kotlin.
|
||||
//! input capture state machine, trust/pairing logic, **mDNS discovery** ([`discovery`], the same
|
||||
//! `mdns-sd` browse the Linux/Windows clients use) — instead of re-porting it into Kotlin. Kotlin
|
||||
//! keeps only the Android-framework surface it must (Compose UI, `SurfaceView`, input capture, the
|
||||
//! Wi-Fi `MulticastLock` + permission UX, Keystore identity).
|
||||
//!
|
||||
//! JNI symbols map to `io.unom.punktfunk.kit.NativeBridge` in the `:kit` Gradle module
|
||||
//! (`clients/android`). The current surface is the scaffold's native-link proof
|
||||
@@ -25,6 +29,9 @@ use jni::JNIEnv;
|
||||
mod audio;
|
||||
#[cfg(target_os = "android")]
|
||||
mod decode;
|
||||
// Ungated: pure `mdns-sd` + `jni`, so the browse + its JNI seam link into the host workspace build
|
||||
// (and its unit test runs there) exactly like `session`/`stats`. Kotlin only ever calls it on device.
|
||||
mod discovery;
|
||||
mod feedback;
|
||||
#[cfg(target_os = "android")]
|
||||
mod mic;
|
||||
|
||||
@@ -557,6 +557,38 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
||||
});
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPointerAbs(handle, x, y, surfaceWidth, surfaceHeight)` — absolute cursor
|
||||
/// position: the host moves the pointer to `x`/`y` in a `surfaceWidth`×`surfaceHeight` pixel space,
|
||||
/// normalizing against the size packed into `flags` as `(w << 16) | h` and mapping into the output
|
||||
/// region (it drops the event if that size is zero). This is the touch "direct pointing" path — the
|
||||
/// cursor jumps to the finger — and matches the Apple client's absolute touch forwarding.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerAbs(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
x: jint,
|
||||
y: jint,
|
||||
surface_width: jint,
|
||||
surface_height: jint,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let w = (surface_width.max(0) as u32) & 0xffff;
|
||||
let ht = (surface_height.max(0) as u32) & 0xffff;
|
||||
let _ = h.client.send_input(&InputEvent {
|
||||
kind: InputKind::MouseMoveAbs,
|
||||
_pad: [0; 3],
|
||||
code: 0,
|
||||
x,
|
||||
y,
|
||||
flags: (w << 16) | ht,
|
||||
});
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
|
||||
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
|
||||
#[no_mangle]
|
||||
|
||||
Reference in New Issue
Block a user