diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ClipboardSync.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ClipboardSync.kt new file mode 100644 index 00000000..3a428400 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ClipboardSync.kt @@ -0,0 +1,107 @@ +package io.unom.punktfunk + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Handler +import android.os.Looper +import io.unom.punktfunk.kit.NativeBridge + +/** + * Text clipboard sync for the active session (the desktop-client model, text-only v1): + * * **Device → host**: a local copy (the primary-clip listener, plus one probe at start) is + * announced as a lazy offer — the text crosses only when the host actually pastes (a + * `fetch:` event, answered with the clipboard's current content). + * * **Host → device**: a host copy arrives as an `offer:` event and is fetched eagerly into + * the system clipboard (Android apps can't lazily materialize a paste from the network + * without a content-provider round-trip that isn't worth it here). + * + * Loop guard: text set from a host fetch is remembered ([lastFromHost]) so the resulting + * primary-clip-changed callback doesn't bounce it straight back as a new offer. Clipboard reads + * happen while the stream is foreground (Android only allows focused-app reads). The native + * events are drained on a dedicated thread and applied on the main thread; [stop] joins it. + */ +class ClipboardSync( + private val context: Context, + private val handle: Long, +) { + private val main = Handler(Looper.getMainLooper()) + private val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + @Volatile private var running = true + private var seq = 0 + private var lastOffered: String? = null + private var lastFromHost: String? = null + private var pendingFetch = -1 + private var thread: Thread? = null + + private val clipListener = ClipboardManager.OnPrimaryClipChangedListener { offerLocal() } + + fun start() { + NativeBridge.nativeClipControl(handle, true) + cm.addPrimaryClipChangedListener(clipListener) + thread = Thread({ pollLoop() }, "pf-clipboard").also { it.start() } + offerLocal() // whatever is already on the clipboard is pasteable host-side right away + } + + fun stop() { + running = false + cm.removePrimaryClipChangedListener(clipListener) + thread?.join(600) // one poll timeout (250 ms) + slack + thread = null + } + + /** Announce the current local text (if it's new and not an echo of a host copy). */ + private fun offerLocal() { + if (!running) return + val text = currentClipText() ?: return + if (text == lastOffered || text == lastFromHost) return + lastOffered = text + seq += 1 + NativeBridge.nativeClipOfferText(handle, seq) + } + + private fun currentClipText(): String? = runCatching { + cm.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0) + ?.coerceToText(context)?.toString()?.takeIf { it.isNotEmpty() } + }.getOrNull() + + private fun pollLoop() { + while (running) { + val ev = NativeBridge.nativeNextClip(handle) ?: continue + if (ev == "closed") return + main.post { handleEvent(ev) } + } + } + + private fun handleEvent(ev: String) { + if (!running) return + val parts = ev.split(":", limit = 3) + when (parts[0]) { + "offer" -> { + val offerSeq = parts.getOrNull(1)?.toIntOrNull() ?: return + if (parts.getOrNull(2) == "1") { + pendingFetch = NativeBridge.nativeClipFetchText(handle, offerSeq) + } + } + "fetch" -> { + val req = parts.getOrNull(1)?.toIntOrNull() ?: return + val text = currentClipText() + if (text != null) { + NativeBridge.nativeClipServeText(handle, req, text) + } else { + NativeBridge.nativeClipCancel(handle, req) + } + } + "data" -> { + val xfer = parts.getOrNull(1)?.toIntOrNull() ?: return + if (xfer != pendingFetch) return // stale/unknown transfer + pendingFetch = -1 + val text = parts.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: return + lastFromHost = text + runCatching { cm.setPrimaryClip(ClipData.newPlainText("Punktfunk", text)) } + } + // "state"/"cancel"/"error": nothing to drive in the text-only v1. + } + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index a8b18373..87fc4598 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -123,6 +123,13 @@ data class Settings( * the Apple/GTK clients' "Invert scroll direction". */ val invertScroll: Boolean = false, + + /** + * Sync text copied on this device to the host and vice versa while streaming (the desktop + * clients' shared clipboard, text-only here). Only effective when the host advertises the + * clipboard capability; the protocol is opt-in per session either way. + */ + val clipboardSync: Boolean = true, ) /** [Settings.touchMode] values; persisted by name. */ @@ -188,6 +195,7 @@ class SettingsStore(context: Context) { sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true), pointerCapture = prefs.getBoolean(K_POINTER_CAPTURE, false), invertScroll = prefs.getBoolean(K_INVERT_SCROLL, false), + clipboardSync = prefs.getBoolean(K_CLIPBOARD_SYNC, true), ) fun save(s: Settings) { @@ -213,6 +221,7 @@ class SettingsStore(context: Context) { .putBoolean(K_SC2_CAPTURE, s.sc2Capture) .putBoolean(K_POINTER_CAPTURE, s.pointerCapture) .putBoolean(K_INVERT_SCROLL, s.invertScroll) + .putBoolean(K_CLIPBOARD_SYNC, s.clipboardSync) .apply() } @@ -253,6 +262,7 @@ class SettingsStore(context: Context) { const val K_SC2_CAPTURE = "sc2_capture" const val K_POINTER_CAPTURE = "pointer_capture" const val K_INVERT_SCROLL = "invert_scroll" + const val K_CLIPBOARD_SYNC = "clipboard_sync" /** Legacy Boolean the enum replaced — read once as the migration default, never written. */ const val K_TRACKPAD = "trackpad_mode" diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index e9ef19b2..e4130c10 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -426,6 +426,13 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont checked = s.invertScroll, onCheckedChange = { on -> update(s.copy(invertScroll = on)) }, ) + ToggleRow( + title = "Shared clipboard", + subtitle = "Text copied here pastes on the host and vice versa (hosts with " + + "clipboard sharing enabled)", + checked = s.clipboardSync, + onCheckedChange = { on -> update(s.copy(clipboardSync = on)) }, + ) } SettingsCard { SettingDropdown( diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 9ebd870b..2cec8b19 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -270,6 +270,13 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { null } activity?.remotePointer = remote + // Shared clipboard (text v1): only when the user setting is on AND the host has a + // working clipboard service. Protocol-level opt-in + the poll thread live in the sync. + val clip = if (initialSettings.clipboardSync && NativeBridge.nativeClipSupported(handle)) { + ClipboardSync(context, handle).also { it.start() } + } else { + null + } activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate // Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad // index via the router; poll threads stopped + joined before the router is released and the @@ -335,6 +342,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { } onDispose { closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it + clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed feedback.onHidRaw = null feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index a08469b8..f62c5d6d 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -301,6 +301,36 @@ object NativeBridge { */ external fun nativeSendText(handle: Long, text: String) + // ---- Shared clipboard (text v1): Kotlin drives ClipboardManager, Rust the protocol ---- + // Opt-in per session (nativeClipControl). Local copies are announced as lazy offers; bytes + // cross only when the host pastes (a "fetch:" event answered by nativeClipServeText). Host + // copies arrive as "offer:" events, fetched eagerly into the system clipboard. + + /** Whether the host advertised a working shared-clipboard service (HOST_CAP_CLIPBOARD). */ + external fun nativeClipSupported(handle: Long): Boolean + + /** Session-level clipboard opt-in/out; nothing happens until enabled=true crosses. */ + external fun nativeClipControl(handle: Long, enabled: Boolean) + + /** Announce "this device's clipboard now holds text". [seq]: monotonic, newest wins. */ + external fun nativeClipOfferText(handle: Long, seq: Int) + + /** Pull the text of the host's offer [seq] → transfer id echoed on "data:"/"error:", or -1. */ + external fun nativeClipFetchText(handle: Long, seq: Int): Int + + /** Answer a "fetch:" event with the clipboard's current text (the host is pasting). */ + external fun nativeClipServeText(handle: Long, reqId: Int, text: String) + + /** Abort a clipboard transfer by id (either direction). */ + external fun nativeClipCancel(handle: Long, id: Int) + + /** + * Block ≤250 ms for the next clipboard event, as a compact string: `state:<0|1>` · + * `offer::` · `fetch:` · `data::` · `cancel:` · + * `error::` · `closed` (session gone) — null on timeout. Dedicated poll thread. + */ + external fun nativeNextClip(handle: Long): String? + // ---- Gamepad: each controller forwarded on its own wire pad index (0..15, low byte of flags) ---- // The pad index is assigned per Android device by GamepadRouter; a single controller lands on 0, // so its wire is byte-identical to the old single-pad path. The core folds the per-transition diff --git a/clients/android/native/src/session/clipboard.rs b/clients/android/native/src/session/clipboard.rs new file mode 100644 index 00000000..7309ac97 --- /dev/null +++ b/clients/android/native/src/session/clipboard.rs @@ -0,0 +1,182 @@ +//! Shared-clipboard plane (text-only v1): Kotlin drives the Android `ClipboardManager`, these +//! shims drive [`punktfunk_core::client::NativeClient`]'s clipboard surface. +//! +//! Model (mirrors the desktop clients): opt-in via `nativeClipControl(true)`; local copies are +//! announced lazily as format-list offers (`nativeClipOfferText`) and the bytes cross only when +//! the host pastes (a `fetch` event answered by `nativeClipServeText`); a host copy arrives as an +//! `offer` event, which the Kotlin side fetches eagerly (Android's clipboard has no lazy provider +//! path worth the complexity) and lands in the system clipboard on the `data` event. +//! +//! Events cross to Kotlin as compact strings from the blocking `nativeNextClip` poll (drained on +//! a dedicated thread, same pattern as `nativeNextRumble`): +//! `state:<0|1>` · `offer::` · `fetch:` · `data::` · +//! `cancel:` · `error::` · `closed` — null on a poll timeout. Non-text fetch +//! requests are cancelled natively (only text is ever offered, so they shouldn't occur). + +use std::time::Duration; + +use jni::objects::{JObject, JString}; +use jni::sys::{jboolean, jint, jlong, jstring}; +use jni::JNIEnv; +use punktfunk_core::clipboard::ClipEventCore; +use punktfunk_core::error::PunktfunkError; +use punktfunk_core::quic::{ClipKind, CLIP_FILE_INDEX_NONE, HOST_CAP_CLIPBOARD}; + +use super::SessionHandle; + +/// The portable wire MIME both ends map to their platform text type. +const TEXT_MIME: &str = "text/plain;charset=utf-8"; + +/// Deref the opaque handle (`0` → `None`). +/// +/// SAFETY: live handle per the nativeConnect/nativeClose contract; every method used is `&self` +/// on the `Sync` connector. +fn client(handle: jlong) -> Option<&'static SessionHandle> { + if handle == 0 { + return None; + } + // SAFETY: see the function docs — the Kotlin side guarantees the handle outlives the call. + Some(unsafe { &*(handle as *const SessionHandle) }) +} + +/// `NativeBridge.nativeClipSupported(handle)` — the host advertised `HOST_CAP_CLIPBOARD`. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupported( + _env: JNIEnv, + _this: JObject, + handle: jlong, +) -> jboolean { + client(handle).map_or(0, |h| { + u8::from(h.client.host_caps() & HOST_CAP_CLIPBOARD != 0) + }) +} + +/// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing +/// clipboard-related happens on either side until an `enabled: true` crosses. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl( + _env: JNIEnv, + _this: JObject, + handle: jlong, + enabled: jboolean, +) { + if let Some(h) = client(handle) { + let _ = h.client.clip_control(enabled != 0, 0); + } +} + +/// `NativeBridge.nativeClipOfferText(handle, seq)` — announce "the Android clipboard now holds +/// text" (format list only; bytes cross when the host fetches). `seq` is Kotlin's monotonic +/// counter, newest wins. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferText( + _env: JNIEnv, + _this: JObject, + handle: jlong, + seq: jint, +) { + if let Some(h) = client(handle) { + let _ = h.client.clip_offer( + seq as u32, + vec![ClipKind { + mime: TEXT_MIME.into(), + size_hint: 0, + }], + ); + } +} + +/// `NativeBridge.nativeClipFetchText(handle, seq)` — pull the text of the host's offer `seq`. +/// Returns the transfer id echoed on the matching `data:`/`error:` event, or −1. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchText( + _env: JNIEnv, + _this: JObject, + handle: jlong, + seq: jint, +) -> jint { + client(handle) + .and_then(|h| { + h.client + .clip_fetch(seq as u32, TEXT_MIME.into(), CLIP_FILE_INDEX_NONE) + .ok() + }) + .map_or(-1, |xfer| xfer as jint) +} + +/// `NativeBridge.nativeClipServeText(handle, reqId, text)` — answer a `fetch:` event with the +/// clipboard's current text (the host is pasting our offer). +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeText( + mut env: JNIEnv, + _this: JObject, + handle: jlong, + req_id: jint, + text: JString, +) { + let Some(h) = client(handle) else { return }; + let Ok(s) = env.get_string(&text) else { + let _ = h.client.clip_cancel(req_id as u32); + return; + }; + let _ = h + .client + .clip_serve(req_id as u32, String::from(s).into_bytes(), true); +} + +/// `NativeBridge.nativeClipCancel(handle, id)` — abort a transfer (either direction). +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel( + _env: JNIEnv, + _this: JObject, + handle: jlong, + id: jint, +) { + if let Some(h) = client(handle) { + let _ = h.client.clip_cancel(id as u32); + } +} + +/// `NativeBridge.nativeNextClip(handle)` — block ≤250 ms for the next clipboard event, encoded +/// as a compact string (module docs); null on timeout, `"closed"` once the session is gone. +/// Call from a dedicated poll thread. +/// +/// Text payloads ride `data::` decoded lossily — safe because the phase-0 +/// clipboard task delivers a whole payload in ONE event (`last = true`), so a chunk boundary +/// can never split a UTF-8 sequence. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextClip( + env: JNIEnv, + _this: JObject, + handle: jlong, +) -> jstring { + let Some(h) = client(handle) else { + return std::ptr::null_mut(); + }; + let msg = match h.client.next_clip(Duration::from_millis(250)) { + Ok(ClipEventCore::State { enabled, .. }) => format!("state:{}", u8::from(enabled)), + Ok(ClipEventCore::RemoteOffer { seq, kinds }) => { + let has_text = kinds.iter().any(|k| k.mime.starts_with("text/plain")); + format!("offer:{seq}:{}", u8::from(has_text)) + } + Ok(ClipEventCore::FetchRequest { req_id, mime, .. }) => { + if mime.starts_with("text/plain") { + format!("fetch:{req_id}") + } else { + // We only ever offer text; cancel anything else rather than stall the host. + let _ = h.client.clip_cancel(req_id); + return std::ptr::null_mut(); + } + } + Ok(ClipEventCore::Data { xfer_id, bytes, .. }) => { + format!("data:{xfer_id}:{}", String::from_utf8_lossy(&bytes)) + } + Ok(ClipEventCore::Cancelled { id }) => format!("cancel:{id}"), + Ok(ClipEventCore::Error { id, code }) => format!("error:{id}:{code}"), + Err(PunktfunkError::NoFrame) => return std::ptr::null_mut(), + Err(_) => "closed".into(), + }; + env.new_string(msg) + .map(|s| s.into_raw()) + .unwrap_or(std::ptr::null_mut()) +} diff --git a/clients/android/native/src/session/mod.rs b/clients/android/native/src/session/mod.rs index 8ca7cc5a..7445d2db 100644 --- a/clients/android/native/src/session/mod.rs +++ b/clients/android/native/src/session/mod.rs @@ -17,6 +17,7 @@ //! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode //! renegotiation. Port the remaining orchestration from `clients/linux`. +mod clipboard; mod connect; mod input; mod planes;