feat(android): shared clipboard (text) — device↔host sync while streaming
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m13s
apple / swift (push) Successful in 1m21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
decky / build-publish (push) Successful in 33s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 8m18s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6m49s
deb / build-publish (push) Successful in 10m26s
release / apple (push) Successful in 9m22s
docker / deploy-docs (push) Successful in 28s
deb / build-publish-host (push) Successful in 12m44s
arch / build-publish (push) Successful in 15m51s
windows-host / package (push) Successful in 16m14s
apple / screenshots (push) Successful in 6m37s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m39s
ci / rust (push) Successful in 24m48s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m31s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m21s
android / android (push) Successful in 11m30s
flatpak / build-publish (push) Successful in 6m16s
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m13s
apple / swift (push) Successful in 1m21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
decky / build-publish (push) Successful in 33s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 8m18s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6m49s
deb / build-publish (push) Successful in 10m26s
release / apple (push) Successful in 9m22s
docker / deploy-docs (push) Successful in 28s
deb / build-publish-host (push) Successful in 12m44s
arch / build-publish (push) Successful in 15m51s
windows-host / package (push) Successful in 16m14s
apple / screenshots (push) Successful in 6m37s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m39s
ci / rust (push) Successful in 24m48s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m31s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m21s
android / android (push) Successful in 11m30s
flatpak / build-publish (push) Successful in 6m16s
The desktop clients' clipboard protocol, adopted on Android (text-only v1): opt-in ClipControl at stream start when the host advertises HOST_CAP_CLIPBOARD and the new "Shared clipboard" setting (default on) allows. Device → host: local copies (primary-clip listener + a probe at start) are announced as lazy format-list offers; the text crosses only when the host actually pastes (FetchRequest → served from the live clipboard). Host → device: a host copy's offer is fetched eagerly and lands in the system clipboard (Android has no practical lazy-paste provider), with an echo guard so the resulting clip-changed callback doesn't bounce it back as a new offer. Native side: session/clipboard.rs JNI shims over NativeClient's clip_* surface; events cross to Kotlin as compact strings from a blocking nativeNextClip poll on a dedicated thread (the nativeNextRumble pattern), joined before the session handle is freed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,6 +123,13 @@ data class Settings(
|
|||||||
* the Apple/GTK clients' "Invert scroll direction".
|
* the Apple/GTK clients' "Invert scroll direction".
|
||||||
*/
|
*/
|
||||||
val invertScroll: Boolean = false,
|
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. */
|
/** [Settings.touchMode] values; persisted by name. */
|
||||||
@@ -188,6 +195,7 @@ class SettingsStore(context: Context) {
|
|||||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||||
pointerCapture = prefs.getBoolean(K_POINTER_CAPTURE, false),
|
pointerCapture = prefs.getBoolean(K_POINTER_CAPTURE, false),
|
||||||
invertScroll = prefs.getBoolean(K_INVERT_SCROLL, false),
|
invertScroll = prefs.getBoolean(K_INVERT_SCROLL, false),
|
||||||
|
clipboardSync = prefs.getBoolean(K_CLIPBOARD_SYNC, true),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun save(s: Settings) {
|
fun save(s: Settings) {
|
||||||
@@ -213,6 +221,7 @@ class SettingsStore(context: Context) {
|
|||||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||||
.putBoolean(K_POINTER_CAPTURE, s.pointerCapture)
|
.putBoolean(K_POINTER_CAPTURE, s.pointerCapture)
|
||||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||||
|
.putBoolean(K_CLIPBOARD_SYNC, s.clipboardSync)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +262,7 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_SC2_CAPTURE = "sc2_capture"
|
const val K_SC2_CAPTURE = "sc2_capture"
|
||||||
const val K_POINTER_CAPTURE = "pointer_capture"
|
const val K_POINTER_CAPTURE = "pointer_capture"
|
||||||
const val K_INVERT_SCROLL = "invert_scroll"
|
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. */
|
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||||
const val K_TRACKPAD = "trackpad_mode"
|
const val K_TRACKPAD = "trackpad_mode"
|
||||||
|
|||||||
@@ -426,6 +426,13 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
|
|||||||
checked = s.invertScroll,
|
checked = s.invertScroll,
|
||||||
onCheckedChange = { on -> update(s.copy(invertScroll = on)) },
|
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 {
|
SettingsCard {
|
||||||
SettingDropdown(
|
SettingDropdown(
|
||||||
|
|||||||
@@ -270,6 +270,13 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
activity?.remotePointer = remote
|
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
|
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
|
// 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
|
// 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 {
|
onDispose {
|
||||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
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.onHidRaw = null
|
||||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||||
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||||
|
|||||||
@@ -301,6 +301,36 @@ object NativeBridge {
|
|||||||
*/
|
*/
|
||||||
external fun nativeSendText(handle: Long, text: String)
|
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:<seq>:<hasText>` · `fetch:<reqId>` · `data:<xferId>:<text>` · `cancel:<id>` ·
|
||||||
|
* `error:<id>:<code>` · `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) ----
|
// ---- 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,
|
// 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
|
// so its wire is byte-identical to the old single-pad path. The core folds the per-transition
|
||||||
|
|||||||
@@ -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:<seq>:<has_text 0|1>` · `fetch:<req_id>` · `data:<xfer_id>:<text>` ·
|
||||||
|
//! `cancel:<id>` · `error:<id>:<code>` · `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:<xfer_id>:<text>` 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())
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
|
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
|
||||||
//! renegotiation. Port the remaining orchestration from `clients/linux`.
|
//! renegotiation. Port the remaining orchestration from `clients/linux`.
|
||||||
|
|
||||||
|
mod clipboard;
|
||||||
mod connect;
|
mod connect;
|
||||||
mod input;
|
mod input;
|
||||||
mod planes;
|
mod planes;
|
||||||
|
|||||||
Reference in New Issue
Block a user