Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/opened-event-android.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"tao": minor
---

Fire `Event::Opened` on Android, which now requires the activity to call the onNewIntent(intent) external function.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ once_cell = "1"
jni = "0.21"
ndk = "0.9"
ndk-sys = "0.6"
percent-encoding = "2.3"
tao-macros = { version = "0.1.0", path = "./tao-macros" }

[target."cfg(any(target_os = \"ios\", target_os = \"macos\"))".dependencies]
Expand Down
11 changes: 11 additions & 0 deletions src/platform_impl/android/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,17 @@ impl<T: 'static> EventLoop<T> {
}
Event::Stop => self.running = false,
Event::Start => self.running = true,
Event::Opened => {
let urls = ndk_glue::take_intent_urls();
if !urls.is_empty() {
call_event_handler!(
event_handler,
self.window_target(),
control_flow,
event::Event::Opened { urls }
);
}
}
//Event::ConfigChanged => {
// #[allow(deprecated)] // TODO: use ndk-context instead
// let am = ndk_glue::native_activity().asset_manager();
Expand Down
244 changes: 239 additions & 5 deletions src/platform_impl/android/ndk_glue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ use ndk::{
looper::{FdEvent, ForeignLooper, ThreadLooper},
};
use once_cell::sync::{Lazy, OnceCell};
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use std::{
collections::BTreeMap,
collections::{BTreeMap, HashSet},
ffi::{c_void, CStr, CString},
fs::File,
io::{BufRead, BufReader},
Expand All @@ -33,6 +34,26 @@ use std::{
/// in the android project.
pub static PACKAGE: OnceCell<&str> = OnceCell::new();

/// Character set for encoding text content in data URLs.
/// Encodes all control characters and special characters that might cause issues in URLs.
const DATA_URL_ENCODING_SET: &AsciiSet = &CONTROLS

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tauri converts shared text to data URLs to provide a unified Event::Opened { urls: Vec<Url> } API across all content types (deep links, files, and text). Is there a specific reason why this pattern is used?

Other cross-platforms frameworks (such as React Native) handle this by splitting out the different content types e.g.

pub enum Event {
    Opened { urls: Vec<Url> },        // Deep links & file URIs only
    TextShared { text: String },       // Raw shared text
    // ...
}

Alternatively a tagged union could be used to handle the different content types:

pub enum OpenedContent {
    Url(Url),
    Text(String),
}

pub enum Event {
    Opened { content: Vec<OpenedContent> },
    // ...
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a specific reason why this pattern is used?

backwards compatibility

tho the new variant is probably ok, would just be a little weird to have two different paths for the same interface (intent data)

.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'&')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');

/// Generate JNI compilant functions that are necessary for
/// building android apps with tao.
///
Expand Down Expand Up @@ -91,6 +112,7 @@ macro_rules! android_binding {
android_fn!($domain, $package, $activity, onActivityDestroy, [JObject]);
android_fn!($domain, $package, $activity, onActivityLowMemory, [JObject]);
android_fn!($domain, $package, $activity, onWindowFocusChanged, [JObject,i32]);
android_fn!($domain, $package, $activity, onNewIntent, [JObject]);
}};
}

Expand Down Expand Up @@ -188,6 +210,7 @@ lazy_static::lazy_static! {
pub(crate) static ref CONTEXTS: Mutex<BTreeMap<ActivityId, AndroidContext>> = Mutex::new(Default::default());
static ref WINDOW_MANAGER: Mutex<BTreeMap<ActivityId, GlobalRef>> = Mutex::new(Default::default());
pub(crate) static ref ACTIVITY_CREATED_SENDERS: Mutex<BTreeMap<ActivityId, Sender<()>>> = Mutex::new(Default::default());
static ref INTENT_URLS: Mutex<Vec<url::Url>> = Mutex::new(Default::default());

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using global mutable state for intent URLs. How does this interact with multi-window support? What if multiple windows exist? How does the intent get delivered to the correct window?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the intent URL is not delivered to a particular window - it is instead sent as a global event Event::Opened
i wanted to include the URLs in the android Event enum but I can't since it has to implement Copy - that's why I'm using this global variable.

}

