Skip to content

Commit b5fc8c7

Browse files
committed
android: jni 0.22 update, exception checks, support any 'Context'
This addresses some exception-handling hygiene issues in the Android backend, and adds support for non-`Activity` `Context` references (considering that `ndk-context` does not guarantee that it provides an `Activity` reference). With the previous implementation, if any of the JNI calls failed (such as the `find_class` call), that would have thrown an exception which was not automatically caught by the jni 0.21 crate. Since there was no code to ensure exceptions were checked and cleared on failure then the previous implementation would have had the side effect of blocking any further JNI on the same thread until the exception was cleared - but it would not have reported a recognisable error for indicating this side effect. By contrast, with jni 0.22 we can rely on the `attach_current_thread` call to check and clear exceptions from the closure before returning. This change also prepares this crate for a planned change in `android-activity` to initialize `ndk-context` with an `Application` reference instead of an `Activity` reference, ref: rust-mobile/android-activity#228 The fact that `android-activity` currently initializes `ndk-context` with an `Activity` reference is not itself guaranteed by the `ndk-context` crate (it only promises an `android.content.Context` reference) but the previous android backend was depending on receiving an `Activity` reference and would throw an exception if it didn't. Note: Associating `ndk-context` with an `Activity` does not align well with Android application programming model because Android applications may often manage many Activities and it doesn't align with the design of `ndk-context` because the `ndk-context` state can't be re-initialized. Even if ndk-context could be changed to support state updates without a panic it could still be ambiguous to maintain a single global reference to one Activity in an application with multiple or possibly no Activity. The new implementation will continue as it did before in case it does receive an Activity reference, but in case it receives some other kind of `Context` then now adds a `ACTIVITY_NEW_TASK` flag to the `Intent` to indicate that the new Activity is allowed to create a new `Task`. In practice this expected to be irrelevant (and result in identical behaviour) because the URL is most likely to be handled by a separate browser application. This needed to bump the rust-version to 1.85 since that's the MSRV for jni 0.22 This also updates the Android test to be based on android-activity instead of ndk-glue
1 parent 94baf04 commit b5fc8c7

File tree

5 files changed

+159
-79
lines changed

5 files changed

+159
-79
lines changed

Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ readme = "README.md"
1010
keywords = ["webbrowser", "browser"]
1111
license = "MIT OR Apache-2.0"
1212
edition = "2021"
13-
rust-version = "1.60"
13+
rust-version = "1.85"
1414

1515
[dependencies]
1616
log = { version = "0.4", default-features = false }
@@ -29,7 +29,7 @@ wasm-console = ["web-sys/console"]
2929
core-foundation = "0.10"
3030

3131
[target.'cfg(target_os = "android")'.dependencies]
32-
jni = "0.21"
32+
jni = "0.22.2"
3333
ndk-context = "0.1"
3434

3535
[target.'cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))'.dependencies]
@@ -52,4 +52,4 @@ tokio = { version = "1", features = ["full"] }
5252
urlencoding = "2.1"
5353

5454
[target.'cfg(target_os = "android")'.dev-dependencies]
55-
ndk-glue = { version = ">= 0.3, <= 0.7" }
55+
android-activity = { version = "0.6", features = ["native-activity"] }

src/android.rs

Lines changed: 151 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::{Browser, BrowserOptions, Error, ErrorKind, Result, TargetType};
2-
use jni::objects::{JObject, JValue};
2+
use jni::{objects::JString, refs::Global};
33
use std::process::{Command, Stdio};
44

55
/// Deal with opening of browsers on Android. Only [Browser::Default] is supported, and
@@ -21,6 +21,77 @@ pub(super) fn open_browser_internal(
2121
}
2222
}
2323

