From 8b7c1e7f10bc852a9ad9f5756b91b494e8e8b613 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 9 Aug 2026 22:35:38 +0200 Subject: [PATCH] Open the sign-in URL through an ACTION_VIEW Intent on Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit made the missing launcher honest; this gives Android a real one, so Login Flow v2 can complete on device. Builds `new Intent(ACTION_VIEW, Uri.parse(url))` and hands it to `startActivity` over JNI. The JavaVM and Activity come from ndk_context, which android-activity's glue populates at startup — the same handle Slint's backend uses, so there is no second VM to reconcile. The login worker is a plain std::thread and therefore unknown to the JVM, where any JNI call would abort the process. jni 0.22 scopes attachment to a closure rather than returning a guard, so the whole Intent is built and dispatched inside `attach_current_thread` and the thread detaches on the way out. Names use `jni_str!` and signatures `jni_sig!`, both compile-time: a typo is a build error rather than a NoSuchMethodError on the device. A pending Java exception is checked and cleared before returning, since leaving one pending makes the next JNI call fail somewhere unrelated; in practice it means ActivityNotFoundException, i.e. no browser installed. jni is pinned to 0.22 to match Slint's Android backend. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 2 + ui/dr-ui/Cargo.toml | 6 +++ ui/dr-ui/src/launch_ui.rs | 82 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 1 deletion(-) 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()) +}