Skip to content

Commit 0b99818

Browse files
JSKittyclaude
andcommitted
add: Android share-sheet target — receive files/text from other apps
Register Vector as an ACTION_SEND / ACTION_SEND_MULTIPLE target so other apps can share files, media, and text into it; the user picks a chat and sends. Mirrors the deep-link model (a share foregrounds the activity, so it's never headless): MainActivity forwards the content:// URIs + text to native, which queues them (PENDING_SHARE) for a cold start and emits `share_received` live. The frontend polls get_pending_share on init / listens live, drops the user on the chat list with a "Choose a chat to forward to" toast, and on chat-open attaches the share — files via the existing file-preview, text into the composer. - Manifest SEND/SEND_MULTIPLE intent-filters; MainActivity handleSendIntent (cold + warm) with API-version-safe EXTRA_STREAM extraction. - share.rs (PENDING_SHARE + get_pending_share command) + JNI bridge + ACL. - Only accept content:// URIs — rejects file:// so a crafted share can't coax a read of our own private files. Also fixes the Android content-URI read path these shares exercised: - Stop percent-decoding URIs before Uri.parse — it corrupted nested-encoded provider URIs (e.g. Google Photos' wrapper around an encoded content:// URI). The encoded form is what ContentResolver expects; simple SAF picks resolve identically (so avatars were unaffected). - Clear the pending JNI exception after the best-effort takePersistableUriPermission (share grants aren't persistable; the stale exception poisoned the following query/openInputStream). - openFilePreview: don't render a video player on Android (generic icon, like the in-app attach path); desktop keeps inline players. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6c9df6d commit 0b99818

11 files changed

Lines changed: 261 additions & 41 deletions

File tree

src-tauri/build.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ fn main() {
131131
"sync_all_profiles",
132132
// Deep link commands
133133
"get_pending_deep_link",
134+
"get_pending_share",
134135
// Account manager commands
135136
"get_current_account",
136137
"list_all_accounts",

src-tauri/capabilities/default.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@
175175
"allow-refresh-profile-now",
176176
"allow-sync-all-profiles",
177177
"allow-get-pending-deep-link",
178+
"allow-get-pending-share",
178179
"allow-get-current-account",
179180
"allow-list-all-accounts",
180181
"allow-list-accounts-with-metadata",

src-tauri/gen/android/app/src/main/AndroidManifest.xml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,26 @@
4848
<data android:scheme="vector" />
4949
</intent-filter>
5050

51+
52+
<!-- Receive shares from other apps (files / media / text). Lets
53+
Vector appear as a target in the system share sheet. -->
54+
<intent-filter>
55+
<action android:name="android.intent.action.SEND" />
56+
<category android:name="android.intent.category.DEFAULT" />
57+
<data android:mimeType="text/plain" />
58+
<data android:mimeType="image/*" />
59+
<data android:mimeType="video/*" />
60+
<data android:mimeType="audio/*" />
61+
<data android:mimeType="application/*" />
62+
<data android:mimeType="*/*" />
63+
</intent-filter>
64+
<intent-filter>
65+
<action android:name="android.intent.action.SEND_MULTIPLE" />
66+
<category android:name="android.intent.category.DEFAULT" />
67+
<data android:mimeType="image/*" />
68+
<data android:mimeType="video/*" />
69+
<data android:mimeType="*/*" />
70+
</intent-filter>
5171
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
5272
<intent-filter android:autoVerify="true" >
5373
<action android:name="android.intent.action.VIEW" />

src-tauri/gen/android/app/src/main/java/io/vectorapp/MainActivity.kt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io.vectorapp
22

33
import android.content.Intent
44
import android.content.pm.PackageManager
5+
import android.net.Uri
56
import android.os.Build
67
import android.webkit.WebSettings
78
import android.webkit.WebView
@@ -26,6 +27,8 @@ class MainActivity : TauriActivity() {
2627
external fun nativeOnPause()
2728
@JvmStatic
2829
external fun nativeOnNotificationTap(chatId: String)
30+
@JvmStatic
31+
external fun nativeOnShareReceived(uris: Array<String>, text: String)
2932
}
3033

3134
private var managedWebView: WebView? = null
@@ -51,12 +54,16 @@ class MainActivity : TauriActivity() {
5154

5255
// Handle notification tap that launched the app
5356
handleNotificationIntent(intent)
57+
// Handle a file/text share that launched the app
58+
handleSendIntent(intent)
5459
}
5560

5661
override fun onNewIntent(intent: Intent) {
5762
super.onNewIntent(intent)
5863
// Handle notification tap when app is already running
5964
handleNotificationIntent(intent)
65+
// Handle a share that arrived while the app was running
66+
handleSendIntent(intent)
6067
}
6168

6269
override fun onResume() {
@@ -80,6 +87,48 @@ class MainActivity : TauriActivity() {
8087
}
8188
}
8289

90+
/**
91+
* Handle another app sharing files/text into Vector via the share sheet.
92+
* Extracts content:// URIs (single or multiple) plus any plain text and
93+
* hands them to native; the frontend then lets the user pick a chat.
94+
*/
95+
private fun handleSendIntent(intent: Intent?) {
96+
if (intent == null) return
97+
val action = intent.action ?: return
98+
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
99+
100+
val uris = ArrayList<String>()
101+
var text = ""
102+
if (action == Intent.ACTION_SEND) {
103+
streamUri(intent)?.let { uris.add(it.toString()) }
104+
intent.getStringExtra(Intent.EXTRA_TEXT)?.let { text = it }
105+
} else {
106+
streamUris(intent)?.forEach { uris.add(it.toString()) }
107+
}
108+
109+
// Consume so a rotation/relaunch doesn't re-share the same payload.
110+
intent.action = null
111+
intent.removeExtra(Intent.EXTRA_STREAM)
112+
intent.removeExtra(Intent.EXTRA_TEXT)
113+
114+
if (uris.isEmpty() && text.isEmpty()) return
115+
try { nativeOnShareReceived(uris.toTypedArray(), text) } catch (_: Throwable) {}
116+
}
117+
118+
@Suppress("DEPRECATION")
119+
private fun streamUri(intent: Intent): Uri? =
120+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
121+
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
122+
else
123+
intent.getParcelableExtra(Intent.EXTRA_STREAM)
124+
125+
@Suppress("DEPRECATION")
126+
private fun streamUris(intent: Intent): ArrayList<Uri>? =
127+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
128+
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
129+
else
130+
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
131+
83132
private fun requestNotificationPermission() {
84133
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
85134
if (ContextCompat.checkSelfPermission(
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Automatically generated - DO NOT EDIT!
2+
3+
[[permission]]
4+
identifier = "allow-get-pending-share"
5+
description = "Enables the get_pending_share command without any pre-configured scope."
6+
commands.allow = ["get_pending_share"]
7+
8+
[[permission]]
9+
identifier = "deny-get-pending-share"
10+
description = "Denies the get_pending_share command without any pre-configured scope."
11+
commands.deny = ["get_pending_share"]

src-tauri/src/android/background_sync.rs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
//! onPause starts standalone sync when the app is backgrounded.
1010
//! onResume stops it when the app returns to foreground.
1111
12-
use jni::objects::{GlobalRef, JClass, JObject, JString};
12+
use jni::objects::{GlobalRef, JClass, JObject, JObjectArray, JString};
1313
use jni::{JavaVM, JNIEnv};
1414
use nostr_sdk::prelude::*;
1515
use std::collections::HashSet;
@@ -149,6 +149,47 @@ pub extern "C" fn Java_io_vectorapp_MainActivity_nativeOnNotificationTap(
149149
}
150150
}
151151

152+
/// Called from MainActivity when another app shares files/text *into* Vector
153+
/// (ACTION_SEND / ACTION_SEND_MULTIPLE). Forwards the content:// URIs and any
154+
/// text to the share handler, which stores it pending + emits to the frontend.
155+
#[no_mangle]
156+
pub extern "C" fn Java_io_vectorapp_MainActivity_nativeOnShareReceived(
157+
mut env: JNIEnv,
158+
_class: JClass,
159+
uris: JObjectArray<'_>,
160+
text: JString<'_>,
161+
) {
162+
let mut uri_vec: Vec<String> = Vec::new();
163+
if let Ok(len) = env.get_array_length(&uris) {
164+
for i in 0..len {
165+
if let Ok(obj) = env.get_object_array_element(&uris, i) {
166+
let js = JString::from(obj);
167+
// Convert into an owned String in a single statement so the
168+
// JavaStr/Result temporaries (which borrow `js`) are dropped at
169+
// the `;`, before `js` itself drops at the end of the block.
170+
let owned: Option<String> = env.get_string(&js).ok().map(|s| s.into());
171+
if let Some(s) = owned {
172+
// Only accept content:// URIs. Legitimate cross-app shares
173+
// are always content:// (Android blocks file:// in
174+
// EXTRA_STREAM); rejecting other schemes stops a crafted
175+
// share from coaxing us into reading our own private files
176+
// (e.g. file:///data/data/<pkg>/...) and sending them.
177+
if s.starts_with("content://") {
178+
uri_vec.push(s);
179+
} else if !s.is_empty() {
180+
logcat(&format!("Share: rejected non-content URI scheme: {}",
181+
s.split(':').next().unwrap_or("?")));
182+
}
183+
}
184+
}
185+
}
186+
}
187+
let text: String = env.get_string(&text).map(|s| s.into()).unwrap_or_default();
188+
189+
logcat(&format!("Share received: {} file(s), {} text chars", uri_vec.len(), text.len()));
190+
crate::share::set_pending_share(uri_vec, text);
191+
}
192+
152193
/// Called by VectorNotificationService when the foreground service starts.
153194
/// Stores JNI refs and data_dir for later use. In service-only mode (no Activity),
154195
/// immediately starts the standalone sync. In full-app mode, standalone sync is

src-tauri/src/android/filesystem.rs

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,6 @@ use jni::objects::{JObject, JValue, JString};
44
use crate::message::{AttachmentFile, FileInfo};
55
use super::utils::{with_android_context, get_content_resolver, STREAM_BUFFER_SIZE};
66

7-
/// Simple percent-decoding for URIs (e.g., %3A -> :)
8-
fn percent_decode(input: &str) -> String {
9-
let mut result = String::with_capacity(input.len());
10-
let mut chars = input.chars().peekable();
11-
12-
while let Some(c) = chars.next() {
13-
if c == '%' {
14-
// Try to read two hex digits
15-
let hex: String = chars.by_ref().take(2).collect();
16-
if hex.len() == 2 {
17-
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
18-
result.push(byte as char);
19-
continue;
20-
}
21-
}
22-
// If decoding failed, keep the original
23-
result.push('%');
24-
result.push_str(&hex);
25-
} else {
26-
result.push(c);
27-
}
28-
}
29-
30-
result
31-
}
32-
337
/// Get file info from an Android content URI
348
pub fn get_android_uri_info(uri: String) -> Result<FileInfo, String> {
359
with_android_context(|env, activity| {
@@ -44,8 +18,10 @@ fn get_android_uri_info_internal(
4418
content_resolver: &JObject,
4519
uri: &str,
4620
) -> Result<FileInfo, Box<dyn std::error::Error>> {
47-
// URL decode the URI in case it's encoded (e.g., %3A -> :)
48-
let decoded_uri = percent_decode(uri);
21+
// Use the URI as-is. Android delivers canonically-encoded URIs; decoding
22+
// here would corrupt nested-encoded URIs (e.g. Google Photos provider URIs
23+
// that wrap a percent-encoded content:// MediaStore URI), breaking the read.
24+
let decoded_uri = uri.to_string();
4925

5026
// Parse URI
5127
let uri_string = env.new_string(&decoded_uri)?;
@@ -188,15 +164,20 @@ fn try_take_persistable_permission(
188164
content_resolver: &JObject,
189165
uri_object: &JObject,
190166
) {
191-
// Try to take persistable read permission
192-
// This may fail if the URI doesn't support it, which is fine
167+
// Try to take persistable read permission. SAF picks (ACTION_OPEN_DOCUMENT)
168+
// support this; share grants (ACTION_SEND) do NOT and throw a
169+
// SecurityException. We don't care either way — but we MUST clear the
170+
// pending JNI exception afterward, or the next JNI call (query /
171+
// openInputStream) inherits it and fails, which silently breaks reading
172+
// every shared content:// URI.
193173
let flag_read = 1i32; // Intent.FLAG_GRANT_READ_URI_PERMISSION
194174
let _ = env.call_method(
195175
content_resolver,
196176
"takePersistableUriPermission",
197177
"(Landroid/net/Uri;I)V",
198178
&[JValue::Object(uri_object), JValue::Int(flag_read)],
199179
);
180+
let _ = env.exception_clear();
200181
}
201182

202183
/// Read raw bytes from an Android content URI (for compression)
@@ -213,8 +194,9 @@ fn read_android_uri_bytes_internal(
213194
content_resolver: &JObject,
214195
uri: &str,
215196
) -> Result<(Vec<u8>, String), Box<dyn std::error::Error>> {
216-
// URL decode the URI in case it's encoded
217-
let decoded_uri = percent_decode(uri);
197+
// Use the URI as-is (see note in get_android_uri_info_internal): decoding
198+
// would corrupt nested-encoded URIs like Google Photos provider URIs.
199+
let decoded_uri = uri.to_string();
218200

219201
// Parse URI
220202
let uri_string = env.new_string(&decoded_uri)?;
@@ -339,8 +321,9 @@ fn read_from_android_uri_internal(
339321
content_resolver: &JObject,
340322
uri: &str,
341323
) -> Result<AttachmentFile, Box<dyn std::error::Error>> {
342-
// URL decode the URI in case it's encoded
343-
let decoded_uri = percent_decode(uri);
324+
// Use the URI as-is (see note in get_android_uri_info_internal): decoding
325+
// would corrupt nested-encoded URIs like Google Photos provider URIs.
326+
let decoded_uri = uri.to_string();
344327

345328
// Parse URI
346329
let uri_string = env.new_string(&decoded_uri)?;

src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ pub mod stored_event {
6262
pub use vector_core::{StoredEvent, StoredEventBuilder};
6363

6464
mod deep_link;
65+
mod share;
6566

6667
// Mini Apps (WebXDC-compatible) support
6768
mod miniapps;
@@ -599,6 +600,7 @@ pub fn run() {
599600
commands::mls::accept_mls_welcome,
600601
// Deep link commands
601602
deep_link::get_pending_deep_link,
603+
share::get_pending_share,
602604
// Account manager commands
603605
account_manager::get_current_account,
604606
account_manager::list_all_accounts,

src-tauri/src/share.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//! Inbound share handler.
2+
//!
3+
//! When another app shares files or text *into* Vector via the Android share
4+
//! sheet (ACTION_SEND / ACTION_SEND_MULTIPLE), MainActivity forwards the
5+
//! payload here. The frontend then lets the user pick a chat and sends it.
6+
//!
7+
//! Mirrors the deep-link pattern: store as pending (the frontend may not be
8+
//! ready on a cold start) AND emit live (if it already is).
9+
10+
use serde::Serialize;
11+
use std::sync::Mutex;
12+
13+
/// Pending inbound share, received before the frontend was ready.
14+
static PENDING_SHARE: Mutex<Option<SharePayload>> = Mutex::new(None);
15+
16+
/// A share received from another app.
17+
#[derive(Debug, Clone, Serialize, Default)]
18+
pub struct SharePayload {
19+
/// `content://` URIs of shared files (may be empty for a text-only share).
20+
pub uris: Vec<String>,
21+
/// Shared plain text (empty when only files were shared).
22+
pub text: String,
23+
}
24+
25+
/// Store an inbound share and emit it to the frontend if it's running.
26+
pub fn set_pending_share(uris: Vec<String>, text: String) {
27+
if uris.is_empty() && text.is_empty() {
28+
return;
29+
}
30+
let payload = SharePayload { uris, text };
31+
32+
if let Ok(mut pending) = PENDING_SHARE.lock() {
33+
*pending = Some(payload.clone());
34+
}
35+
36+
if let Some(handle) = crate::TAURI_APP.get() {
37+
use tauri::Emitter;
38+
let _ = handle.emit("share_received", &payload);
39+
}
40+
}
41+
42+
/// Get and clear any pending inbound share. The frontend polls this on init to
43+
/// catch a cold-start share that arrived before its listener was attached.
44+
#[tauri::command]
45+
pub fn get_pending_share() -> Option<SharePayload> {
46+
PENDING_SHARE.lock().ok().and_then(|mut p| p.take())
47+
}

src/js/file-preview.js

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -643,12 +643,22 @@ async function openFilePreview(filepath, receiver, replyRef = '') {
643643
appendSpoilerToggle(imgCont, shouldAutoSpoiler);
644644
}
645645
} else if (isVideo) {
646-
const videoSrc = mediaUrl(filepath);
647-
contentArea.innerHTML = `
648-
<div class="file-preview-video-container">
649-
<video src="${videoSrc}" class="file-preview-video" controls muted></video>
650-
</div>
651-
`;
646+
if (isAndroid) {
647+
// Video preview is unreliable on Android; show a generic film icon
648+
// (matches openFilePreviewWithFile, the in-app attach path).
649+
contentArea.innerHTML = `
650+
<div class="file-preview-icon-container">
651+
<div class="icon icon-film file-preview-icon"></div>
652+
</div>
653+
`;
654+
} else {
655+
const videoSrc = mediaUrl(filepath);
656+
contentArea.innerHTML = `
657+
<div class="file-preview-video-container">
658+
<video src="${videoSrc}" class="file-preview-video" controls muted></video>
659+
</div>
660+
`;
661+
}
652662
} else {
653663
// Show file icon
654664
const iconClass = getFileIcon(filepath);

0 commit comments

Comments
 (0)