24+
jni::bind_java_type! {
25+
AUri => "android.net.Uri",
26+
methods {
27+
static fn parse(uri: JString) -> AUri
28+
}
29+
30+
}
31+
32+
jni::bind_java_type! {
33+
AIntent => "android.content.Intent",
34+
type_map {
35+
AUri => "android.net.Uri"
36+
},
37+
constructors {
38+
fn new_with_url(action: JString, uri: AUri)
39+
},
40+
fields {
41+
#[allow(non_snake_case)]
42+
static ACTION_VIEW: JString,
43+
#[allow(non_snake_case)]
44+
static FLAG_ACTIVITY_NEW_TASK: jint
45+
},
46+
methods {
47+
fn add_flags(flags: jint) -> AIntent
48+
}
49+
}
50+
51+
jni::bind_java_type! {
52+
AContext => "android.content.Context",
53+
type_map {
54+
AIntent => "android.content.Intent"
55+
},
56+
methods {
57+
fn start_activity(intent: AIntent)
58+
}
59+
}
60+
61+
jni::bind_java_type! {
62+
AActivity => "android.app.Activity",
63+
type_map {
64+
AContext => "android.content.Context"
65+
},
66+
is_instance_of {
67+
activity: AContext
68+
},
69+
}
70+
71+
#[derive(Debug)]
72+
enum OpenUrlJniError {
73+
Jni(jni::errors::Error),
74+
Other(crate::Error),
75+
}
76+
impl From<jni::errors::Error> for OpenUrlJniError {
77+
fn from(err: jni::errors::Error) -> Self {
78+
OpenUrlJniError::Jni(err)
79+
}
80+
}
81+
impl From<crate::Error> for OpenUrlJniError {
82+
fn from(err: crate::Error) -> Self {
83+
OpenUrlJniError::Other(err)
84+
}
85+
}
86+
impl std::fmt::Display for OpenUrlJniError {
87+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88+
match self {
89+
OpenUrlJniError::Jni(err) => write!(f, "JNI error: {}", err),
90+
OpenUrlJniError::Other(err) => write!(f, "{}", err),
91+
}
92+
}
93+
}
94+
2495
/// Open the default browser
2596
fn open_browser_default(url: &str, options: &BrowserOptions) -> Result<()> {
2697
// always return true for a dry run
@@ -34,64 +105,85 @@ fn open_browser_default(url: &str, options: &BrowserOptions) -> Result<()> {
34105
return Ok(());
35106
}
36107

37-
// Create a VM for executing Java calls
108+
// Get a `JavaVM` reference for executing Java calls
38109
let ctx = ndk_context::android_context();
39-
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm() as _) }.map_err(|_| {
40-
Error::new(
110+
if ctx.vm().is_null() || ctx.context().is_null() {
111+
return Err(Error::new(
41112
ErrorKind::NotFound,
42-
"Expected to find JVM via ndk_context crate",
43-
)
44-
})?;
45-
46-
let activity = unsafe { jni::objects::JObject::from_raw(ctx.context() as _) };
47-
let mut env = vm
48-
.attach_current_thread()
49-
.map_err(|_| Error::new(ErrorKind::Other, "Failed to attach current thread"))?;
50-
51-
// Create ACTION_VIEW object
52-
let intent_class = env
53-
.find_class("android/content/Intent")
54-
.map_err(|_| Error::new(ErrorKind::NotFound, "Failed to find Intent class"))?;
55-
let action_view = env
56-
.get_static_field(&intent_class, "ACTION_VIEW", "Ljava/lang/String;")
57-
.map_err(|_| Error::new(ErrorKind::NotFound, "Failed to get intent.ACTION_VIEW"))?;
58-
59-
// Create Uri object
60-
let uri_class = env
61-
.find_class("android/net/Uri")
62-
.map_err(|_| Error::new(ErrorKind::NotFound, "Failed to find Uri class"))?;
63-
let url = env
64-
.new_string(url)
65-
.map_err(|_| Error::new(ErrorKind::Other, "Failed to create JNI string"))?;
66-
let uri = env
67-
.call_static_method(
68-
&uri_class,
69-
"parse",
70-
"(Ljava/lang/String;)Landroid/net/Uri;",
71-
&[JValue::Object(&JObject::from(url))],
72-
)
73-
.map_err(|_| Error::new(ErrorKind::Other, "Failed to parse JNI Uri"))?;
74-
75-
// Create new ACTION_VIEW intent with the uri
76-
let intent = env
77-
.alloc_object(&intent_class)
78-
.map_err(|_| Error::new(ErrorKind::Other, "Failed to allocate intent"))?;
79-
env.call_method(
80-
&intent,
81-
"<init>",
82-
"(Ljava/lang/String;Landroid/net/Uri;)V",
83-
&[action_view.borrow(), uri.borrow()],
84-
)
85-
.map_err(|_| Error::new(ErrorKind::Other, "Failed to initialize intent"))?;
86-
87-
// Start the intent activity.
88-
env.call_method(
89-
&activity,
90-
"startActivity",
91-
"(Landroid/content/Intent;)V",
92-
&[JValue::Object(&intent)],
93-
)
94-
.map_err(|_| Error::new(ErrorKind::Other, "Failed to start activity"))?;
113+
"Expected to find JVM and context.context.Context reference via ndk_context crate",
114+
));
115+
}
116+
// Safety: we have checked we have a non-null pointer
117+
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm() as _) };
118+
119+
// Note: attach_current_thread will also make sure to catch/clear any Java exceptions that could be
120+
// thrown while attempting to interact with the Android SDK
121+
vm.attach_current_thread(|env| -> std::result::Result<(), OpenUrlJniError> {
122+
// Safety:
123+
//
124+
// The docs for `ndk-context` promise that `.context()` will return a reference to an
125+
// `android.content.Context`
126+
//
127+
// The reference associated with ndk-context must implicitly be a global JNI reference
128+
//
129+
// Note: although we already check for a `null` `Context` reference above, that's not
130+
// strictly required for _safety_ here (`null` JNI references are safe).
131+
//
132+
// Wrapping the raw reference with a `Cast` here ensures that we can't accidentally delete
133+
// a global reference that we don't own.
134+
let context: jni::sys::jobject = ctx.context() as _;
135+
let context = unsafe { env.as_cast_raw::<Global<AContext>>(&context) }
136+
.map_err(|e| Error::other(format!("Failed to cast context: {}", e)))?;
137+
let context: &AContext = &context;
138+
139+
let action_view = AIntent::ACTION_VIEW(env).map_err(|e| {
140+
Error::other(format!(
141+
"Failed to lookup ACTION_VIEW field constant: {}",
142+
e
143+
))
144+
})?;
145+
146+
let url = JString::new(env, url)
147+
.map_err(|e| Error::other(format!("Failed to create JString for URL: {}", e)))?;
148+
let uri = AUri::parse(env, url)
149+
.map_err(|e| Error::other(format!("Failed to parse URL: {}", e)))?;
150+
151+
// Create new ACTION_VIEW intent with the uri
152+
let intent = AIntent::new_with_url(env, &action_view, &uri)
153+
.map_err(|e| Error::other(format!("Failed to create ACTION_VIEW intent: {}", e)))?;
154+
155+
// `ndk-context only` promises to offer an `android.content.Context` reference and so we
156+
// can't assume that this is an `Activity` (it's also likely to be an `Application`
157+
// reference)
158+
//
159+
// If we have an `Activity` then `startActivity` will automatically try and associate the
160+
// new activity with the `Task` that holds the existing `Activity`.
161+
//
162+
// If we have an `Application` reference then `startActivity` will need the
163+
// `FLAG_ACTIVITY_NEW_TASK` flag to indicate that we expect the new activity to be started
164+
// in a new task (and if we don't the system will throw an exception).
165+
//
166+
// Note: In practice the end result is likely to be the same either way because a http/https
167+
// URL will almost certainly open in a separate browser application - not in any `Task`
168+
// running in the current application.
169+
let maybe_activity = env.as_cast::<AActivity>(&context);
170+
match maybe_activity {
171+
Ok(_activity) => {
172+
// We have an Activity and so the associated Task is implied
173+
context.start_activity(env, &intent)?;
174+
}
175+
Err(_) => {
176+
// If we don't have an `Activity` then we need to add the `ACTIVITY_NEW_TASK` flag
177+
// to the `Intent`, to ensure it can be started from a non-activity context.
178+
let flag_activity_new_task = AIntent::FLAG_ACTIVITY_NEW_TASK(env)?;
179+
let intent = intent.add_flags(env, flag_activity_new_task)?;
180+
context.start_activity(env, &intent)?;
181+
}
182+
}
183+
184+
Ok(())
185+
})
186+
.map_err(|e| Error::other(format!("Failed to open URL via Android Intent: {}", e)))?;
95187