static INPUT_QUEUE: Lazy<RwLock<Option<InputQueue>>> = Lazy::new(Default::default);
Expand Down Expand Up @@ -248,6 +271,10 @@ pub fn poll_events() -> Option<Event> {
}
}

pub fn take_intent_urls() -> Vec<url::Url> {
INTENT_URLS.lock().unwrap().drain(..).collect()
}

unsafe fn wake(event: Event) {
log::trace!("{:?}", event);
let size = std::mem::size_of::<Event>();
Expand All @@ -263,7 +290,8 @@ pub struct Rect {
pub bottom: u32,
}

#[derive(Clone, Debug, Eq, PartialEq)]
// event must be copyable to be used in the event loop
#[derive(Clone, Debug, Eq, PartialEq, Copy)]
#[repr(u8)]
pub enum Event {
Start,
Expand All @@ -273,9 +301,10 @@ pub enum Event {
LowMemory,
WindowEvent { id: WindowId, event: WindowEvent },
ContentRectChanged,
Opened,
}

#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Eq, PartialEq, Copy)]
#[repr(u8)]
pub enum WindowEvent {
Focused(bool),
Expand Down Expand Up @@ -350,6 +379,12 @@ pub unsafe fn onActivityCreate(
activity: JObject,
setup: unsafe fn(&str, JNIEnv, &ThreadLooper, GlobalRef),
) {
let intent = env
.call_method(&activity, "getIntent", "()Landroid/content/Intent;", &[])
.unwrap()
.l()
.unwrap();

let activity_id = env
.call_method(&activity, "getId", "()I", &[])
.unwrap()
Expand Down Expand Up @@ -386,7 +421,7 @@ pub unsafe fn onActivityCreate(
.insert(activity_id, window_manager);
let activity = env.new_global_ref(activity).unwrap();
let vm = env.get_java_vm().unwrap();
let env = vm.attach_current_thread_as_daemon().unwrap();
let thread_env = vm.attach_current_thread_as_daemon().unwrap();

CONTEXTS.lock().unwrap().insert(
activity_id,
Expand All @@ -398,7 +433,7 @@ pub unsafe fn onActivityCreate(
},
);
let looper = ThreadLooper::for_thread().unwrap();
setup(PACKAGE.get().unwrap(), env, &looper, activity);
setup(PACKAGE.get().unwrap(), thread_env, &looper, activity);

if let Some(tx) = ACTIVITY_CREATED_SENDERS
.lock()
Expand All @@ -407,6 +442,8 @@ pub unsafe fn onActivityCreate(
{
let _ = tx.send(());
}

handle_intent(env, intent);
}

pub unsafe fn resume(_: JNIEnv, _: JClass, _: JObject) {
Expand Down Expand Up @@ -436,6 +473,203 @@ pub unsafe fn onWindowFocusChanged(
wake(event);
}

#[allow(non_snake_case)]
pub unsafe fn onNewIntent(env: JNIEnv, _: JClass, intent: JObject) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a number of silent failure paths in this function e.g.

let Ok(action) = env.get_string(&action)
  .map(|action| action.to_string_lossy().to_string())
else {
  return; // Silent failure
};

Should these errors be propagated or logged?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handle_intent(env, intent);
}

pub unsafe fn handle_intent(mut env: JNIEnv, intent: JObject) {
let action = env
.call_method(&intent, "getAction", "()Ljava/lang/String;", &[])
.unwrap()
.l()
.unwrap()
.into();
let action = env
.get_string(&action)
.map(|action| action.to_string_lossy().to_string())
.unwrap();

// Only handle SEND, SEND_MULTIPLE, and VIEW actions
if action != "android.intent.action.SEND"
&& action != "android.intent.action.VIEW"
&& action != "android.intent.action.SEND_MULTIPLE"
{
return;
}

let mut urls = HashSet::new();

// Get intent type (may be null)
let intent_type = env
.call_method(&intent, "getType", "()Ljava/lang/String;", &[])
.ok()
.and_then(|result| result.l().ok())
.map(|jstr| jstr.into())
.map(|intent_type| {
env
.get_string(&intent_type)
.unwrap()
.to_string_lossy()
.to_string()
});

// Handle text/plain intents (EXTRA_TEXT)
if intent_type.as_deref() == Some("text/plain") {
let extra_text = env
.call_method(
&intent,
"getStringExtra",
"(Ljava/lang/String;)Ljava/lang/String;",
&[(&env.new_string("android.intent.extra.TEXT").unwrap()).into()],
)
.ok()
.and_then(|result| result.l().ok())
.and_then(|jstr| {
let jstr: JString = jstr.into();
env
.get_string(&jstr)
.ok()
.map(|s| s.to_string_lossy().to_string())
});

if let Some(text) = extra_text {
if !text.is_empty() {
// Check if it's a valid URL
if let Ok(url) = url::Url::parse(&text) {
urls.insert(url);
} else {
// If not a URL, create a data URL for plain text
// Use percent encoding for the text content
let encoded = utf8_percent_encode(&text, DATA_URL_ENCODING_SET).to_string();
if let Ok(url) = url::Url::parse(&format!("data:text/plain,{}", encoded)) {
urls.insert(url);
}
}
}
}
}

// Handle ClipData (API >= KITKAT, which is API 19)
// We'll try to get clip data, and if it fails, we'll continue
let clip_data = env
.call_method(&intent, "getClipData", "()Landroid/content/ClipData;", &[])
.ok()
.and_then(|result| result.l().ok());

if let Some(clip_data) = clip_data {
let item_count = env
.call_method(&clip_data, "getItemCount", "()I", &[])
.unwrap()
.i()
.unwrap();

for i in 0..item_count {
let clip_item = env
.call_method(
&clip_data,
"getItemAt",
"(I)Landroid/content/ClipData$Item;",
&[i.into()],
)
.unwrap()
.l()
.unwrap();

let uri = env
.call_method(&clip_item, "getUri", "()Landroid/net/Uri;", &[])
.ok()
.and_then(|result| result.l().ok());

if let Some(uri) = uri {
let uri_string: JString = env
.call_method(&uri, "toString", "()Ljava/lang/String;", &[])
.unwrap()
.l()
.unwrap()
.into();
let uri_str = env
.get_string(&uri_string)
.unwrap()
.to_string_lossy()
.to_string();
if let Ok(url) = url::Url::parse(&uri_str) {
urls.insert(url);
} else {
log::error!("failed to parse URI: {}", uri_str);
}
}
}
}

// Handle EXTRA_STREAM (for file sharing)
let extras = env
.call_method(&intent, "getExtras", "()Landroid/os/Bundle;", &[])
.ok()
.and_then(|result| result.l().ok());

if let Some(extras) = extras {
let extra_stream = env
.call_method(
&extras,
"get",
"(Ljava/lang/String;)Ljava/lang/Object;",
&[(&env.new_string("android.intent.extra.STREAM").unwrap()).into()],
)
.ok()
.and_then(|result| result.l().ok());

if let Some(stream_uri) = extra_stream {
let uri_string: JString = env
.call_method(&stream_uri, "toString", "()Ljava/lang/String;", &[])
.unwrap()
.l()
.unwrap()
.into();
let uri_str = env
.get_string(&uri_string)
.unwrap()
.to_string_lossy()
.to_string();
if let Ok(url) = url::Url::parse(&uri_str) {
urls.insert(url);
} else {
log::error!("failed to parse URI: {}", uri_str);
}
}
}

// Handle getDataString() for VIEW intents (deeplinks)
if action == "android.intent.action.VIEW" {
let data_string = env
.call_method(&intent, "getDataString", "()Ljava/lang/String;", &[])
.ok()
.and_then(|result| result.l().ok())
.and_then(|jstr| {
let jstr: JString = jstr.into();
env
.get_string(&jstr)
.ok()
.map(|s| s.to_string_lossy().to_string())
});

if let Some(data_str) = data_string {
if let Ok(url) = url::Url::parse(&data_str) {
urls.insert(url);
} else {
log::error!("failed to parse data string: {}", data_str);
}
} else {
log::error!("Intent data string is null");
}
}

if !urls.is_empty() {
INTENT_URLS.lock().unwrap().extend(urls);
wake(Event::Opened);
}
}

pub unsafe fn start(_: JNIEnv, _: JClass, _: JObject) {
wake(Event::Start);
}
Expand Down
Loading