Open the sign-in URL through an ACTION_VIEW Intent on Android

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 22:35:38 +02:00
co-authored by Claude Opus 5
parent 0876977133
commit 8b7c1e7f10
3 changed files with 89 additions and 1 deletions
Generated
+2
View File
@@ -1469,7 +1469,9 @@ dependencies = [
"dr-sync-nextcloud",
"dr-thumbs",
"dr-types",
"jni 0.22.4",
"log",
"ndk-context",
"pollster",
"reqwest",
"rusqlite",
+6
View File
@@ -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
+81 -1
View File
@@ -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())
}