Add AndroidApp::run_on_java_main_thread

This makes it easy to schedule boxed closures to be run on the Java main
/ ui thread.

When the closure is run then:
- Any panic will be caught, so we don't unwind into the Looper and abort
  the process
- The JVM will be attached (for JNI) and any exceptions that are thrown
  will be caught and logged as errors.
- A JNI stack frame will be pushed and popped before running your closure
 (so you don't have to worry about leaking local JNI references)

This bumps the jni dependency to 0.22.4 because that version adds a
`JCharSequence` binding that we use in the `Toast` example in the
documentation.
This commit is contained in:
Robert Bragg
2026-03-12 21:40:17 +00:00
parent 43de2770b9
commit 0c32e9d8fa
8 changed files with 417 additions and 40 deletions
+2
View File
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- input: TextInputAction enum representing action button types on soft keyboards.
- input: InputEvent::TextAction event for handling action button presses from soft keyboards.
- The `ndk` and `ndk-sys` crates are now re-exported under `android_activity::ndk` and `android_activity::ndk_sys` ([#194](https://github.com/rust-mobile/android-activity/pull/194))
- `AndroidApp::java_main_looper()` gives access to the `ALooper` for the Java main / UI thread ([#198](https://github.com/rust-mobile/android-activity/pull/198))
- `AndroidApp::run_on_java_main_thread()` can be used to run boxed closures on the Java main / UI thread ([#232](https://github.com/rust-mobile/android-activity/pull/232))
### Changed
- rust-version bumped to 1.85.0 ([#193](https://github.com/rust-mobile/android-activity/pull/193), [#219](https://github.com/rust-mobile/android-activity/pull/219))
+4 -1
View File
@@ -28,7 +28,7 @@ api-level-30 = ["ndk/api-level-30"]
[dependencies]
log = "0.4"
simd_cesu8 = { version = "1.0.1", optional = true }
jni = "0.22.2"
jni = "0.22.4"
ndk-sys = "0.6.0"
ndk = { version = "0.9.0", default-features = false }
ndk-context = "0.1.1"
@@ -41,6 +41,9 @@ thiserror = "1"
[build-dependencies]
cc = { version = "1.0.42", features = ["parallel"] }
[dev-dependencies]
jni = "0.22.4"
[package.metadata.docs.rs]
targets = [
"aarch64-linux-android",
+27 -5
View File
@@ -22,6 +22,7 @@ use ndk::configuration::Configuration;
use ndk::native_window::NativeWindow;
use crate::error::InternalResult;
use crate::main_callbacks::MainCallbacks;
use crate::util::{
abort_on_panic, forward_stdio_to_logcat, init_android_main_thread, log_panic,
try_get_path_from_ptr,
@@ -111,6 +112,7 @@ impl AndroidApp {
ptr: NonNull<ffi::android_app>,
jvm: jni::JavaVM,
main_looper: ndk::looper::ForeignLooper,
main_callbacks: MainCallbacks,
) -> Self {
// We attach to the thread before creating the AndroidApp
jvm.with_local_frame(10, |env| -> jni::errors::Result<_> {
@@ -132,6 +134,7 @@ impl AndroidApp {
config: ConfigurationRef::new(config),
native_window: Default::default(),
main_looper,
main_callbacks,
key_maps: Mutex::new(HashMap::new()),
input_receiver: Mutex::new(None),
})),
@@ -285,6 +288,8 @@ pub struct AndroidAppInner {
config: ConfigurationRef,
native_window: RwLock<Option<NativeWindow>>,
pub(crate) main_callbacks: MainCallbacks,
/// Looper associated with the activity's Java main thread, sometimes called
/// the UI thread.
main_looper: ndk::looper::ForeignLooper,
@@ -623,6 +628,13 @@ impl AndroidAppInner {
}
}
pub fn run_on_java_main_thread<F>(&self, f: Box<F>)
where
F: FnOnce() + Send + 'static,
{
self.main_callbacks.run_on_java_main_thread(f);
}
pub fn config(&self) -> ConfigurationRef {
self.config.clone()
}
@@ -1011,13 +1023,23 @@ pub unsafe extern "C" fn _rust_glue_entry(native_app: *mut ffi::android_app) {
// SAFETY: We know jni_activity is a valid JNI global ref to an Activity instance
let jni_activity = unsafe { env.as_cast_raw::<Global<JObject>>(&jni_activity)? };
if let Err(err) = init_android_main_thread(&jvm, &jni_activity) {
eprintln!("Failed to name Java thread and set thread context class loader: {err}");
}
let main_callbacks = match init_android_main_thread(&jvm, &jni_activity, &main_looper) {
Ok(main_callbacks) => main_callbacks,
Err(err) => {
eprintln!(
"Failed to name Java thread and set thread context class loader: {err}"
);
return Err(err);
}
};
unsafe {
let app =
AndroidApp::new(NonNull::new(native_app).unwrap(), jvm.clone(), main_looper);
let app = AndroidApp::new(
NonNull::new(native_app).unwrap(),
jvm.clone(),
main_looper,
main_callbacks,
);
// We want to specifically catch any panic from the application's android_main
// so we can finish + destroy the Activity gracefully via the JVM
catch_unwind(|| {
+87
View File
@@ -190,6 +190,8 @@ mod jni_utils;
mod waker;
pub use waker::AndroidAppWaker;
mod main_callbacks;
/// A rectangle with integer edge coordinates. Used to represent window insets, for example.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Rect {
@@ -678,6 +680,91 @@ impl AndroidApp {
self.inner.read().unwrap().create_waker()
}
/// Runs the given closure on the Java main / UI thread.
///
/// This is useful for performing operations that must be executed on the
/// main thread, such as interacting with Android SDK APIs that require
/// execution on the main thread.
///
/// Any panic within the closure will be caught and logged as an error,
/// (assuming your application is built to allow unwinding).
///
/// The thread will be attached to the JVM (for using JNI) and any
/// un-cleared Java exceptions left over by the callback will be caught,
/// cleared and logged as an error.
///
/// There is no built-in mechanism to propagate results back to the caller
/// but you can use channels or other synchronization primitives that you
/// capture.
///
/// It's important to avoid blocking the `android_main` thread while waiting
/// for any results because this could lead to deadlocks for `Activity`
/// callbacks that require a synchronous response for the `android_activity`
/// thread.
///
/// # Example
///
/// This example demonstrates using the `jni` 0.22 API to show a toast
/// message from the Java main thread.
///
/// ```no_run
/// use android_activity::AndroidApp;
/// use jni::{objects::JString, refs::Global};
///
/// jni::bind_java_type! { Context => "android.content.Context" }
/// jni::bind_java_type! {
/// Activity => "android.app.Activity",
/// type_map {
/// Context => "android.content.Context",
/// },
/// is_instance_of {
/// context: Context
/// },
/// }
///
/// jni::bind_java_type! {
/// Toast => "android.widget.Toast",
/// type_map {
/// Context => "android.content.Context",
/// },
/// methods {
/// static fn make_text(context: Context, text: JCharSequence, duration: i32) -> Toast,
/// fn show(),
/// }
/// }
///
/// enum ToastDuration {
/// Short = 0,
/// Long = 1,
/// }
///
/// fn send_toast(outer_app: &AndroidApp, msg: impl AsRef<str>, duration: ToastDuration) {
/// let app = outer_app.clone();
/// let msg = msg.as_ref().to_string();
/// outer_app.run_on_java_main_thread(Box::new(move || {
/// let jvm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr() as _) };
/// // As an micro optimization you could use jvm.with_top_local_frame, since we know
/// // we're already attached
/// if let Err(err) = jvm.attach_current_thread(|env| -> jni::errors::Result<()> {
/// let activity: jni::sys::jobject = app.activity_as_ptr() as _;
/// let activity = unsafe { env.as_cast_raw::<Global<Activity>>(&activity)? };
/// let message = JString::new(env, &msg)?;
/// let toast = Toast::make_text(env, activity.as_ref(), &message, duration as i32)?;
/// toast.show(env)?;
/// Ok(())
/// }) {
/// log::error!("Failed to show toast on main thread: {err:?}");
/// }
/// }));
/// }
/// ```
pub fn run_on_java_main_thread<F>(&self, f: Box<F>)
where
F: FnOnce() + Send + 'static,
{
self.inner.read().unwrap().run_on_java_main_thread(f);
}
/// Returns a **reference** to this application's [`ndk::configuration::Configuration`].
///
/// # Warning
+226
View File
@@ -0,0 +1,226 @@
use jni::vm::JavaVM;
use std::{
ffi::c_void,
panic::{catch_unwind, AssertUnwindSafe},
sync::{atomic::AtomicBool, Arc, Mutex, Weak},
};
use crate::util::abort_on_panic;
struct CallbackBuffers {
pub front: Vec<Box<dyn FnOnce() + Send>>,
pub back: Vec<Box<dyn FnOnce() + Send>>,
}
impl std::fmt::Debug for CallbackBuffers {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CallbackBuffers")
.field("front", &self.front.len())
.field("back", &self.back.len())
.finish()
}
}
impl CallbackBuffers {
pub fn take_front(&mut self) -> Vec<Box<dyn FnOnce() + Send>> {
std::mem::swap(&mut self.front, &mut self.back);
std::mem::take(&mut self.back)
}
// After calling `take_front` and draining callbacks then the empty
// vec should be put back so the capacity can be reused
//
// The given `back` vector must be empty
pub fn replace_back(&mut self, back: Vec<Box<dyn FnOnce() + Send>>) {
assert!(back.is_empty());
self.back = back;
}
}
#[derive(Debug)]
pub(crate) struct MainCallbacksState {
_pending_detach: AtomicBool,
event_fd: libc::c_int,
callbacks: Mutex<CallbackBuffers>,
}
impl Drop for MainCallbacksState {
fn drop(&mut self) {
eprintln!("Dropping MainCallbacksState");
log::warn!("Dropping MainCallbacksState");
}
}
#[derive(Debug, Clone)]
pub(crate) struct MainCallbacks {
inner: Arc<MainCallbacksState>,
}
impl std::ops::Deref for MainCallbacks {
type Target = MainCallbacksState;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl MainCallbacks {
pub fn new(java_main_looper: &ndk::looper::ForeignLooper) -> Self {
let java_main_callbacks_event_fd =
unsafe { libc::eventfd(0, libc::EFD_NONBLOCK | libc::EFD_CLOEXEC) };
assert_ne!(
java_main_callbacks_event_fd, -1,
"Failed to create Java main looper event fd"
);
let inner = Arc::new(MainCallbacksState {
_pending_detach: AtomicBool::new(false),
event_fd: java_main_callbacks_event_fd,
callbacks: Mutex::new(CallbackBuffers {
front: Vec::new(),
back: Vec::new(),
}),
});
let weak = Arc::downgrade(&inner);
let weak = weak.into_raw();
unsafe {
ndk_sys::ALooper_addFd(
java_main_looper.ptr().as_ptr(),
java_main_callbacks_event_fd,
ndk_sys::ALOOPER_POLL_CALLBACK,
ndk_sys::ALOOPER_EVENT_INPUT as libc::c_int,
Some(run_java_main_callbacks),
weak as _,
);
}
Self { inner }
}
pub fn wake_java_main_for_callbacks(&self) {
let count: u64 = 1;
loop {
match unsafe {
libc::write(self.event_fd, &count as *const _ as *const libc::c_void, 8)
} {
8 => break,
-1 => {
let err = std::io::Error::last_os_error();
if err.kind() != std::io::ErrorKind::Interrupted {
log::error!("Failure waking up java main loop: {}", err);
return;
}
}
count => {
log::error!("Spurious write of {count} bytes while waking up java main loop");
return;
}
}
}
}
pub fn run_on_java_main_thread<F>(&self, f: Box<F>)
where
F: FnOnce() + Send + 'static,
{
{
let mut guard = self.callbacks.lock().unwrap();
guard.front.push(f);
}
self.wake_java_main_for_callbacks();
}
// Asynchronously detach the callbacks event fd from the Java main looper
//
// Note: we can't do this synchronously because ALooper_removeFd can't
// guarantee that there isn't already a callback pending (which will still
// require a valid data pointer)
//
// Since the java main Looper runs for the lifetime of the application
// process we never actually expect to detach the callbacks event fd, and in
// the unlikely case where there is no future callback after calling
// `wake_java_main_for_callbacks` then the event fd and `MainCallbacks` will
// be leaked - but the implication is that the process is about to terminate
// (otherwise the Looper would still be running)
pub fn _detach_callbacks_event_fd_from_java_main_looper(&mut self) {
self._pending_detach
.store(true, std::sync::atomic::Ordering::SeqCst);
self.wake_java_main_for_callbacks();
}
}
unsafe extern "C" fn run_java_main_callbacks(fd: i32, events: i32, data: *mut c_void) -> i32 {
abort_on_panic(|| {
// Reset the eventfd counter
if events & ndk_sys::ALOOPER_EVENT_INPUT as i32 != 0 {
let counter: u64 = 0;
loop {
match unsafe { libc::read(fd, &counter as *const _ as *mut libc::c_void, 8) } {
8 => break,
-1 => {
let error = std::io::Error::last_os_error();
if error.kind() != std::io::ErrorKind::Interrupted {
log::error!("Error reading from fd: {:?}", error);
break;
}
}
count => {
log::error!("Unexpected read count from event fd: {}", count);
}
}
}
}
let weak_ptr: *const MainCallbacksState = data.cast();
let weak_ref = Weak::from_raw(weak_ptr);
let maybe_upgraded = weak_ref.upgrade();
// Make sure we don't Drop the Weak reference (so the data pointer
// remains valid for future callbacks)
let _ = weak_ref.into_raw();
if let Some(main_callbacks) = maybe_upgraded {
if main_callbacks
._pending_detach
.load(std::sync::atomic::Ordering::SeqCst)
{
let _ = unsafe { libc::close(main_callbacks.event_fd) };
let _drop_weak = Weak::from_raw(weak_ptr);
// Returning zero indicates that the fd / callback should be
// removed from the Looper
return 0;
}
let mut callbacks = main_callbacks.callbacks.lock().unwrap().take_front();
let jvm = JavaVM::singleton().unwrap();
for callback in callbacks.drain(0..) {
let res = jvm.attach_current_thread(|_env| -> jni::errors::Result<()> {
let res = catch_unwind(AssertUnwindSafe(|| {
callback();
}));
if let Err(err) = res {
log::error!("Panic in Java main/UI thread callback: {:?}", err);
}
Ok(())
});
if let Err(err) = res {
log::error!(
"JNI Error while running Java main/UI thread callback: {:?}",
err
);
}
}
// put callbacks vec back so we can keep reusing its capacity
let mut guard = main_callbacks.callbacks.lock().unwrap();
guard.replace_back(callbacks);
}
1
})
}
+12 -6
View File
@@ -898,13 +898,19 @@ fn rust_glue_entry(
// SAFETY: We know jni_activity is a valid JNI global ref to an Activity instance
let jni_activity = unsafe { env.as_cast_raw::<Global<JObject>>(&jni_activity)? };
if let Err(err) = init_android_main_thread(&jvm, &jni_activity) {
eprintln!(
"Failed to name Java thread and set thread context class loader: {err}"
);
}
let main_callbacks =
match init_android_main_thread(&jvm, &jni_activity, &main_looper) {
Ok(callbacks) => callbacks,
Err(err) => {
eprintln!(
"Failed to name Java thread and set thread context class loader: {err}"
);
return Err(err);
}
};
let app = AndroidApp::new(rust_glue.clone(), jvm.clone(), main_looper);
let app =
AndroidApp::new(rust_glue.clone(), jvm.clone(), main_looper, main_callbacks);
rust_glue.notify_main_thread_running();
@@ -13,6 +13,7 @@ use ndk::input_queue::InputQueue;
use ndk::{asset::AssetManager, native_window::NativeWindow};
use crate::error::InternalResult;
use crate::main_callbacks::MainCallbacks;
use crate::{
util, AndroidApp, AndroidAppWaker, ConfigurationRef, InputStatus, MainEvent, PollEvent, Rect,
WindowManagerFlags,
@@ -66,6 +67,7 @@ impl AndroidApp {
native_activity: NativeActivityGlue,
jvm: JavaVM,
main_looper: ndk::looper::ForeignLooper,
main_callbacks: MainCallbacks,
) -> Self {
jvm.with_local_frame(10, |env| -> jni::errors::Result<_> {
if let Err(err) = crate::input::jni_init(env) {
@@ -84,6 +86,7 @@ impl AndroidApp {
native_activity,
looper,
main_looper,
main_callbacks,
key_maps: Mutex::new(HashMap::new()),
input_receiver: Mutex::new(None),
})),
@@ -118,6 +121,8 @@ pub(crate) struct AndroidAppInner {
pub(crate) native_activity: NativeActivityGlue,
main_callbacks: MainCallbacks,
/// Looper associated with the Rust `android_main` thread
looper: ndk::looper::ForeignLooper,
@@ -294,6 +299,13 @@ impl AndroidAppInner {
unsafe { AndroidAppWaker::new(self.looper_as_ptr()) }
}
pub fn run_on_java_main_thread<F>(&self, f: Box<F>)
where
F: FnOnce() + Send + 'static,
{
self.main_callbacks.run_on_java_main_thread(f);
}
pub fn config(&self) -> ConfigurationRef {
self.native_activity.config()
}
+47 -28
View File
@@ -15,6 +15,8 @@ use std::{
sync::OnceLock,
};
use crate::main_callbacks::MainCallbacks;
pub fn try_get_path_from_ptr(path: *const c_char) -> Option<std::path::PathBuf> {
if path.is_null() {
return None;
@@ -118,10 +120,14 @@ pub(crate) fn abort_on_panic<R>(f: impl FnOnce() -> R) -> R {
})
}
static NDK_CONTEXT_ONCE: OnceLock<()> = OnceLock::new();
struct AppState {
main_callbacks: MainCallbacks,
}
static APP_ONCE: OnceLock<AppState> = OnceLock::new();
// Get the Application instance from the Activity
fn get_application<'local, 'any>(
pub(crate) fn get_application<'local, 'any>(
env: &mut jni::Env<'local>,
activity: &JObject<'any>,
) -> jni::errors::Result<JObject<'local>> {
@@ -136,40 +142,53 @@ fn get_application<'local, 'any>(
Ok(app)
}
fn try_init_current_thread(env: &mut jni::Env, activity: &JObject) -> jni::errors::Result<()> {
let activity_class = env.get_object_class(activity)?;
let class_loader = activity_class.get_class_loader(env)?;
let thread = JThread::current_thread(env)?;
thread.set_context_class_loader(env, &class_loader)?;
let thread_name = JString::from_jni_str(env, jni_str!("android_main"))?;
thread.set_name(env, &thread_name)?;
// Also name native thread - this needs to happen here after attaching to a JVM thread,
// since that changes the thread name to something like "Thread-2".
unsafe {
let thread_name = std::ffi::CStr::from_bytes_with_nul(b"android_main\0").unwrap();
let _ = libc::pthread_setname_np(libc::pthread_self(), thread_name.as_ptr());
}
Ok(())
}
/// Name the Java Thread + native thread "android_main" and set the Java Thread context class loader
/// so that jni code can more-easily find non-system Java classes.
pub(crate) fn init_android_main_thread(
vm: &JavaVM,
jni_activity: &JObject,
) -> jni::errors::Result<()> {
vm.with_local_frame(10, |env| -> jni::errors::Result<()> {
let activity_class = env.get_object_class(jni_activity)?;
java_main_looper: &ndk::looper::ForeignLooper,
) -> jni::errors::Result<MainCallbacks> {
vm.with_local_frame(10, |env| -> jni::errors::Result<_> {
let app_state = APP_ONCE.get_or_init(|| unsafe {
let application =
get_application(env, jni_activity).expect("Failed to get Application instance");
let app_global = env
.new_global_ref(application)
.expect("Failed to create global ref for Application");
// Make sure we don't delete the global reference via Drop
let app_global = app_global.into_raw();
ndk_context::initialize_android_context(vm.get_raw().cast(), app_global.cast());
if let Ok(application) = get_application(env, jni_activity) {
NDK_CONTEXT_ONCE.get_or_init(|| unsafe {
let app_global = env
.new_global_ref(application)
.expect("Failed to create global ref for Application");
// Make sure we don't delete the global reference via Drop
let app_global = app_global.into_raw();
ndk_context::initialize_android_context(vm.get_raw().cast(), app_global.cast());
});
let main_callbacks = MainCallbacks::new(java_main_looper);
AppState { main_callbacks }
});
if let Err(err) = try_init_current_thread(env, jni_activity) {
eprintln!("Failed to initialize Java thread state: {:?}", err);
}
let class_loader = activity_class.get_class_loader(env)?;
let main_callbacks = app_state.main_callbacks.clone();
let thread = JThread::current_thread(env)?;
thread.set_context_class_loader(env, &class_loader)?;
let thread_name = JString::from_jni_str(env, jni_str!("android_main"))?;
thread.set_name(env, &thread_name)?;
// Also name native thread - this needs to happen here after attaching to a JVM thread,
// since that changes the thread name to something like "Thread-2".
unsafe {
let thread_name = std::ffi::CStr::from_bytes_with_nul(b"android_main\0").unwrap();
let _ = libc::pthread_setname_np(libc::pthread_self(), thread_name.as_ptr());
}
Ok(())
Ok(main_callbacks)
})
}