diff --git a/Cargo.lock b/Cargo.lock index 90491ad..fc62925 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1469,7 +1469,9 @@ dependencies = [ "dr-sync-nextcloud", "dr-thumbs", "dr-types", + "jni 0.22.4", "log", + "ndk-context", "pollster", "reqwest", "rusqlite", diff --git a/ui/dr-ui/Cargo.toml b/ui/dr-ui/Cargo.toml index 59ce4ed..424480f 100644 --- a/ui/dr-ui/Cargo.toml +++ b/ui/dr-ui/Cargo.toml @@ -45,6 +45,12 @@ slint = { workspace = true, features = ["backend-winit"] } [target.'cfg(target_os = "android")'.dependencies] slint = { workspace = true, features = ["backend-android-activity-06"] } +# Opening the sign-in URL needs an ACTION_VIEW Intent — Android has no +# xdg-open. Version-matched to Slint's Android backend so both halves of the +# process agree on the JavaVM types; ndk-context supplies the VM and activity +# that android-activity's glue already stashed. +jni = "0.22" +ndk-context = "0.1" [build-dependencies] slint-build.workspace = true diff --git a/ui/dr-ui/src/launch_ui.rs b/ui/dr-ui/src/launch_ui.rs index 8201ec7..91bc959 100644 --- a/ui/dr-ui/src/launch_ui.rs +++ b/ui/dr-ui/src/launch_ui.rs @@ -553,7 +553,14 @@ fn open_in_browser(url: &str) -> std::io::Result<()> { .spawn() .map(|_| ()) } - #[cfg(not(all(unix, not(target_os = "android"), not(target_os = "macos"))))] + #[cfg(target_os = "android")] + { + android_open_url(url).map_err(|e| std::io::Error::other(e)) + } + #[cfg(not(any( + all(unix, not(target_os = "android"), not(target_os = "macos")), + target_os = "android" + )))] { let _ = url; Err(std::io::Error::new( @@ -562,3 +569,76 @@ fn open_in_browser(url: &str) -> std::io::Result<()> { )) } } + +/// Hand a URL to whatever the user has set as their browser, via +/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))`. +/// +/// Called from the login worker, which is a plain `std::thread` and therefore +/// not known to the JVM — every JNI call from it would abort the process +/// without `attach_current_thread` first. The thread detaches when the returned +/// guard drops. +/// +/// The VM and activity come from `ndk_context`, which android-activity's glue +/// populates at startup; that is the same handle Slint's backend uses, so there +/// is no second JavaVM to reconcile. +#[cfg(target_os = "android")] +fn android_open_url(url: &str) -> Result<(), String> { + let ctx = ndk_context::android_context(); + if ctx.vm().is_null() || ctx.context().is_null() { + return Err("no Android context available".into()); + } + + // SAFETY: the pointer comes from ndk_context, which android-activity fills + // in with the process's real JavaVM before any Rust runs. + let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }; + let raw_activity: jni::sys::jobject = ctx.context().cast(); + + // jni 0.22 scopes the attachment to a closure rather than handing back a + // guard, so all the JNI work happens in here and the thread is detached on + // the way out. + vm.attach_current_thread(|env| { + // SAFETY: valid for as long as this frame, which is all we need — the + // Intent is dispatched before the closure returns. + let activity = unsafe { jni::objects::JObject::from_raw(env, raw_activity) }; + + // Names go through `jni_str!` (UTF-8 literal to MUTF-8 `&'static + // JNIStr`) and signatures through `jni_sig!`, which parses and + // type-checks them at compile time — a typo in either is a build error + // rather than a NoSuchMethodError on the device. + let jurl = env.new_string(url)?; + let uri = env + .call_static_method( + jni::jni_str!("android/net/Uri"), + jni::jni_str!("parse"), + jni::jni_sig!("(Ljava/lang/String;)Landroid/net/Uri;"), + &[(&jurl).into()], + )? + .l()?; + + let action = env.new_string("android.intent.action.VIEW")?; + let intent = env.new_object( + jni::jni_str!("android/content/Intent"), + jni::jni_sig!("(Ljava/lang/String;Landroid/net/Uri;)V"), + &[(&action).into(), (&uri).into()], + )?; + + env.call_method( + &activity, + jni::jni_str!("startActivity"), + jni::jni_sig!("(Landroid/content/Intent;)V"), + &[(&intent).into()], + )?; + + // A pending Java exception leaves the JVM unusable for the next call, + // and ActivityNotFoundException here means the device has no browser at + // all — worth reporting rather than leaving for something unrelated to + // trip over. + if env.exception_check() { + env.exception_clear(); + return Err(jni::errors::Error::JavaException); + } + + Ok(()) + }) + .map_err(|e: jni::errors::Error| e.to_string()) +}