From 4ea6cdc81354e00ceae28ef3f394517bc7c25f88 Mon Sep 17 00:00:00 2001 From: brunocrazy15-art Date: Sat, 24 Oct 2026 05:05:42 +0100 Subject: [PATCH] native-activity: opt-in non-blocking lifecycle callbacks, and let the activity own the IME Both are for activities that do more than a bare NativeActivity: `set_nonblocking_callbacks(true)` covers a main thread that is parked inside a modal operation (a SAF file picker via startActivityForResult, in practice). The JVM-thread lifecycle callbacks block on a condvar until `poll_events` runs; if the main thread is parked in that pick, the system ANRs the app after ~5s. With the flag on, a callback delivers its command to the pipe and waits at most 100ms, and the main thread picks the state up when it resumes pumping. Duplicate InputQueueChanged/InitWindow commands are coalesced so the pipe does not fill while nobody drains it. `show_soft_input`/`hide_soft_input` first call `imeShow()`/`imeHide()` on the activity when it has them. A bare NativeActivity has no editable view, so `showSoftInput` on the decor view is unreliable and text never arrives; an activity generated for the app can host a real EditText and forward the IME result. Activities without the methods fall through to the existing decor-view path. --- android-activity/CHANGELOG.md | 10 ++ android-activity/src/lib.rs | 2 + android-activity/src/native_activity/glue.rs | 146 ++++++++++++++++--- android-activity/src/native_activity/mod.rs | 34 +++++ 4 files changed, 168 insertions(+), 24 deletions(-) diff --git a/android-activity/CHANGELOG.md b/android-activity/CHANGELOG.md index 5d8795d..8bf3a4f 100644 --- a/android-activity/CHANGELOG.md +++ b/android-activity/CHANGELOG.md @@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- native-activity: `set_nonblocking_callbacks()`, for apps that park the Rust main thread inside a + modal operation (a file picker, say). While enabled, the JVM-thread lifecycle callbacks deliver + their state and return within a bounded wait instead of blocking until the main thread pumps + events, which is what makes the system ANR the app after a few seconds. +- native-activity: `show_soft_input()` / `hide_soft_input()` call the activity's `imeShow()` / + `imeHide()` when it provides them, so an activity that hosts a real editable view can own the + soft keyboard instead of the bare decor view. + ## [0.6.1] - 2026-03-30 ### Added diff --git a/android-activity/src/lib.rs b/android-activity/src/lib.rs index 64fac0d..c2d6827 100644 --- a/android-activity/src/lib.rs +++ b/android-activity/src/lib.rs @@ -447,6 +447,8 @@ impl From for Rect { } } +#[cfg(feature = "native-activity")] +pub use activity_impl::set_nonblocking_callbacks; pub use activity_impl::StateLoader; pub use activity_impl::StateSaver; diff --git a/android-activity/src/native_activity/glue.rs b/android-activity/src/native_activity/glue.rs index 0c63e49..9bd6fdf 100644 --- a/android-activity/src/native_activity/glue.rs +++ b/android-activity/src/native_activity/glue.rs @@ -6,9 +6,42 @@ use std::{ ops::Deref, panic::catch_unwind, ptr::{self, NonNull}, - sync::{Arc, Condvar, Mutex, Weak}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Condvar, Mutex, Weak, + }, + time::Duration, }; +/// SolidCraft: while `true`, the JVM-thread lifecycle callbacks below (`set_input`, `set_window`, +/// `set_activity_state`, `request_save_state`) must not block waiting for the Rust main +/// thread to run `poll_events`. +/// +/// The Rust main thread can be parked for a long time inside a modal (SAF) file picker; +/// if a JVM callback blocks on the condition variable while it is parked, the Android +/// system ANRs the app after ~5 seconds. With this flag set the callbacks deliver their +/// state through the command pipe and return within a short bounded wait (or immediately), +/// and the Rust main thread consumes the pending state once it resumes pumping events. +/// +/// This is process-wide on purpose: it is toggled around a blocking pick no matter which +/// thread initiated the pick, and restored as soon as the pick completes. +static CALLBACKS_NONBLOCKING: AtomicBool = AtomicBool::new(false); + +/// SolidCraft: enables/disables the bounded (non-blocking) behavior of JVM-thread callbacks. +/// +/// See [`CALLBACKS_NONBLOCKING`] for why this exists. Only toggle this around a modal +/// operation that prevents the Rust main thread from pumping events (e.g. a blocking +/// file picker), and restore it as soon as the operation completes. +pub fn set_nonblocking_callbacks(nonblocking: bool) { + CALLBACKS_NONBLOCKING.store(nonblocking, Ordering::SeqCst); +} + +/// Upper bound on how long a JVM callback may wait for the Rust main thread while +/// [`CALLBACKS_NONBLOCKING`] is enabled. Short enough that Android never ANRs (input +/// dispatch / lifecycle callbacks get multiple seconds), long enough that a main thread +/// that *is* pumping events usually syncs within the same callback. +const NONBLOCKING_WAIT: Duration = Duration::from_millis(100); + use jni::{objects::JObject, refs::Global, vm::AttachConfig}; use ndk::{configuration::Configuration, input_queue::InputQueue, native_window::NativeWindow}; @@ -246,6 +279,14 @@ pub struct NativeActivityState { pub pending_input_queue: *mut ndk_sys::AInputQueue, pub pending_window: Option, + + /// True while an `InputQueueChanged` command sits in the command pipe that has not + /// been consumed by `pre_exec_cmd` yet. Used to avoid piling up duplicate commands + /// (and to keep the *latest* pending value authoritative) while callbacks run in + /// non-blocking mode and the main thread can't pump the pipe. + pub input_cmd_pending: bool, + /// Same as `input_cmd_pending`, but for the `InitWindow` command. + pub window_cmd_pending: bool, } impl NativeActivityState { @@ -392,6 +433,8 @@ impl WaitableNativeActivityState { destroyed: false, pending_input_queue: ptr::null_mut(), pending_window: None, + input_cmd_pending: false, + window_cmd_pending: false, }), cond: Condvar::new(), } @@ -444,7 +487,12 @@ impl WaitableNativeActivityState { // from Android can never arrive at an interim state, and validates that Android: // 1. Only provides resizes in between onNativeWindowCreated and onNativeWindowDestroyed; // 2. Doesn't call it on a bogus window pointer that we don't know about. - debug_assert_eq!(guard.window.as_ref().unwrap().ptr().as_ptr(), native_window); + // In non-blocking mode (or while a window transition is still queued behind a parked + // main thread) `.window` may legitimately not reflect the new window yet, so only + // assert when the callback can't race a pending transition. + if !CALLBACKS_NONBLOCKING.load(Ordering::SeqCst) && guard.window.is_some() { + debug_assert_eq!(guard.window.as_ref().unwrap().ptr().as_ptr(), native_window); + } guard.write_cmd(AppCmd::WindowResized); } @@ -454,52 +502,79 @@ impl WaitableNativeActivityState { // from Android can never arrive at an interim state, and validates that Android: // 1. Only provides resizes in between onNativeWindowCreated and onNativeWindowDestroyed; // 2. Doesn't call it on a bogus window pointer that we don't know about. - debug_assert_eq!(guard.window.as_ref().unwrap().ptr().as_ptr(), native_window); + // See `notify_window_resized` for why the assert is conditional. + if !CALLBACKS_NONBLOCKING.load(Ordering::SeqCst) && guard.window.is_some() { + debug_assert_eq!(guard.window.as_ref().unwrap().ptr().as_ptr(), native_window); + } guard.write_cmd(AppCmd::WindowRedrawNeeded); } unsafe fn set_input(&self, input_queue: *mut ndk_sys::AInputQueue) { let mut guard = self.mutex.lock().unwrap(); - // The pending_input_queue state should only be set while in this method, and since - // it doesn't allow re-entrance and is cleared before returning then we expect - // this to be null - debug_assert!( - guard.pending_input_queue.is_null(), - "InputQueue update clash" - ); + let nonblocking = CALLBACKS_NONBLOCKING.load(Ordering::SeqCst); + if !nonblocking { + // The pending_input_queue state should only be set while in this method, and since + // it doesn't allow re-entrance and is cleared before returning then we expect + // this to be null + debug_assert!( + guard.pending_input_queue.is_null(), + "InputQueue update clash" + ); + } + // Track the value we're delivering locally: `pre_exec_cmd` owns clearing + // `pending_input_queue` (it reads the *latest* value written there), so the wait + // below must not depend on the pending field still holding our value. + let expected = input_queue; guard.pending_input_queue = input_queue; - guard.write_cmd(AppCmd::InputQueueChanged); - while guard.thread_state == NativeThreadState::Running - && guard.input_queue != guard.pending_input_queue - { + if !guard.input_cmd_pending { + guard.write_cmd(AppCmd::InputQueueChanged); + guard.input_cmd_pending = true; + } + while guard.thread_state == NativeThreadState::Running && guard.input_queue != expected { + if nonblocking { + // Main thread is (potentially) parked inside a modal picker: give it a + // short chance to pump the command pipe, then return so the JVM thread + // is never blocked for long (an ANR would follow ~5s of blocking). + let (g, _) = self.cond.wait_timeout(guard, NONBLOCKING_WAIT).unwrap(); + drop(g); + break; + } guard = self.cond.wait(guard).unwrap(); } - guard.pending_input_queue = ptr::null_mut(); } unsafe fn set_window(&self, window: Option) { let mut guard = self.mutex.lock().unwrap(); - // The pending_window state should only be set while in this method, and since - // it doesn't allow re-entrance and is cleared before returning then we expect - // this to be None - debug_assert!(guard.pending_window.is_none(), "NativeWindow update clash"); + let nonblocking = CALLBACKS_NONBLOCKING.load(Ordering::SeqCst); + if !nonblocking { + // The pending_window state should only be set while in this method, and since + // it doesn't allow re-entrance and is cleared before returning then we expect + // this to be None + debug_assert!(guard.pending_window.is_none(), "NativeWindow update clash"); + } if guard.window.is_some() { guard.write_cmd(AppCmd::TermWindow); } + // See `set_input`: the pending field may be re-written (or cleared by + // `pre_exec_cmd`) while we wait, so compare against our local expectation. + let expected = window.clone(); guard.pending_window = window; - if guard.pending_window.is_some() { + if guard.pending_window.is_some() && !guard.window_cmd_pending { guard.write_cmd(AppCmd::InitWindow); + guard.window_cmd_pending = true; } - while guard.thread_state == NativeThreadState::Running - && guard.window != guard.pending_window - { + while guard.thread_state == NativeThreadState::Running && guard.window != expected { + if nonblocking { + let (g, _) = self.cond.wait_timeout(guard, NONBLOCKING_WAIT).unwrap(); + drop(g); + break; + } guard = self.cond.wait(guard).unwrap(); } - guard.pending_window = None; } unsafe fn set_content_rect(&self, rect: *const ndk_sys::ARect) { @@ -521,6 +596,13 @@ impl WaitableNativeActivityState { guard.write_cmd(cmd); while guard.thread_state == NativeThreadState::Running && guard.activity_state != state { + if CALLBACKS_NONBLOCKING.load(Ordering::SeqCst) { + // Delivered through the command pipe; the state will be applied as soon + // as the (possibly parked) main thread resumes pumping events. + let (g, _) = self.cond.wait_timeout(guard, NONBLOCKING_WAIT).unwrap(); + drop(g); + break; + } guard = self.cond.wait(guard).unwrap(); } } @@ -528,6 +610,14 @@ impl WaitableNativeActivityState { fn request_save_state(&self) -> (*mut libc::c_void, libc::size_t) { let mut guard = self.mutex.lock().unwrap(); + if CALLBACKS_NONBLOCKING.load(Ordering::SeqCst) { + // The main thread can't service a `SaveState` command while it's parked in a + // modal picker, and waiting here would stall the JVM thread (risking an ANR). + // Return whatever state was saved previously (normally none) right away. + drop(guard); + return (ptr::null_mut(), 0); + } + // The state_saved flag should only be set while in this method, and since // it doesn't allow re-entrance and is cleared before returning then we expect // this to be None @@ -608,11 +698,19 @@ impl WaitableNativeActivityState { if !guard.input_queue.is_null() { guard.attach_input_queue_to_looper(looper, input_queue_ident); } + // Own clearing the pending state here: in non-blocking mode the setter may + // have already returned without waiting, and it may even have dropped a + // newer value into `pending_*` after we consumed an earlier command. The + // setters wait on their own local expectation, so clearing is safe. + guard.pending_input_queue = ptr::null_mut(); + guard.input_cmd_pending = false; self.cond.notify_one(); } AppCmd::InitWindow => { let mut guard = self.mutex.lock().unwrap(); guard.window = guard.pending_window.clone(); + guard.pending_window = None; + guard.window_cmd_pending = false; self.cond.notify_one(); } AppCmd::Resume | AppCmd::Start | AppCmd::Pause | AppCmd::Stop => { diff --git a/android-activity/src/native_activity/mod.rs b/android-activity/src/native_activity/mod.rs index 5634f11..7a49c4e 100644 --- a/android-activity/src/native_activity/mod.rs +++ b/android-activity/src/native_activity/mod.rs @@ -27,6 +27,7 @@ use crate::input::{ }; mod glue; +pub use self::glue::set_nonblocking_callbacks; use self::glue::NativeActivityGlue; pub const LOOPER_ID_MAIN: libc::c_int = 1; @@ -375,6 +376,23 @@ impl AndroidAppInner { let res = self .jvm .attach_current_thread(|env| -> jni::errors::Result<()> { + // SolidCraft: prefer the Activity's own IME host view when it provides one. A bare NativeActivity + // has no editable view, so showSoftInput() on the decor view is unreliable and never + // delivers text; the generated Activity focuses a real editable EditText instead. + // Activities that do not opt in fall through to the stock decor-view call. + if env + .call_method( + self.activity.as_ref(), + jni::jni_str!("imeShow"), + jni::jni_sig!("()V"), + &[], + ) + .is_ok() + { + return Ok(()); + } + let _ = env.exception_clear(); + let activity = env.as_cast::(self.activity.as_ref())?; let ims = Context::INPUT_METHOD_SERVICE(env)?; @@ -409,6 +427,22 @@ impl AndroidAppInner { let res = self .jvm .attach_current_thread(|env| -> jni::errors::Result<()> { + // Mirror `show_soft_input`: when the Activity owns an IME host view, dismiss the + // keyboard through it (and release its focus). Falls through to the decor view + // otherwise. + if env + .call_method( + self.activity.as_ref(), + jni::jni_str!("imeHide"), + jni::jni_sig!("()V"), + &[], + ) + .is_ok() + { + return Ok(()); + } + let _ = env.exception_clear(); + let activity = env.as_cast::(self.activity.as_ref())?; let ims = Context::INPUT_METHOD_SERVICE(env)?;