From 57747ee62baf947dcc9a061960e18e19ac40101d Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 14 Aug 2026 16:57:57 +0200 Subject: [PATCH] fix(share): send shared plain text to the laptop clipboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `text/plain` share only did something when the text contained a URL, which took the browsing-handoff path. Anything else fell through to the file loop, where a text share has no EXTRA_STREAM — so it iterated zero URIs and ended on the toast "Couldn't read the shared file(s)". Nothing was ever transmitted, and the laptop never logged a receive. The manifest advertises `text/plain` for this activity, so Vortex offers itself as a target for text and has to honour it. Route plain text to `VortexService.clipboardBus`, the bus the Quick Settings tile already uses, so it inherits the enable check, the character cap, the CLIPBOARD_TEXT chunking and the per-peer send. Guarded on an empty URI list so a caption shared ALONGSIDE an attachment doesn't hijack the file. The order is now: URL → handoff, attachment → file, plain text → clipboard. Laptop side: the receive path threw away the result of `set_system_text` with `let _ =`, which made a refused clipboard write indistinguishable from text that never arrived — the exact ambiguity that made this bug hard to place. Log the failure (the image path always did) and only claim "synced from phone" when the write actually succeeded; keep the text in history either way so it stays reachable from the Super+V popup. Verified end-to-end on a PJZ110 (Android 16) over wireless-debugging adb: the phone logs "share: forwarded 28 chars to the laptop clipboard" and the text lands in the laptop's Wayland clipboard. Co-Authored-By: Claude Opus 5 --- .../core/clipboard/ShareReceiverActivity.kt | 41 +++++++++++++++---- .../ui-tauri/src-tauri/src/clipboard_sync.rs | 22 +++++++++- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt index 4039105..add4677 100644 --- a/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt +++ b/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt @@ -10,13 +10,18 @@ import android.widget.Toast import com.vortex.a3.service.VortexService /** - * Share-sheet target: the user picks "Vortex" when sharing one or more files - * (any type) to the laptop — instant-share style. The files arrive as granted - * `content://` URIs in the intent (no focus trick needed). Each is read and - * handed to [VortexService] as a FILE; the laptop saves them to Downloads (NOT - * the clipboard). Handles both single (`ACTION_SEND`) and multi - * (`ACTION_SEND_MULTIPLE`) shares — file managers use the latter for a - * multi-selection, which is why a SEND-only filter never appeared. + * Share-sheet target: the user picks "Vortex" when sharing to the laptop — + * instant-share style. Three kinds of share, in priority order: + * + * 1. text containing a URL → browsing handoff (the laptop opens the page), + * 2. any attachment → FILE to the laptop's download folder, + * 3. plain text → the laptop's CLIPBOARD. + * + * Files arrive as granted `content://` URIs in the intent (no focus trick + * needed) and are handed to [VortexService] as FILEs. Handles both single + * (`ACTION_SEND`) and multi (`ACTION_SEND_MULTIPLE`) shares — file managers use + * the latter for a multi-selection, which is why a SEND-only filter never + * appeared. */ class ShareReceiverActivity : Activity() { @@ -49,6 +54,28 @@ class ShareReceiverActivity : Activity() { else -> emptyList() } + // Shared plain TEXT (no URL in it, so the handoff above passed) → the + // laptop's CLIPBOARD, via the same bus the Quick Settings tile uses, so + // it inherits the cap + chunking + per-peer send. Without this, a text + // share fell through to the file loop below with nothing to read and + // died on "Couldn't read the shared file(s)" — the manifest advertises + // text/plain, so Vortex offers itself for text and must honour it. + // + // Guarded on `uris.isEmpty()`: a share can carry a caption ALONGSIDE an + // attachment (EXTRA_TEXT + EXTRA_STREAM), and there the file is the + // payload the user meant. + if (uris.isEmpty() && intent?.action == Intent.ACTION_SEND) { + val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.trim() + if (!text.isNullOrEmpty()) { + VortexService.clipboardBus.tryEmit(text) + Log.i(TAG, "share: forwarded ${text.length} chars to the laptop clipboard") + Toast.makeText(this, "Sending text to laptop…", Toast.LENGTH_SHORT).show() + finish() + overridePendingTransition(0, 0) + return + } + } + var sent = 0 for (uri in uris) { val file = ClipboardFileReader.read(this, uri) diff --git a/linux/ui-tauri/src-tauri/src/clipboard_sync.rs b/linux/ui-tauri/src-tauri/src/clipboard_sync.rs index 996612d..13f1611 100644 --- a/linux/ui-tauri/src-tauri/src/clipboard_sync.rs +++ b/linux/ui-tauri/src-tauri/src/clipboard_sync.rs @@ -259,10 +259,28 @@ pub(crate) fn spawn_clipboard_sync( *g = sync_sig(&text); } let t2 = text.clone(); - let _ = tokio::task::spawn_blocking(move || set_system_text(t2)).await; + // Never swallow the apply error: "the phone's text didn't show + // up in my clipboard" is indistinguishable from "it never + // arrived" without this, and the two have nothing in common to + // debug. (The image path below has always logged its failures.) + let applied = match tokio::task::spawn_blocking(move || set_system_text(t2)).await { + Ok(Ok(())) => true, + Ok(Err(e)) => { + tracing::warn!("clipboard: phone text NOT applied: {e}"); + false + } + Err(e) => { + tracing::warn!("clipboard: apply task join: {e}"); + false + } + }; + // Still kept in history even if the system clipboard refused it, + // so the text is at least reachable from the Super+V popup. store_capture("text", Some(text), None); let _ = app.emit("vortex:clipboard", ()); - tracing::info!("clipboard: synced from phone"); + if applied { + tracing::info!("clipboard: synced from phone"); + } } }); }