96188
Ok(())
97189
}
@@ -117,13 +209,10 @@ fn try_for_termux(url: &str, options: &BrowserOptions) -> Result<()> {
117209
if status.success() {
118210
Ok(())
119211
} else {
120-
Err(Error::new(
121-
ErrorKind::Other,
122-
"command present but exited unsuccessfully",
123-
))
212+
Err(Error::other("command present but exited unsuccessfully"))
124213
}
125214
})
126215
} else {
127-
Err(Error::new(ErrorKind::Other, "Not a termux environment"))
216+
Err(Error::other("Not a termux environment"))
128217
}
129218
}

tests/test-android-app/Cargo.toml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
[package]
22
name = "test-android-app"
33
version = "0.1.0"
4-
edition = "2021"
4+
edition = "2024"
55

66
[lib]
77
name = "test_android_app"
8-
crate-type = ["lib", "cdylib"]
8+
crate-type = ["cdylib"]
99

1010
[dependencies]
11-
jni = "0.19"
12-
ndk-glue = "0.7"
11+
android-activity = { version = "0.6", features = ["native-activity"] }
1312
webbrowser = { path = "../.." }
1413

1514
[package.metadata.android.sdk]

tests/test-android-app/src/lib.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
#[allow(non_snake_case)]
2-
extern crate ndk_glue;
3-
41
const SERVER_URL: &str = "http://127.0.0.1";
52

6-
#[cfg_attr(target_os = "android", ndk_glue::main(backtrace = "on"))]
7-
pub fn android_main() {
3+
#[unsafe(no_mangle)]
4+
pub fn android_main(_app: android_activity::AndroidApp) {
85
println!("****** [WEBB DEBUG] {} ***** begin", SERVER_URL);
96
webbrowser::open(SERVER_URL).unwrap();
107
println!("****** [WEBB DEBUG] {} ***** end", SERVER_URL);

tests/test-android-app/src/main.rs

Lines changed: 0 additions & 5 deletions
This file was deleted.

0 commit comments

Comments
 (0)