From 944a0e15dbd1f515ac92c121d66d6a21ed2b0443 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Mon, 5 Oct 2026 08:20:18 +0000 Subject: [PATCH 01/16] android: expose GPUI's accessibility tree to TalkBack GPUI 0.3.7 builds an AccessKit tree and hands it to PlatformWindow::a11y_init / a11y_tree_update, which the Android window left as no-ops. Wire them to accesskit_android's InjectingAdapter, which installs an AccessibilityDelegate on the existing host View, so hosts need no Java changes. accesskit_android 0.7.5 is the newest release on accesskit 0.24 (the version gpui-pre 0.3.7 uses). It raises accessibility events without checking AccessibilityManager.isEnabled(), and Android throws on the UI thread when no service is listening, so updates are skipped while accessibility is off (0.9 has this check but needs accesskit 0.25). Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.toml | 3 + src/android/accessibility.rs | 191 +++++++++++++++++++++++++++++++++++ src/android/jni.rs | 1 + src/android/mod.rs | 1 + src/android/window.rs | 8 ++ 5 files changed, 204 insertions(+) create mode 100644 src/android/accessibility.rs diff --git a/Cargo.toml b/Cargo.toml index 10f46d8a..d1598236 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,6 +119,9 @@ android_logger = "0.15" # Android uses Cosmic Text and system fonts; font-kit pulls in desktop Fontconfig. gpui_wgpu = { package = "gpui-pre-wgpu", version = "=0.3.8" } +# TalkBack: exposes GPUI's AccessKit tree (accesskit 0.24, as gpui-pre). +accesskit_android = { version = "0.7.5", features = ["embedded-dex"] } + # rustc-hash is required by the PlatformKeyboardMapper trait signature rustc-hash = "2" uuid = { version = "1", features = ["v5"] } diff --git a/src/android/accessibility.rs b/src/android/accessibility.rs new file mode 100644 index 00000000..dbbd00fb --- /dev/null +++ b/src/android/accessibility.rs @@ -0,0 +1,191 @@ +//! Screen-reader support: hands GPUI's AccessKit tree to TalkBack. +//! +//! GPUI builds an AccessKit tree every frame once a screen reader is active and passes +//! it to `PlatformWindow::a11y_tree_update`. `accesskit_android`'s [`InjectingAdapter`] +//! exposes that tree as virtual `AccessibilityNodeInfo`s by installing an +//! `AccessibilityDelegate` on an existing `View`, so hosts need no Java changes. +//! +//! Node bounds are in surface pixels, so the `View` must line up with GPUI's surface: +//! the decor view on the `android-activity` path (the native surface fills the window) +//! and the content view on the host-driven path (it holds the `SurfaceView`). +//! +//! There is one GPUI window per Activity, so the state is global. A recreated Activity +//! has new views; [`super::jni::set_host_activity`] moves the adapter onto them. +//! +//! Android has no "screen reader stopped" callback for this adapter, so once TalkBack +//! has been used GPUI keeps building the tree until the process ends. Updates are +//! dropped while accessibility is off: this adapter version raises events without +//! checking, and Android throws on the UI thread when nobody is listening +//! (`accesskit_android` 0.9 checks, but needs `accesskit` 0.25). + +use std::sync::{Arc, Mutex}; + +use accesskit_android::{ + jni::{ + objects::{GlobalRef, JObject}, + JNIEnv, JavaVM, + }, + InjectingAdapter, +}; +use gpui::{ + accesskit::{ActionHandler, ActionRequest, ActivationHandler, TreeUpdate}, + A11yCallbacks, +}; + +static BRIDGE: Mutex> = Mutex::new(None); + +/// `android.R.id.content`. +const CONTENT_VIEW_ID: i32 = 0x0102_0002; + +struct Bridge { + callbacks: Handlers, + adapter: Option, +} + +struct Adapter { + inner: InjectingAdapter, + vm: JavaVM, + /// `android.view.accessibility.AccessibilityManager`. + manager: GlobalRef, +} + +// SAFETY: `InjectingAdapter` holds a `JavaVM`, global/weak references and a +// `&'static` global class reference, all valid on any thread; its methods attach to +// the JVM and post UI work to the host `View` themselves. +unsafe impl Send for Adapter {} + +/// GPUI's callbacks, shared by every adapter a recreated Activity gets. +#[derive(Clone)] +struct Handlers(Arc>); + +impl ActivationHandler for Handlers { + fn request_initial_tree(&mut self) -> Option { + (self.0.lock().expect("poisoned").activation)() + } +} + +impl ActionHandler for Handlers { + fn do_action(&mut self, request: ActionRequest) { + (self.0.lock().expect("poisoned").action)(request) + } +} + +/// `PlatformWindow::a11y_init`, on the GPUI thread. +pub(crate) fn init(callbacks: A11yCallbacks) { + let mut bridge = BRIDGE.lock().expect("poisoned"); + bridge + .insert(Bridge { + callbacks: Handlers(Arc::new(Mutex::new(callbacks))), + adapter: None, + }) + .attach(); +} + +/// `PlatformWindow::a11y_tree_update`, on the GPUI thread that called [`init`]. +pub(crate) fn update(tree: TreeUpdate) { + let mut bridge = BRIDGE.lock().expect("poisoned"); + let Some(adapter) = bridge.as_mut().and_then(|b| b.adapter.as_mut()) else { + return; + }; + if adapter.is_accessibility_enabled() { + adapter.inner.update_if_active(|| tree); + } +} + +/// A new Activity was registered; move the adapter onto its views. +pub(crate) fn activity_changed() { + if let Some(bridge) = BRIDGE.lock().expect("poisoned").as_mut() { + bridge.attach(); + } +} + +impl Bridge { + fn attach(&mut self) { + // Dropping the old adapter removes its delegate from the old view. + self.adapter = None; + let callbacks = self.callbacks.clone(); + match with_host_view(|env, view| { + let manager = accessibility_manager(env, view)?; + Ok(Adapter { + inner: InjectingAdapter::new(env, view, callbacks.clone(), callbacks), + vm: env.get_java_vm()?, + manager, + }) + }) { + Ok(adapter) => self.adapter = Some(adapter), + Err(err) => log::warn!("accessibility: cannot attach to the host view: {err}"), + } + } +} + +impl Adapter { + fn is_accessibility_enabled(&self) -> bool { + let enabled = self.vm.get_env().and_then(|mut env| { + env.call_method(&self.manager, "isEnabled", "()Z", &[])?.z() + }); + enabled.unwrap_or_else(|err| { + log::warn!("accessibility: AccessibilityManager.isEnabled failed: {err}"); + false + }) + } +} + +fn accessibility_manager( + env: &mut JNIEnv, + view: &JObject, +) -> accesskit_android::jni::errors::Result { + let context = env + .call_method(view, "getContext", "()Landroid/content/Context;", &[])? + .l()?; + let name = env.new_string("accessibility")?; + let manager = env + .call_method( + &context, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[(&name).into()], + )? + .l()?; + env.new_global_ref(manager) +} + +fn with_host_view( + f: impl FnOnce(&mut JNIEnv, &JObject) -> accesskit_android::jni::errors::Result, +) -> Result { + let vm = super::jni::java_vm(); + let activity = super::jni::activity_as_ptr(); + if vm.is_null() || activity.is_null() { + return Err("JavaVM or Activity not available".into()); + } + // SAFETY: both pointers come from the running JVM; the Activity is a global + // reference that `super::jni` keeps alive. + let vm = unsafe { JavaVM::from_raw(vm.cast()) }.map_err(|e| e.to_string())?; + let activity = unsafe { JObject::from_raw(activity.cast()) }; + // `InjectingAdapter::update_if_active` expects the calling thread to stay attached. + let mut env = vm + .attach_current_thread_permanently() + .map_err(|e| e.to_string())?; + env.with_local_frame(8, |env| { + let window = env + .call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])? + .l()?; + let mut view = env + .call_method(&window, "getDecorView", "()Landroid/view/View;", &[])? + .l()?; + if super::jni::android_app().is_none() { + view = env + .call_method( + &view, + "findViewById", + "(I)Landroid/view/View;", + &[CONTENT_VIEW_ID.into()], + )? + .l()?; + } + if view.is_null() { + return Ok(Err("the Activity has no content view".into())); + } + Ok::<_, accesskit_android::jni::errors::Error>(Ok(f(env, &view)?)) + }) + .map_err(|e| e.to_string())? +} diff --git a/src/android/jni.rs b/src/android/jni.rs index 03ff1df9..7a7c074c 100644 --- a/src/android/jni.rs +++ b/src/android/jni.rs @@ -344,6 +344,7 @@ fn register_host_activity(env: &mut jni::Env<'_>, activity: &JObject<'_>) -> Res } } } + super::accessibility::activity_changed(); Ok(()) } diff --git a/src/android/mod.rs b/src/android/mod.rs index 1811cc48..eefff4dc 100644 --- a/src/android/mod.rs +++ b/src/android/mod.rs @@ -149,6 +149,7 @@ pub fn size(width: T, height: T) -> Size { // ── sub-modules ────────────────────────────────────────────────────────────── +mod accessibility; pub mod dispatcher; pub mod display; mod document_picker; diff --git a/src/android/window.rs b/src/android/window.rs index efdcee02..6b343dab 100644 --- a/src/android/window.rs +++ b/src/android/window.rs @@ -1547,6 +1547,14 @@ impl PlatformWindow for AndroidPlatformWindow { } } + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + super::accessibility::init(callbacks); + } + + fn a11y_tree_update(&self, tree_update: gpui::accesskit::TreeUpdate) { + super::accessibility::update(tree_update); + } + fn prompt( &self, _level: PromptLevel, From 5e07a53bdb1f5d4086ca4afd0ac60c304a95139e Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Mon, 5 Oct 2026 08:28:05 +0000 Subject: [PATCH 02/16] android: narrow accessibility bridge visibility to pub(super) Only the sibling window and jni modules call it, matching document_picker and text_input. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/android/accessibility.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/android/accessibility.rs b/src/android/accessibility.rs index dbbd00fb..73e9a2b9 100644 --- a/src/android/accessibility.rs +++ b/src/android/accessibility.rs @@ -71,7 +71,7 @@ impl ActionHandler for Handlers { } /// `PlatformWindow::a11y_init`, on the GPUI thread. -pub(crate) fn init(callbacks: A11yCallbacks) { +pub(super) fn init(callbacks: A11yCallbacks) { let mut bridge = BRIDGE.lock().expect("poisoned"); bridge .insert(Bridge { @@ -82,7 +82,7 @@ pub(crate) fn init(callbacks: A11yCallbacks) { } /// `PlatformWindow::a11y_tree_update`, on the GPUI thread that called [`init`]. -pub(crate) fn update(tree: TreeUpdate) { +pub(super) fn update(tree: TreeUpdate) { let mut bridge = BRIDGE.lock().expect("poisoned"); let Some(adapter) = bridge.as_mut().and_then(|b| b.adapter.as_mut()) else { return; @@ -93,7 +93,7 @@ pub(crate) fn update(tree: TreeUpdate) { } /// A new Activity was registered; move the adapter onto its views. -pub(crate) fn activity_changed() { +pub(super) fn activity_changed() { if let Some(bridge) = BRIDGE.lock().expect("poisoned").as_mut() { bridge.attach(); } From 3d4ed5147ec591fd6c9a5c78eab929e7cca3f8f2 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Mon, 5 Oct 2026 08:36:27 +0000 Subject: [PATCH 03/16] android: drop a redundant unsafe impl Send InjectingAdapter is already Send (its JavaVM, WeakRef, global JClass and Arc> fields all are in jni 0.21), so the bridge state can live in the static Mutex without it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/android/accessibility.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/android/accessibility.rs b/src/android/accessibility.rs index 73e9a2b9..d287282b 100644 --- a/src/android/accessibility.rs +++ b/src/android/accessibility.rs @@ -49,11 +49,6 @@ struct Adapter { manager: GlobalRef, } -// SAFETY: `InjectingAdapter` holds a `JavaVM`, global/weak references and a -// `&'static` global class reference, all valid on any thread; its methods attach to -// the JVM and post UI work to the host `View` themselves. -unsafe impl Send for Adapter {} - /// GPUI's callbacks, shared by every adapter a recreated Activity gets. #[derive(Clone)] struct Handlers(Arc>); From 04735afe795be2e15bf1eb7f35f700ce39695f08 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Mon, 5 Oct 2026 14:34:24 +0000 Subject: [PATCH 04/16] android: let a later screen reader activate GPUI again Once accessibility turns off, tell GPUI to stop building trees and swap in an adapter that is not yet active. Before, the adapter stayed active after TalkBack was turned off, so turning it back on never re-activated GPUI and TalkBack read a stale tree until something repainted; GPUI also kept building trees nobody read. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/android/accessibility.rs | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/android/accessibility.rs b/src/android/accessibility.rs index d287282b..a8a695ef 100644 --- a/src/android/accessibility.rs +++ b/src/android/accessibility.rs @@ -12,11 +12,11 @@ //! There is one GPUI window per Activity, so the state is global. A recreated Activity //! has new views; [`super::jni::set_host_activity`] moves the adapter onto them. //! -//! Android has no "screen reader stopped" callback for this adapter, so once TalkBack -//! has been used GPUI keeps building the tree until the process ends. Updates are -//! dropped while accessibility is off: this adapter version raises events without -//! checking, and Android throws on the UI thread when nobody is listening -//! (`accesskit_android` 0.9 checks, but needs `accesskit` 0.25). +//! This adapter version has no "screen reader stopped" callback and raises events +//! without checking whether anyone listens, which Android answers by throwing on the +//! UI thread (`accesskit_android` 0.9 checks, but needs `accesskit` 0.25). So each +//! update first asks `AccessibilityManager.isEnabled()`; once it is off, GPUI is told +//! to stop building trees and a fresh adapter waits for the next screen reader. use std::sync::{Arc, Mutex}; @@ -79,12 +79,20 @@ pub(super) fn init(callbacks: A11yCallbacks) { /// `PlatformWindow::a11y_tree_update`, on the GPUI thread that called [`init`]. pub(super) fn update(tree: TreeUpdate) { let mut bridge = BRIDGE.lock().expect("poisoned"); - let Some(adapter) = bridge.as_mut().and_then(|b| b.adapter.as_mut()) else { + let Some(bridge) = bridge.as_mut() else { + return; + }; + let Some(adapter) = bridge.adapter.as_mut() else { return; }; if adapter.is_accessibility_enabled() { adapter.inner.update_if_active(|| tree); + return; } + // The screen reader has gone. Stop GPUI building trees, and swap in an adapter + // that is not yet active, so the next screen reader activates GPUI again. + (bridge.callbacks.0.lock().expect("poisoned").deactivation)(); + bridge.attach(); } /// A new Activity was registered; move the adapter onto its views. @@ -115,9 +123,10 @@ impl Bridge { impl Adapter { fn is_accessibility_enabled(&self) -> bool { - let enabled = self.vm.get_env().and_then(|mut env| { - env.call_method(&self.manager, "isEnabled", "()Z", &[])?.z() - }); + let enabled = self + .vm + .get_env() + .and_then(|mut env| env.call_method(&self.manager, "isEnabled", "()Z", &[])?.z()); enabled.unwrap_or_else(|err| { log::warn!("accessibility: AccessibilityManager.isEnabled failed: {err}"); false From d0c7702e760f3c6f2a61713b67a0b80d1d1b83ec Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Mon, 5 Oct 2026 14:50:08 +0000 Subject: [PATCH 05/16] android: let touch exploration reach the accessibility delegate On the android-activity (NativeActivity) path every motion event goes to the native input queue first, and hover events were reported as handled even though GPUI skips them. With TalkBack on, touch exploration arrives as hover events, so the decor view's accessibility delegate never saw them and TalkBack found nothing under the finger. Report them unhandled so Android passes them on, as touches on platform views already are. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/android/jni.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/android/jni.rs b/src/android/jni.rs index 7a7c074c..0ca54e64 100644 --- a/src/android/jni.rs +++ b/src/android/jni.rs @@ -583,6 +583,18 @@ fn process_input_events(app: &AndroidApp) { } }; + // GPUI does not use hover on mobile. With a screen reader on, + // touch exploration arrives as hover events, which the + // accessibility delegate on the decor view has to see. + if matches!( + action, + MotionAction::HoverEnter + | MotionAction::HoverMove + | MotionAction::HoverExit + ) { + return android_activity::InputStatus::Unhandled; + } + if hits_platform_view { log::debug!( "process_input_events: touch hits platform view, skipping GPUI dispatch", From dce52a2210b96ca73c6afc0a3df88282819d6aed Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Fri, 9 Oct 2026 07:22:43 +0000 Subject: [PATCH 06/16] android: let the active window own the screen reader Since #26 each host Activity has its own window, and going back re-attaches an earlier one. The bridge kept the last window's callbacks and view, so the returning window got no screen reader. Each window now keeps its callbacks, takes the adapter over when it becomes active, and the window it replaces is told to stop building trees. Co-Authored-By: Claude Opus 5.5 --- src/android/accessibility.rs | 42 +++++++++++++++++++++++++++--------- src/android/window.rs | 18 ++++++++++++++-- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/android/accessibility.rs b/src/android/accessibility.rs index a8a695ef..f6a47932 100644 --- a/src/android/accessibility.rs +++ b/src/android/accessibility.rs @@ -9,8 +9,9 @@ //! the decor view on the `android-activity` path (the native surface fills the window) //! and the content view on the host-driven path (it holds the `SurfaceView`). //! -//! There is one GPUI window per Activity, so the state is global. A recreated Activity -//! has new views; [`super::jni::set_host_activity`] moves the adapter onto them. +//! Only the active window's Activity is on screen, so one adapter serves the active +//! window, and a window that becomes active takes it over. A recreated Activity has +//! new views; [`super::jni::set_host_activity`] moves the adapter onto them. //! //! This adapter version has no "screen reader stopped" callback and raises events //! without checking whether anyone listens, which Android answers by throwing on the @@ -49,9 +50,19 @@ struct Adapter { manager: GlobalRef, } -/// GPUI's callbacks, shared by every adapter a recreated Activity gets. +/// One window's GPUI callbacks, shared by every adapter its Activity gets. #[derive(Clone)] -struct Handlers(Arc>); +pub(super) struct Handlers(Arc>); + +impl Handlers { + pub(super) fn new(callbacks: A11yCallbacks) -> Self { + Self(Arc::new(Mutex::new(callbacks))) + } + + fn deactivate(&self) { + (self.0.lock().expect("poisoned").deactivation)(); + } +} impl ActivationHandler for Handlers { fn request_initial_tree(&mut self) -> Option { @@ -65,21 +76,32 @@ impl ActionHandler for Handlers { } } -/// `PlatformWindow::a11y_init`, on the GPUI thread. -pub(super) fn init(callbacks: A11yCallbacks) { +/// Point the screen reader at this window, on the GPUI thread. Called from +/// `PlatformWindow::a11y_init` and when the window becomes active again. +pub(super) fn init(callbacks: &Handlers) { let mut bridge = BRIDGE.lock().expect("poisoned"); + if let Some(previous) = bridge.as_ref() { + if Arc::ptr_eq(&previous.callbacks.0, &callbacks.0) { + return; + } + // The previous window is off screen; stop it building trees. + previous.callbacks.deactivate(); + } bridge .insert(Bridge { - callbacks: Handlers(Arc::new(Mutex::new(callbacks))), + callbacks: callbacks.clone(), adapter: None, }) .attach(); } /// `PlatformWindow::a11y_tree_update`, on the GPUI thread that called [`init`]. -pub(super) fn update(tree: TreeUpdate) { +pub(super) fn update(callbacks: &Handlers, tree: TreeUpdate) { let mut bridge = BRIDGE.lock().expect("poisoned"); - let Some(bridge) = bridge.as_mut() else { + let Some(bridge) = bridge + .as_mut() + .filter(|bridge| Arc::ptr_eq(&bridge.callbacks.0, &callbacks.0)) + else { return; }; let Some(adapter) = bridge.adapter.as_mut() else { @@ -91,7 +113,7 @@ pub(super) fn update(tree: TreeUpdate) { } // The screen reader has gone. Stop GPUI building trees, and swap in an adapter // that is not yet active, so the next screen reader activates GPUI again. - (bridge.callbacks.0.lock().expect("poisoned").deactivation)(); + bridge.callbacks.deactivate(); bridge.attach(); } diff --git a/src/android/window.rs b/src/android/window.rs index 6b343dab..a6e862f0 100644 --- a/src/android/window.rs +++ b/src/android/window.rs @@ -397,6 +397,9 @@ pub struct AndroidWindow { /// present an empty image (a black screen). One `force_render` frame /// repopulates it. force_render_once: Arc, + /// GPUI's accessibility callbacks, once a screen reader has been asked for. The + /// screen reader follows whichever window is active. + a11y: std::sync::Mutex>, } // SAFETY: `WindowState` is protected by a `Mutex`. @@ -476,6 +479,7 @@ impl AndroidWindow { id, active: Arc::new(std::sync::atomic::AtomicBool::new(true)), force_render_once: Arc::new(std::sync::atomic::AtomicBool::new(true)), + a11y: Default::default(), })) } @@ -510,6 +514,7 @@ impl AndroidWindow { id: ((width as u64) << 32) | (height as u64), active: Arc::new(std::sync::atomic::AtomicBool::new(false)), force_render_once: Arc::new(std::sync::atomic::AtomicBool::new(true)), + a11y: Default::default(), }) } @@ -986,6 +991,11 @@ impl AndroidWindow { } } log::info!("AndroidWindow::set_active({}) — done", active); + if active { + if let Some(handlers) = self.a11y.lock().expect("poisoned").clone() { + super::accessibility::init(&handlers); + } + } } if active { // A vsync callback posted before the app left the foreground may @@ -1548,11 +1558,15 @@ impl PlatformWindow for AndroidPlatformWindow { } fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { - super::accessibility::init(callbacks); + let handlers = super::accessibility::Handlers::new(callbacks); + *self.window.a11y.lock().expect("poisoned") = Some(handlers.clone()); + super::accessibility::init(&handlers); } fn a11y_tree_update(&self, tree_update: gpui::accesskit::TreeUpdate) { - super::accessibility::update(tree_update); + if let Some(handlers) = self.window.a11y.lock().expect("poisoned").clone() { + super::accessibility::update(&handlers, tree_update); + } } fn prompt( From 7006f5022f7578613a9ceed060f79d3e17fac590 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Fri, 9 Oct 2026 13:26:09 +0000 Subject: [PATCH 07/16] android: Clear the Java exceptions the accessibility bridge leaves pending accesskit_android's jni 0.21 API returns an error for a Java exception but leaves it pending, and the bridge never cleared it, so the next JNI call on the render thread ran with an exception pending. Describe and clear it after the host-view lookups, AccessibilityManager.isEnabled and each tree update. Co-Authored-By: Claude Opus 5.5 --- src/android/accessibility.rs | 38 +++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/android/accessibility.rs b/src/android/accessibility.rs index f6a47932..a086d167 100644 --- a/src/android/accessibility.rs +++ b/src/android/accessibility.rs @@ -109,6 +109,7 @@ pub(super) fn update(callbacks: &Handlers, tree: TreeUpdate) { }; if adapter.is_accessibility_enabled() { adapter.inner.update_if_active(|| tree); + adapter.clear_exception(); return; } // The screen reader has gone. Stop GPUI building trees, and swap in an adapter @@ -144,11 +145,20 @@ impl Bridge { } impl Adapter { + /// Raising events calls into Java; don't leave its exception to the next caller. + fn clear_exception(&self) { + if let Ok(mut env) = self.vm.get_env() { + let _ = clear_exception(&mut env, Ok(())); + } + } + fn is_accessibility_enabled(&self) -> bool { - let enabled = self - .vm - .get_env() - .and_then(|mut env| env.call_method(&self.manager, "isEnabled", "()Z", &[])?.z()); + let enabled = self.vm.get_env().and_then(|mut env| { + let enabled = env + .call_method(&self.manager, "isEnabled", "()Z", &[]) + .and_then(|value| value.z()); + clear_exception(&mut env, enabled) + }); enabled.unwrap_or_else(|err| { log::warn!("accessibility: AccessibilityManager.isEnabled failed: {err}"); false @@ -156,6 +166,20 @@ impl Adapter { } } +/// `accesskit_android`'s jni 0.21 returns an error for a Java exception but leaves it +/// pending, and no further JNI call is allowed until it is cleared. Clear it (after +/// logging it) so it does not surface in the next, unrelated call on this thread. +fn clear_exception( + env: &mut JNIEnv, + result: accesskit_android::jni::errors::Result, +) -> accesskit_android::jni::errors::Result { + if env.exception_check().unwrap_or(false) { + let _ = env.exception_describe(); + let _ = env.exception_clear(); + } + result +} + fn accessibility_manager( env: &mut JNIEnv, view: &JObject, @@ -191,7 +215,7 @@ fn with_host_view( let mut env = vm .attach_current_thread_permanently() .map_err(|e| e.to_string())?; - env.with_local_frame(8, |env| { + let result = env.with_local_frame(8, |env| { let window = env .call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])? .l()?; @@ -212,6 +236,6 @@ fn with_host_view( return Ok(Err("the Activity has no content view".into())); } Ok::<_, accesskit_android::jni::errors::Error>(Ok(f(env, &view)?)) - }) - .map_err(|e| e.to_string())? + }); + clear_exception(&mut env, result).map_err(|e| e.to_string())? } From 6d443bd88b4e736e0963ddbf66cbe639ed442db3 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Mon, 12 Oct 2026 09:37:10 +0000 Subject: [PATCH 08/16] android: return hover events before the platform-view hit test Touch exploration sends a stream of hover events; each paid for a platform-view registry lookup that was then thrown away. Co-Authored-By: Claude Opus 5.5 --- src/android/jni.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/android/jni.rs b/src/android/jni.rs index 0ca54e64..b5262462 100644 --- a/src/android/jni.rs +++ b/src/android/jni.rs @@ -552,6 +552,18 @@ fn process_input_events(app: &AndroidApp) { pointer_count, ); + // GPUI does not use hover on mobile. With a screen reader on, + // touch exploration arrives as hover events, which the + // accessibility delegate on the decor view has to see. + if matches!( + action, + MotionAction::HoverEnter + | MotionAction::HoverMove + | MotionAction::HoverExit + ) { + return android_activity::InputStatus::Unhandled; + } + // Check if this touch lands on a platform view. // // On Android with NativeActivity, ALL touch events go @@ -583,18 +595,6 @@ fn process_input_events(app: &AndroidApp) { } }; - // GPUI does not use hover on mobile. With a screen reader on, - // touch exploration arrives as hover events, which the - // accessibility delegate on the decor view has to see. - if matches!( - action, - MotionAction::HoverEnter - | MotionAction::HoverMove - | MotionAction::HoverExit - ) { - return android_activity::InputStatus::Unhandled; - } - if hits_platform_view { log::debug!( "process_input_events: touch hits platform view, skipping GPUI dispatch", From d0bea7c3a923dfcfbda8d7d1c26801c64c74610f Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Mon, 12 Oct 2026 09:37:24 +0000 Subject: [PATCH 09/16] android: let go of the screen reader when a window closes After HostDestroyed, BRIDGE still held the closed window's callbacks and an adapter on its view: a later screen reader got the dead window's tree, and the next init deactivated a closed window. Closing the window, or dropping it, now drops the bridge and its adapter. Co-Authored-By: Claude Opus 5.5 --- src/android/accessibility.rs | 12 ++++++++++++ src/android/host.rs | 3 +++ src/android/window.rs | 8 ++++++++ 3 files changed, 23 insertions(+) diff --git a/src/android/accessibility.rs b/src/android/accessibility.rs index a086d167..4ed68890 100644 --- a/src/android/accessibility.rs +++ b/src/android/accessibility.rs @@ -118,6 +118,18 @@ pub(super) fn update(callbacks: &Handlers, tree: TreeUpdate) { bridge.attach(); } +/// The window is closing: drop its adapter, which takes the delegate off its view, so +/// a later screen reader cannot reach the closed window's callbacks. +pub(super) fn close(callbacks: &Handlers) { + let mut bridge = BRIDGE.lock().expect("poisoned"); + if bridge + .as_ref() + .is_some_and(|bridge| Arc::ptr_eq(&bridge.callbacks.0, &callbacks.0)) + { + *bridge = None; + } +} + /// A new Activity was registered; move the adapter onto its views. pub(super) fn activity_changed() { if let Some(bridge) = BRIDGE.lock().expect("poisoned").as_mut() { diff --git a/src/android/host.rs b/src/android/host.rs index f6b9c534..9e7c6690 100644 --- a/src/android/host.rs +++ b/src/android/host.rs @@ -352,6 +352,9 @@ fn render_thread(launch: Launch) { app.failed.remove(&host); if let Some(win) = app.windows.remove(&host) { win.term_window(); + // GPUI may keep the window object for a while; the screen + // reader must not. + win.close_accessibility(); platform.close_window(win.id()); log::info!( "gpui-main: host {host} closed, {} window(s) left", diff --git a/src/android/window.rs b/src/android/window.rs index a6e862f0..fae71c56 100644 --- a/src/android/window.rs +++ b/src/android/window.rs @@ -1004,6 +1004,13 @@ impl AndroidWindow { } } + /// Let go of the screen reader, if this window has it. On close and on drop. + pub(super) fn close_accessibility(&self) { + if let Some(handlers) = self.a11y.lock().expect("poisoned").take() { + super::accessibility::close(&handlers); + } + } + // ── transparency ────────────────────────────────────────────────────────── /// Enable or disable transparent compositing. @@ -1392,6 +1399,7 @@ impl From for anyhow::Error { impl Drop for AndroidWindow { fn drop(&mut self) { + self.close_accessibility(); let mut state = self.state.lock(); // Destroy renderer before releasing the native window. From ea97c6dc6312512ed5b14462a50260284d57d327 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Mon, 12 Oct 2026 09:37:35 +0000 Subject: [PATCH 10/16] android: retry attaching the screen reader after a failure init returned early whenever the window's callbacks were already registered, even if the first attach had failed, so accessibility stayed off for that window. It now attaches again while there is no adapter. Co-Authored-By: Claude Opus 5.5 --- src/android/accessibility.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/android/accessibility.rs b/src/android/accessibility.rs index 4ed68890..b31b88f0 100644 --- a/src/android/accessibility.rs +++ b/src/android/accessibility.rs @@ -80,8 +80,13 @@ impl ActionHandler for Handlers { /// `PlatformWindow::a11y_init` and when the window becomes active again. pub(super) fn init(callbacks: &Handlers) { let mut bridge = BRIDGE.lock().expect("poisoned"); - if let Some(previous) = bridge.as_ref() { + if let Some(previous) = bridge.as_mut() { if Arc::ptr_eq(&previous.callbacks.0, &callbacks.0) { + // An earlier attach may have failed (no JavaVM or Activity yet, a JNI + // error); try again rather than leaving the window unreadable. + if previous.adapter.is_none() { + previous.attach(); + } return; } // The previous window is off screen; stop it building trees. From 34f8c96c0cdc617c76815c8fd2c88a14d8bb18c3 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Mon, 12 Oct 2026 09:37:57 +0000 Subject: [PATCH 11/16] android: skip a view that already has an accessibility delegate InjectingAdapter panics on the UI thread when its view already has a delegate, and the panic aborts the process because it runs inside an extern "system" callback. A host app or an androidx layer can set one on android.R.id.content. attach now checks first, logs and leaves the view alone; an earlier adapter's delegate is fine, its removal is queued first. Co-Authored-By: Claude Opus 5.5 --- src/android/accessibility.rs | 44 ++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/android/accessibility.rs b/src/android/accessibility.rs index b31b88f0..02f63239 100644 --- a/src/android/accessibility.rs +++ b/src/android/accessibility.rs @@ -23,7 +23,7 @@ use std::sync::{Arc, Mutex}; use accesskit_android::{ jni::{ - objects::{GlobalRef, JObject}, + objects::{GlobalRef, JObject, JString}, JNIEnv, JavaVM, }, InjectingAdapter, @@ -148,12 +148,19 @@ impl Bridge { self.adapter = None; let callbacks = self.callbacks.clone(); match with_host_view(|env, view| { + // `InjectingAdapter` panics on the UI thread, aborting the process, if the + // view has a delegate already: the host's, or an androidx layer's. + if let Some(class) = foreign_delegate(env, view)? { + return Ok(Err(format!( + "the view already has an accessibility delegate ({class})" + ))); + } let manager = accessibility_manager(env, view)?; - Ok(Adapter { + Ok(Ok(Adapter { inner: InjectingAdapter::new(env, view, callbacks.clone(), callbacks), vm: env.get_java_vm()?, manager, - }) + })) }) { Ok(adapter) => self.adapter = Some(adapter), Err(err) => log::warn!("accessibility: cannot attach to the host view: {err}"), @@ -197,6 +204,33 @@ fn clear_exception( result } +/// The class of `view`'s accessibility delegate, unless it has none or it is an earlier +/// adapter's (whose removal is queued ahead of the new one on the UI thread). +fn foreign_delegate( + env: &mut JNIEnv, + view: &JObject, +) -> accesskit_android::jni::errors::Result> { + let delegate = env + .call_method( + view, + "getAccessibilityDelegate", + "()Landroid/view/View$AccessibilityDelegate;", + &[], + )? + .l()?; + if delegate.is_null() { + return Ok(None); + } + let class = env + .call_method(&delegate, "getClass", "()Ljava/lang/Class;", &[])? + .l()?; + let name = env + .call_method(&class, "getName", "()Ljava/lang/String;", &[])? + .l()?; + let name: String = env.get_string(&JString::from(name))?.into(); + Ok((name != "dev.accesskit.android.Delegate").then_some(name)) +} + fn accessibility_manager( env: &mut JNIEnv, view: &JObject, @@ -217,7 +251,7 @@ fn accessibility_manager( } fn with_host_view( - f: impl FnOnce(&mut JNIEnv, &JObject) -> accesskit_android::jni::errors::Result, + f: impl FnOnce(&mut JNIEnv, &JObject) -> accesskit_android::jni::errors::Result>, ) -> Result { let vm = super::jni::java_vm(); let activity = super::jni::activity_as_ptr(); @@ -252,7 +286,7 @@ fn with_host_view( if view.is_null() { return Ok(Err("the Activity has no content view".into())); } - Ok::<_, accesskit_android::jni::errors::Error>(Ok(f(env, &view)?)) + f(env, &view) }); clear_exception(&mut env, result).map_err(|e| e.to_string())? } From 9418278c3ac84b3165d04d39c194fca4a67141a4 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Mon, 12 Oct 2026 09:38:47 +0000 Subject: [PATCH 12/16] android: attach the screen reader when the window becomes active, not in onCreate set_host_activity moved the adapter in onCreate, which can run before setContentView; with AppCompat, android.R.id.content then moves to the subDecor's ContentFrameLayout and the delegate sits on an outer frame with offset bounds. The adapter is now attached whenever the window becomes active (on resume and when its surface arrives), and moves when the Activity behind the window is no longer the one it is on. Co-Authored-By: Claude Opus 5.5 --- src/android/accessibility.rs | 83 +++++++++++++++++++++--------------- src/android/jni.rs | 1 - src/android/window.rs | 10 ++--- 3 files changed, 54 insertions(+), 40 deletions(-) diff --git a/src/android/accessibility.rs b/src/android/accessibility.rs index 02f63239..01f1b4e7 100644 --- a/src/android/accessibility.rs +++ b/src/android/accessibility.rs @@ -10,8 +10,10 @@ //! and the content view on the host-driven path (it holds the `SurfaceView`). //! //! Only the active window's Activity is on screen, so one adapter serves the active -//! window, and a window that becomes active takes it over. A recreated Activity has -//! new views; [`super::jni::set_host_activity`] moves the adapter onto them. +//! window, and a window that becomes active takes it over. The adapter is attached when +//! the window becomes active (on resume, and when its surface arrives), after the +//! Activity's `setContentView`: AppCompat moves `android.R.id.content` there. A +//! recreated Activity has new views, so the adapter follows it then too. //! //! This adapter version has no "screen reader stopped" callback and raises events //! without checking whether anyone listens, which Android answers by throwing on the @@ -45,6 +47,8 @@ struct Bridge { struct Adapter { inner: InjectingAdapter, + /// The Activity whose view `inner` is on. + activity: GlobalRef, vm: JavaVM, /// `android.view.accessibility.AccessibilityManager`. manager: GlobalRef, @@ -83,8 +87,8 @@ pub(super) fn init(callbacks: &Handlers) { if let Some(previous) = bridge.as_mut() { if Arc::ptr_eq(&previous.callbacks.0, &callbacks.0) { // An earlier attach may have failed (no JavaVM or Activity yet, a JNI - // error); try again rather than leaving the window unreadable. - if previous.adapter.is_none() { + // error), or the Activity was recreated; attach again in both cases. + if !previous.is_current() { previous.attach(); } return; @@ -135,19 +139,26 @@ pub(super) fn close(callbacks: &Handlers) { } } -/// A new Activity was registered; move the adapter onto its views. -pub(super) fn activity_changed() { - if let Some(bridge) = BRIDGE.lock().expect("poisoned").as_mut() { - bridge.attach(); +impl Bridge { + /// Whether the adapter is on the window's current Activity. + fn is_current(&self) -> bool { + let Some(adapter) = &self.adapter else { + return false; + }; + with_activity(|env, activity| Ok(Ok(env.is_same_object(&adapter.activity, activity)?))) + .unwrap_or(false) } -} -impl Bridge { fn attach(&mut self) { // Dropping the old adapter removes its delegate from the old view. self.adapter = None; let callbacks = self.callbacks.clone(); - match with_host_view(|env, view| { + match with_activity(|env, activity| { + let view = host_view(env, activity)?; + if view.is_null() { + return Ok(Err("the Activity has no content view".into())); + } + let view = &view; // `InjectingAdapter` panics on the UI thread, aborting the process, if the // view has a delegate already: the host's, or an androidx layer's. if let Some(class) = foreign_delegate(env, view)? { @@ -158,6 +169,7 @@ impl Bridge { let manager = accessibility_manager(env, view)?; Ok(Ok(Adapter { inner: InjectingAdapter::new(env, view, callbacks.clone(), callbacks), + activity: env.new_global_ref(activity)?, vm: env.get_java_vm()?, manager, })) @@ -250,7 +262,8 @@ fn accessibility_manager( env.new_global_ref(manager) } -fn with_host_view( +/// Run `f` with the window's Activity, on this thread's (permanently attached) env. +fn with_activity( f: impl FnOnce(&mut JNIEnv, &JObject) -> accesskit_android::jni::errors::Result>, ) -> Result { let vm = super::jni::java_vm(); @@ -266,27 +279,29 @@ fn with_host_view( let mut env = vm .attach_current_thread_permanently() .map_err(|e| e.to_string())?; - let result = env.with_local_frame(8, |env| { - let window = env - .call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])? - .l()?; - let mut view = env - .call_method(&window, "getDecorView", "()Landroid/view/View;", &[])? - .l()?; - if super::jni::android_app().is_none() { - view = env - .call_method( - &view, - "findViewById", - "(I)Landroid/view/View;", - &[CONTENT_VIEW_ID.into()], - )? - .l()?; - } - if view.is_null() { - return Ok(Err("the Activity has no content view".into())); - } - f(env, &view) - }); + let result = env.with_local_frame(8, |env| f(env, &activity)); clear_exception(&mut env, result).map_err(|e| e.to_string())? } + +/// The `View` that lines up with GPUI's surface; null if the Activity has none yet. +fn host_view<'local>( + env: &mut JNIEnv<'local>, + activity: &JObject, +) -> accesskit_android::jni::errors::Result> { + let window = env + .call_method(activity, "getWindow", "()Landroid/view/Window;", &[])? + .l()?; + let view = env + .call_method(&window, "getDecorView", "()Landroid/view/View;", &[])? + .l()?; + if super::jni::android_app().is_some() { + return Ok(view); + } + env.call_method( + &view, + "findViewById", + "(I)Landroid/view/View;", + &[CONTENT_VIEW_ID.into()], + )? + .l() +} diff --git a/src/android/jni.rs b/src/android/jni.rs index b5262462..16266bca 100644 --- a/src/android/jni.rs +++ b/src/android/jni.rs @@ -344,7 +344,6 @@ fn register_host_activity(env: &mut jni::Env<'_>, activity: &JObject<'_>) -> Res } } } - super::accessibility::activity_changed(); Ok(()) } diff --git a/src/android/window.rs b/src/android/window.rs index fae71c56..26b4a2e3 100644 --- a/src/android/window.rs +++ b/src/android/window.rs @@ -991,13 +991,13 @@ impl AndroidWindow { } } log::info!("AndroidWindow::set_active({}) — done", active); - if active { - if let Some(handlers) = self.a11y.lock().expect("poisoned").clone() { - super::accessibility::init(&handlers); - } - } } if active { + // Also when already active: the surface arriving is a later, safer point to + // attach, and a recreated Activity has new views. + if let Some(handlers) = self.a11y.lock().expect("poisoned").clone() { + super::accessibility::init(&handlers); + } // A vsync callback posted before the app left the foreground may // never fire; post afresh so demand raised meanwhile is served. super::frame_source::resume(); From 2b761c155ba5bd55f1d6502d86b4f5a128d6b7c6 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Mon, 12 Oct 2026 09:39:48 +0000 Subject: [PATCH 13/16] android: put the screen reader on the window's own Activity The adapter went on the Activity that most recently called set_host_activity. With host A, then host B, then Back, A resumed but its adapter was installed on B's finishing view, and TalkBack saw A as one opaque surface again. host::set_activity(env, host, activity) does what set_host_activity does and also remembers the Activity per HostId; each window knows its host, so the adapter goes on that host's own Activity. Hosts that only call set_host_activity keep the old behaviour. Co-Authored-By: Claude Opus 5.5 --- src/android/accessibility.rs | 34 ++++++++++++++++------- src/android/host.rs | 54 ++++++++++++++++++++++++++++++++++-- src/android/window.rs | 12 +++++++- 3 files changed, 87 insertions(+), 13 deletions(-) diff --git a/src/android/accessibility.rs b/src/android/accessibility.rs index 01f1b4e7..810bb31d 100644 --- a/src/android/accessibility.rs +++ b/src/android/accessibility.rs @@ -23,6 +23,7 @@ use std::sync::{Arc, Mutex}; +use super::host::HostId; use accesskit_android::{ jni::{ objects::{GlobalRef, JObject, JString}, @@ -54,13 +55,14 @@ struct Adapter { manager: GlobalRef, } -/// One window's GPUI callbacks, shared by every adapter its Activity gets. +/// One window's GPUI callbacks, shared by every adapter its Activity gets, and the +/// window's host on the host-driven path. #[derive(Clone)] -pub(super) struct Handlers(Arc>); +pub(super) struct Handlers(Arc>, Option); impl Handlers { - pub(super) fn new(callbacks: A11yCallbacks) -> Self { - Self(Arc::new(Mutex::new(callbacks))) + pub(super) fn new(callbacks: A11yCallbacks, host: Option) -> Self { + Self(Arc::new(Mutex::new(callbacks)), host) } fn deactivate(&self) { @@ -145,15 +147,17 @@ impl Bridge { let Some(adapter) = &self.adapter else { return false; }; - with_activity(|env, activity| Ok(Ok(env.is_same_object(&adapter.activity, activity)?))) - .unwrap_or(false) + with_activity(self.callbacks.1, |env, activity| { + Ok(Ok(env.is_same_object(&adapter.activity, activity)?)) + }) + .unwrap_or(false) } fn attach(&mut self) { // Dropping the old adapter removes its delegate from the old view. self.adapter = None; let callbacks = self.callbacks.clone(); - match with_activity(|env, activity| { + match with_activity(self.callbacks.1, |env, activity| { let view = host_view(env, activity)?; if view.is_null() { return Ok(Err("the Activity has no content view".into())); @@ -262,17 +266,27 @@ fn accessibility_manager( env.new_global_ref(manager) } -/// Run `f` with the window's Activity, on this thread's (permanently attached) env. +/// Run `f` with the window's own Activity, on this thread's (permanently attached) env. fn with_activity( + host: Option, + f: impl FnOnce(&mut JNIEnv, &JObject) -> accesskit_android::jni::errors::Result>, +) -> Result { + match host { + Some(host) => super::host::with_activity(host, |activity| with_raw_activity(activity, f)), + None => with_raw_activity(super::jni::activity_as_ptr(), f), + } +} + +fn with_raw_activity( + activity: *mut std::ffi::c_void, f: impl FnOnce(&mut JNIEnv, &JObject) -> accesskit_android::jni::errors::Result>, ) -> Result { let vm = super::jni::java_vm(); - let activity = super::jni::activity_as_ptr(); if vm.is_null() || activity.is_null() { return Err("JavaVM or Activity not available".into()); } // SAFETY: both pointers come from the running JVM; the Activity is a global - // reference that `super::jni` keeps alive. + // reference that `super::host` or `super::jni` keeps alive for this call. let vm = unsafe { JavaVM::from_raw(vm.cast()) }.map_err(|e| e.to_string())?; let activity = unsafe { JObject::from_raw(activity.cast()) }; // `InjectingAdapter::update_if_active` expects the calling thread to stay attached. diff --git a/src/android/host.rs b/src/android/host.rs index 9e7c6690..c7ed506e 100644 --- a/src/android/host.rs +++ b/src/android/host.rs @@ -24,8 +24,9 @@ //! From the Java UI thread, in this order: //! //! 1. In `Activity.onCreate` — every time, recreation included — call -//! [`super::jni::set_host_activity`] from a JNI entry point, then [`start`] (a no-op -//! after the first call). `set_host_activity` must come first: it keeps the +//! [`set_activity`] (or [`super::jni::set_host_activity`], which does not tell +//! hosts apart) from a JNI entry point, then [`start`] (a no-op +//! after the first call). `set_activity` must come first: it keeps the //! application's `AssetManager`, which the platform reads the bundled emoji font //! from when it starts. (If `start` runs first, the font is loaded late, when //! `set_host_activity` provides the `AssetManager`.) @@ -59,6 +60,8 @@ //! `ANativeWindow_lock` is a use-after-free. use std::{ + collections::BTreeMap, + ffi::c_void, sync::{ atomic::{AtomicPtr, AtomicU32, AtomicU64, Ordering}, Arc, Mutex, OnceLock, @@ -67,6 +70,7 @@ use std::{ }; use gpui::{App, Application, ApplicationHandle}; +use jni::{objects::JObject, refs::Global}; use ndk::native_window::NativeWindow; use super::platform::{AndroidPlatform, SharedPlatform}; @@ -157,6 +161,11 @@ type Launch = Box; /// with the platform primed to hand GPUI that host's surface. type OpenWindow = Arc; +/// Each host's own Activity, from [`set_activity`]. The screen reader goes on its views: +/// after Back, the newest Activity ([`super::jni::activity_as_ptr`]) is the finishing +/// one, not the one on screen. +static ACTIVITIES: Mutex>>> = Mutex::new(BTreeMap::new()); + /// Which backend a fresh GPU context is built on. See [`set_gpu_backend`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum GpuBackend { @@ -491,6 +500,7 @@ fn on_surface_created( } }; CURRENT_SURFACE.store(incoming, Ordering::SeqCst); + win.set_host(host); win.set_active(true); app.windows.insert(host, Arc::clone(&win)); app.attached = Some(host); @@ -543,6 +553,45 @@ pub fn surface_created(host: HostId, window: NativeWindow, scale: f32) { }); } +/// [`super::jni::set_host_activity`], and remember `activity` as `host`'s own, so +/// what belongs to one host's views (the screen reader) finds that host's Activity. +/// Call from `Activity.onCreate`, every time, recreation included. +pub fn set_activity( + env: &mut jni::Env<'_>, + host: HostId, + activity: &JObject<'_>, +) -> Result<(), String> { + super::jni::set_host_activity(env, activity)?; + if super::jni::android_app().is_some() { + return Ok(()); + } + match env.new_global_ref(activity) { + Ok(activity) => { + ACTIVITIES.lock().expect("poisoned").insert(host, activity); + Ok(()) + } + Err(err) => { + if env.exception_check() { + env.exception_clear(); + } + Err(err.to_string()) + } + } +} + +/// Run `f` with `host`'s own Activity (a global `jobject`), or with the newest one if +/// the host never named it through [`set_activity`]. The Activity stays referenced +/// until `f` returns. +pub(super) fn with_activity(host: HostId, f: impl FnOnce(*mut c_void) -> T) -> T { + let activities = ACTIVITIES.lock().expect("poisoned"); + let activity = activities + .get(&host) + .map_or_else(super::jni::activity_as_ptr, |activity| { + activity.as_raw() as *mut c_void + }); + f(activity) +} + /// Build the UI for a host window. Called once per [`HostId`] on the render thread, /// with the platform primed so the `cx.open_window` inside lands on that host's /// surface — see [`AndroidPlatform::set_pending_window`]. @@ -561,6 +610,7 @@ where /// Call from `Activity.onDestroy` when the Activity is finishing for good — not on a /// configuration change, where the same [`HostId`] is expected back. pub fn host_destroyed(host: HostId) { + ACTIVITIES.lock().expect("poisoned").remove(&host); post(Command::HostDestroyed { host }); } diff --git a/src/android/window.rs b/src/android/window.rs index 26b4a2e3..29ccfa63 100644 --- a/src/android/window.rs +++ b/src/android/window.rs @@ -400,6 +400,8 @@ pub struct AndroidWindow { /// GPUI's accessibility callbacks, once a screen reader has been asked for. The /// screen reader follows whichever window is active. a11y: std::sync::Mutex>, + /// The host this window belongs to, on the host-driven path. + host: std::sync::OnceLock, } // SAFETY: `WindowState` is protected by a `Mutex`. @@ -480,6 +482,7 @@ impl AndroidWindow { active: Arc::new(std::sync::atomic::AtomicBool::new(true)), force_render_once: Arc::new(std::sync::atomic::AtomicBool::new(true)), a11y: Default::default(), + host: Default::default(), })) } @@ -515,6 +518,7 @@ impl AndroidWindow { active: Arc::new(std::sync::atomic::AtomicBool::new(false)), force_render_once: Arc::new(std::sync::atomic::AtomicBool::new(true)), a11y: Default::default(), + host: Default::default(), }) } @@ -1004,6 +1008,11 @@ impl AndroidWindow { } } + /// Name the host this window belongs to; see [`super::host::set_activity`]. + pub(super) fn set_host(&self, host: super::host::HostId) { + let _ = self.host.set(host); + } + /// Let go of the screen reader, if this window has it. On close and on drop. pub(super) fn close_accessibility(&self) { if let Some(handlers) = self.a11y.lock().expect("poisoned").take() { @@ -1566,7 +1575,8 @@ impl PlatformWindow for AndroidPlatformWindow { } fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { - let handlers = super::accessibility::Handlers::new(callbacks); + let handlers = + super::accessibility::Handlers::new(callbacks, self.window.host.get().copied()); *self.window.a11y.lock().expect("poisoned") = Some(handlers.clone()); super::accessibility::init(&handlers); } From 31d79d8f51ac71eb58bad9fa433e17758002cf96 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Mon, 12 Oct 2026 09:40:37 +0000 Subject: [PATCH 14/16] android: cache the JavaVM and AccessibilityManager for the screen reader with_host_view rebuilt a JavaVM on every attach, and update made its JNI isEnabled() call while holding the BRIDGE lock. Both are cached once, and the check runs before the lock is taken. The docs now say what the check cannot cover: a screen reader turned off between it and the adapter's UI-thread runnable. Co-Authored-By: Claude Opus 5.5 --- src/android/accessibility.rs | 94 +++++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 38 deletions(-) diff --git a/src/android/accessibility.rs b/src/android/accessibility.rs index 810bb31d..0d36bcef 100644 --- a/src/android/accessibility.rs +++ b/src/android/accessibility.rs @@ -15,13 +15,16 @@ //! Activity's `setContentView`: AppCompat moves `android.R.id.content` there. A //! recreated Activity has new views, so the adapter follows it then too. //! -//! This adapter version has no "screen reader stopped" callback and raises events -//! without checking whether anyone listens, which Android answers by throwing on the -//! UI thread (`accesskit_android` 0.9 checks, but needs `accesskit` 0.25). So each -//! update first asks `AccessibilityManager.isEnabled()`; once it is off, GPUI is told -//! to stop building trees and a fresh adapter waits for the next screen reader. +//! This adapter version has no "screen reader stopped" callback, and raises events from +//! a runnable it posts to the UI thread without checking whether anyone listens, which +//! Android answers by throwing there (`accesskit_android` 0.9 checks in that runnable, +//! but needs `accesskit` 0.25). So each update first asks +//! `AccessibilityManager.isEnabled()`; once it is off, GPUI is told to stop building +//! trees and a fresh adapter waits for the next screen reader. A screen reader turned +//! off after that check but before the runnable runs still throws; only the adapter +//! can close that window. -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use super::host::HostId; use accesskit_android::{ @@ -38,6 +41,12 @@ use gpui::{ static BRIDGE: Mutex> = Mutex::new(None); +/// The JavaVM in `accesskit_android`'s jni version. +static VM: OnceLock = OnceLock::new(); + +/// `android.view.accessibility.AccessibilityManager`, one per process. +static MANAGER: OnceLock = OnceLock::new(); + /// `android.R.id.content`. const CONTENT_VIEW_ID: i32 = 0x0102_0002; @@ -50,9 +59,6 @@ struct Adapter { inner: InjectingAdapter, /// The Activity whose view `inner` is on. activity: GlobalRef, - vm: JavaVM, - /// `android.view.accessibility.AccessibilityManager`. - manager: GlobalRef, } /// One window's GPUI callbacks, shared by every adapter its Activity gets, and the @@ -108,6 +114,8 @@ pub(super) fn init(callbacks: &Handlers) { /// `PlatformWindow::a11y_tree_update`, on the GPUI thread that called [`init`]. pub(super) fn update(callbacks: &Handlers, tree: TreeUpdate) { + // Outside the lock: a JNI call every frame should not hold up `init`. + let enabled = is_accessibility_enabled(); let mut bridge = BRIDGE.lock().expect("poisoned"); let Some(bridge) = bridge .as_mut() @@ -118,9 +126,12 @@ pub(super) fn update(callbacks: &Handlers, tree: TreeUpdate) { let Some(adapter) = bridge.adapter.as_mut() else { return; }; - if adapter.is_accessibility_enabled() { + if enabled { adapter.inner.update_if_active(|| tree); - adapter.clear_exception(); + // Raising events calls into Java; don't leave its exception to the next caller. + if let Ok(mut env) = vm().and_then(|vm| vm.get_env().map_err(|e| e.to_string())) { + let _ = clear_exception(&mut env, Ok(())); + } return; } // The screen reader has gone. Stop GPUI building trees, and swap in an adapter @@ -170,12 +181,12 @@ impl Bridge { "the view already has an accessibility delegate ({class})" ))); } - let manager = accessibility_manager(env, view)?; + if MANAGER.get().is_none() { + let _ = MANAGER.set(accessibility_manager(env, view)?); + } Ok(Ok(Adapter { inner: InjectingAdapter::new(env, view, callbacks.clone(), callbacks), activity: env.new_global_ref(activity)?, - vm: env.get_java_vm()?, - manager, })) }) { Ok(adapter) => self.adapter = Some(adapter), @@ -184,26 +195,34 @@ impl Bridge { } } -impl Adapter { - /// Raising events calls into Java; don't leave its exception to the next caller. - fn clear_exception(&self) { - if let Ok(mut env) = self.vm.get_env() { - let _ = clear_exception(&mut env, Ok(())); - } - } +/// `false` until an adapter has been attached. +fn is_accessibility_enabled() -> bool { + let (Some(vm), Some(manager)) = (VM.get(), MANAGER.get()) else { + return false; + }; + let enabled = vm.get_env().and_then(|mut env| { + let enabled = env + .call_method(manager, "isEnabled", "()Z", &[]) + .and_then(|value| value.z()); + clear_exception(&mut env, enabled) + }); + enabled.unwrap_or_else(|err| { + log::warn!("accessibility: AccessibilityManager.isEnabled failed: {err}"); + false + }) +} - fn is_accessibility_enabled(&self) -> bool { - let enabled = self.vm.get_env().and_then(|mut env| { - let enabled = env - .call_method(&self.manager, "isEnabled", "()Z", &[]) - .and_then(|value| value.z()); - clear_exception(&mut env, enabled) - }); - enabled.unwrap_or_else(|err| { - log::warn!("accessibility: AccessibilityManager.isEnabled failed: {err}"); - false - }) +fn vm() -> Result<&'static JavaVM, String> { + if let Some(vm) = VM.get() { + return Ok(vm); + } + let vm = super::jni::java_vm(); + if vm.is_null() { + return Err("JavaVM not available".into()); } + // SAFETY: the pointer comes from the running JVM, which lives as long as the process. + let vm = unsafe { JavaVM::from_raw(vm.cast()) }.map_err(|e| e.to_string())?; + Ok(VM.get_or_init(|| vm)) } /// `accesskit_android`'s jni 0.21 returns an error for a Java exception but leaves it @@ -281,13 +300,12 @@ fn with_raw_activity( activity: *mut std::ffi::c_void, f: impl FnOnce(&mut JNIEnv, &JObject) -> accesskit_android::jni::errors::Result>, ) -> Result { - let vm = super::jni::java_vm(); - if vm.is_null() || activity.is_null() { - return Err("JavaVM or Activity not available".into()); + if activity.is_null() { + return Err("Activity not available".into()); } - // SAFETY: both pointers come from the running JVM; the Activity is a global - // reference that `super::host` or `super::jni` keeps alive for this call. - let vm = unsafe { JavaVM::from_raw(vm.cast()) }.map_err(|e| e.to_string())?; + let vm = vm()?; + // SAFETY: the Activity is a global reference that `super::host` or `super::jni` + // keeps alive for this call. let activity = unsafe { JObject::from_raw(activity.cast()) }; // `InjectingAdapter::update_if_active` expects the calling thread to stay attached. let mut env = vm From fafa6f066bc6d3aef8761a3d359b63a06c1bea99 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Tue, 13 Oct 2026 07:33:18 +0000 Subject: [PATCH 15/16] Android a11y: free accesskit_android's local references per update InjectingAdapter::update_if_active and its Drop create local references (the host view, the posted Runnable) and never delete them. The GPUI thread stays attached, so they piled up every frame and kept old views alive. Run both inside a local reference frame. Co-Authored-By: Claude Opus 5.5 --- src/android/accessibility.rs | 41 ++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/android/accessibility.rs b/src/android/accessibility.rs index 0d36bcef..6bed50ec 100644 --- a/src/android/accessibility.rs +++ b/src/android/accessibility.rs @@ -24,7 +24,10 @@ //! off after that check but before the runnable runs still throws; only the adapter //! can close that window. -use std::sync::{Arc, Mutex, OnceLock}; +use std::{ + mem::ManuallyDrop, + sync::{Arc, Mutex, OnceLock}, +}; use super::host::HostId; use accesskit_android::{ @@ -56,11 +59,18 @@ struct Bridge { } struct Adapter { - inner: InjectingAdapter, + inner: ManuallyDrop, /// The Activity whose view `inner` is on. activity: GlobalRef, } +impl Drop for Adapter { + fn drop(&mut self) { + // SAFETY: `inner` is not used after this. + in_local_frame(|| unsafe { ManuallyDrop::drop(&mut self.inner) }); + } +} + /// One window's GPUI callbacks, shared by every adapter its Activity gets, and the /// window's host on the host-driven path. #[derive(Clone)] @@ -127,7 +137,7 @@ pub(super) fn update(callbacks: &Handlers, tree: TreeUpdate) { return; }; if enabled { - adapter.inner.update_if_active(|| tree); + in_local_frame(|| adapter.inner.update_if_active(|| tree)); // Raising events calls into Java; don't leave its exception to the next caller. if let Ok(mut env) = vm().and_then(|vm| vm.get_env().map_err(|e| e.to_string())) { let _ = clear_exception(&mut env, Ok(())); @@ -185,7 +195,12 @@ impl Bridge { let _ = MANAGER.set(accessibility_manager(env, view)?); } Ok(Ok(Adapter { - inner: InjectingAdapter::new(env, view, callbacks.clone(), callbacks), + inner: ManuallyDrop::new(InjectingAdapter::new( + env, + view, + callbacks.clone(), + callbacks, + )), activity: env.new_global_ref(activity)?, })) }) { @@ -225,6 +240,24 @@ fn vm() -> Result<&'static JavaVM, String> { Ok(VM.get_or_init(|| vm)) } +/// Run `f` in its own local reference frame. `accesskit_android` creates local references +/// in every update and drop and never deletes them, and the GPUI thread stays attached, +/// so they would pile up and keep old views alive. +fn in_local_frame(f: impl FnOnce() -> T) -> T { + // On a thread that is not attached, `accesskit_android` attaches only for the call, + // and detaching frees them. + let Some(env) = VM.get().and_then(|vm| vm.get_env().ok()) else { + return f(); + }; + if env.push_local_frame(16).is_err() { + return f(); + } + let value = f(); + // SAFETY: no local reference from inside the frame is used after it. + let _ = unsafe { env.pop_local_frame(&JObject::null()) }; + value +} + /// `accesskit_android`'s jni 0.21 returns an error for a Java exception but leaves it /// pending, and no further JNI call is allowed until it is cleared. Clear it (after /// logging it) so it does not surface in the next, unrelated call on this thread. From 7dbb1deb414689d901bfada9a8695d7ac1ce23e6 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 14 Oct 2026 11:29:48 +0800 Subject: [PATCH 16/16] =?UTF-8?q?Fix=20Android=20accessibility=20on=20API?= =?UTF-8?q?=2026=E2=80=9328=20and=20disabled=20event=20delivery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Cargo.toml | 2 +- src/android/accessibility.rs | 25 +- src/android/accesskit_adapter.rs | 524 ++++++++++++++++++ src/android/accesskit_classes.dex | Bin 0 -> 3108 bytes src/android/accesskit_java/Delegate.java | 70 +++ src/android/mod.rs | 1 + .../android-accessibility/AndroidManifest.xml | 6 + tests/android-accessibility/Cargo.toml | 14 + .../java/dev/gpui/a11ytest/MainActivity.java | 51 ++ tests/android-accessibility/run.sh | 27 + tests/android-accessibility/src/lib.rs | 116 ++++ 12 files changed, 819 insertions(+), 18 deletions(-) create mode 100644 src/android/accesskit_adapter.rs create mode 100644 src/android/accesskit_classes.dex create mode 100644 src/android/accesskit_java/Delegate.java create mode 100644 tests/android-accessibility/AndroidManifest.xml create mode 100644 tests/android-accessibility/Cargo.toml create mode 100644 tests/android-accessibility/java/dev/gpui/a11ytest/MainActivity.java create mode 100755 tests/android-accessibility/run.sh create mode 100644 tests/android-accessibility/src/lib.rs diff --git a/.gitignore b/.gitignore index 5e8e720c..6afd8c8e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ xcuserdata/ *.apk *.ap_ *.dex +!src/android/accesskit_classes.dex *.class bin/ gen/ diff --git a/Cargo.toml b/Cargo.toml index d1598236..6c8ff0a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,7 +120,7 @@ android_logger = "0.15" gpui_wgpu = { package = "gpui-pre-wgpu", version = "=0.3.8" } # TalkBack: exposes GPUI's AccessKit tree (accesskit 0.24, as gpui-pre). -accesskit_android = { version = "0.7.5", features = ["embedded-dex"] } +accesskit_android = "0.7.5" # rustc-hash is required by the PlatformKeyboardMapper trait signature rustc-hash = "2" diff --git a/src/android/accessibility.rs b/src/android/accessibility.rs index 6bed50ec..e477dd50 100644 --- a/src/android/accessibility.rs +++ b/src/android/accessibility.rs @@ -20,22 +20,20 @@ //! Android answers by throwing there (`accesskit_android` 0.9 checks in that runnable, //! but needs `accesskit` 0.25). So each update first asks //! `AccessibilityManager.isEnabled()`; once it is off, GPUI is told to stop building -//! trees and a fresh adapter waits for the next screen reader. A screen reader turned -//! off after that check but before the runnable runs still throws; only the adapter -//! can close that window. +//! trees and a fresh adapter waits for the next screen reader. The patched injection layer +//! also checks on the UI thread before raising queued events, +//! closing the race between this check and event delivery. use std::{ mem::ManuallyDrop, sync::{Arc, Mutex, OnceLock}, }; +use super::accesskit_adapter::InjectingAdapter; use super::host::HostId; -use accesskit_android::{ - jni::{ - objects::{GlobalRef, JObject, JString}, - JNIEnv, JavaVM, - }, - InjectingAdapter, +use accesskit_android::jni::{ + objects::{GlobalRef, JObject, JString}, + JNIEnv, JavaVM, }; use gpui::{ accesskit::{ActionHandler, ActionRequest, ActivationHandler, TreeUpdate}, @@ -278,14 +276,7 @@ fn foreign_delegate( env: &mut JNIEnv, view: &JObject, ) -> accesskit_android::jni::errors::Result> { - let delegate = env - .call_method( - view, - "getAccessibilityDelegate", - "()Landroid/view/View$AccessibilityDelegate;", - &[], - )? - .l()?; + let delegate = super::accesskit_adapter::get_accessibility_delegate(env, view)?; if delegate.is_null() { return Ok(None); } diff --git a/src/android/accesskit_adapter.rs b/src/android/accesskit_adapter.rs new file mode 100644 index 00000000..ed0790c3 --- /dev/null +++ b/src/android/accesskit_adapter.rs @@ -0,0 +1,524 @@ +//! AccessKit Android 0.7.5 injection layer with compatibility fixes. +//! +//! Derived from https://github.com/AccessKit/accesskit (0.7.5). +//! Uses the public adapter/tree APIs so publishing gpui-mobile retains the fixes. +//! Changes: API 26–28 delegate lookup, UI-thread event enablement check (upstream +//! c4e9eeb), framework class-loader parent on Android 8/9, and preservation of +//! foreign delegates during removal. The unchanged embedded dex and Java source +//! are from the accesskit_android 0.7.5 crates.io release. + +// Copyright 2025 The AccessKit Authors. All rights reserved. +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file) or the MIT license (found in +// the LICENSE-MIT file), at your option. + +// Derived from jni-rs +// Copyright 2016 Prevoty, Inc. and jni-rs contributors +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file) or the MIT license (found in +// the LICENSE-MIT file), at your option. + +use accesskit_android::jni::{ + errors::Result, + objects::{GlobalRef, JClass, JObject, WeakRef}, + sys::{jboolean, jfloat, jint, jlong, JNI_FALSE, JNI_TRUE}, + JNIEnv, JavaVM, NativeMethod, +}; +use gpui::accesskit::{ActionHandler, ActivationHandler, TreeUpdate}; +use log::debug; +use std::{ + collections::BTreeMap, + ffi::c_void, + fmt::{Debug, Formatter}, + sync::{ + atomic::{AtomicI64, Ordering}, + Arc, Mutex, OnceLock, Weak, + }, +}; + +use accesskit_android::{Adapter, PlatformAction, QueuedEvents}; + +struct InnerInjectingAdapter { + adapter: Adapter, + activation_handler: Box, + action_handler: Box, +} + +impl Debug for InnerInjectingAdapter { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("InnerInjectingAdapter") + .field("adapter", &self.adapter) + .field("activation_handler", &"ActivationHandler") + .field("action_handler", &"ActionHandler") + .finish() + } +} + +impl InnerInjectingAdapter { + fn create_accessibility_node_info<'local>( + &mut self, + env: &mut JNIEnv<'local>, + host: &JObject, + virtual_view_id: jint, + ) -> JObject<'local> { + self.adapter.create_accessibility_node_info( + &mut *self.activation_handler, + env, + host, + virtual_view_id, + ) + } + + fn find_focus<'local>( + &mut self, + env: &mut JNIEnv<'local>, + host: &JObject, + focus_type: jint, + ) -> JObject<'local> { + self.adapter + .find_focus(&mut *self.activation_handler, env, host, focus_type) + } + + fn perform_action( + &mut self, + virtual_view_id: jint, + action: &PlatformAction, + ) -> Option { + self.adapter + .perform_action(&mut *self.action_handler, virtual_view_id, action) + } + + fn on_hover_event(&mut self, action: jint, x: jfloat, y: jfloat) -> Option { + self.adapter + .on_hover_event(&mut *self.activation_handler, action, x, y) + } +} + +static NEXT_HANDLE: AtomicI64 = AtomicI64::new(0); +static HANDLE_MAP: Mutex>>> = + Mutex::new(BTreeMap::new()); + +fn inner_adapter_from_handle(handle: jlong) -> Option>> { + let handle_map_guard = HANDLE_MAP.lock().unwrap(); + handle_map_guard.get(&handle).and_then(Weak::upgrade) +} + +static NEXT_CALLBACK_HANDLE: AtomicI64 = AtomicI64::new(0); +#[allow(clippy::type_complexity)] +static CALLBACK_MAP: Mutex< + BTreeMap>, +> = Mutex::new(BTreeMap::new()); + +fn post_to_ui_thread( + env: &mut JNIEnv, + delegate_class: &JClass, + host: &JObject, + callback: impl FnOnce(&mut JNIEnv, &JClass, &JObject) + Send + 'static, +) { + let handle = NEXT_CALLBACK_HANDLE.fetch_add(1, Ordering::Relaxed); + CALLBACK_MAP + .lock() + .unwrap() + .insert(handle, Box::new(callback)); + let runnable = env + .call_static_method( + delegate_class, + "newCallback", + "(Landroid/view/View;J)Ljava/lang/Runnable;", + &[host.into(), handle.into()], + ) + .unwrap() + .l() + .unwrap(); + env.call_method( + host, + "post", + "(Ljava/lang/Runnable;)Z", + &[(&runnable).into()], + ) + .unwrap(); +} + +extern "system" fn run_callback<'local>( + mut env: JNIEnv<'local>, + class: JClass<'local>, + host: JObject<'local>, + handle: jlong, +) { + let Some(callback) = CALLBACK_MAP.lock().unwrap().remove(&handle) else { + return; + }; + callback(&mut env, &class, &host); +} + +extern "system" fn create_accessibility_node_info<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + adapter_handle: jlong, + host: JObject<'local>, + virtual_view_id: jint, +) -> JObject<'local> { + let Some(inner_adapter) = inner_adapter_from_handle(adapter_handle) else { + return JObject::null(); + }; + let mut inner_adapter = inner_adapter.lock().unwrap(); + inner_adapter.create_accessibility_node_info(&mut env, &host, virtual_view_id) +} + +extern "system" fn find_focus<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + adapter_handle: jlong, + host: JObject<'local>, + focus_type: jint, +) -> JObject<'local> { + let Some(inner_adapter) = inner_adapter_from_handle(adapter_handle) else { + return JObject::null(); + }; + let mut inner_adapter = inner_adapter.lock().unwrap(); + inner_adapter.find_focus(&mut env, &host, focus_type) +} + +extern "system" fn perform_action<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + adapter_handle: jlong, + host: JObject<'local>, + virtual_view_id: jint, + action: jint, + arguments: JObject<'local>, +) -> jboolean { + let Some(action) = PlatformAction::from_java(&mut env, action, &arguments) else { + return JNI_FALSE; + }; + let Some(inner_adapter) = inner_adapter_from_handle(adapter_handle) else { + return JNI_FALSE; + }; + let mut inner_adapter = inner_adapter.lock().unwrap(); + let Some(events) = inner_adapter.perform_action(virtual_view_id, &action) else { + return JNI_FALSE; + }; + drop(inner_adapter); + raise_events(events, &mut env, &host); + JNI_TRUE +} + +extern "system" fn on_hover_event<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + adapter_handle: jlong, + host: JObject<'local>, + action: jint, + x: jfloat, + y: jfloat, +) -> jboolean { + let Some(inner_adapter) = inner_adapter_from_handle(adapter_handle) else { + return JNI_FALSE; + }; + let mut inner_adapter = inner_adapter.lock().unwrap(); + let Some(events) = inner_adapter.on_hover_event(action, x, y) else { + return JNI_FALSE; + }; + drop(inner_adapter); + raise_events(events, &mut env, &host); + JNI_TRUE +} + +fn delegate_class(env: &mut JNIEnv) -> &'static JClass<'static> { + static CLASS: OnceLock = OnceLock::new(); + let global = CLASS.get_or_init(|| { + let class = { + let dex_class_loader_class = env + .find_class("dalvik/system/InMemoryDexClassLoader") + .unwrap(); + let dex_bytes = include_bytes!("accesskit_classes.dex"); + let dex_buffer = unsafe { + env.new_direct_byte_buffer(dex_bytes.as_ptr() as *mut u8, dex_bytes.len()) + } + .unwrap(); + // Android 8/9 do not resolve framework classes with a null parent. + // Use View's loader so Delegate can resolve View.AccessibilityDelegate. + let view_class = env.find_class("android/view/View").unwrap(); + let parent = env + .call_method( + &view_class, + "getClassLoader", + "()Ljava/lang/ClassLoader;", + &[], + ) + .unwrap() + .l() + .unwrap(); + let dex_class_loader = env + .new_object( + &dex_class_loader_class, + "(Ljava/nio/ByteBuffer;Ljava/lang/ClassLoader;)V", + &[(&dex_buffer).into(), (&parent).into()], + ) + .unwrap(); + let class_name = env.new_string("dev.accesskit.android.Delegate").unwrap(); + let class_obj = env + .call_method( + &dex_class_loader, + "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;", + &[(&class_name).into()], + ) + .unwrap_or_else(|err| { + let _ = env.exception_describe(); + panic!("loading AccessKit Delegate: {err}"); + }) + .l() + .unwrap(); + JClass::from(class_obj) + }; + env.register_native_methods( + &class, + &[ + NativeMethod { + name: "runCallback".into(), + sig: "(Landroid/view/View;J)V".into(), + fn_ptr: run_callback as *mut c_void, + }, + NativeMethod { + name: "createAccessibilityNodeInfo".into(), + sig: + "(JLandroid/view/View;I)Landroid/view/accessibility/AccessibilityNodeInfo;" + .into(), + fn_ptr: create_accessibility_node_info as *mut c_void, + }, + NativeMethod { + name: "findFocus".into(), + sig: + "(JLandroid/view/View;I)Landroid/view/accessibility/AccessibilityNodeInfo;" + .into(), + fn_ptr: find_focus as *mut c_void, + }, + NativeMethod { + name: "performAction".into(), + sig: "(JLandroid/view/View;IILandroid/os/Bundle;)Z".into(), + fn_ptr: perform_action as *mut c_void, + }, + NativeMethod { + name: "onHoverEvent".into(), + sig: "(JLandroid/view/View;IFF)Z".into(), + fn_ptr: on_hover_event as *mut c_void, + }, + ], + ) + .unwrap(); + env.new_global_ref(class).unwrap() + }); + global.as_obj().into() +} + +/// High-level AccessKit Android adapter that injects itself into an Android +/// view without requiring the view class to be modified for accessibility. +/// +/// Loads the unchanged AccessKit 0.7.5 Delegate class from the embedded dex. +/// Calls may originate on either the GPUI or Android UI thread; installation, +/// removal, and event delivery are posted to the Android UI thread. +pub(super) struct InjectingAdapter { + vm: JavaVM, + delegate_class: &'static JClass<'static>, + host: WeakRef, + handle: jlong, + inner: Arc>, +} + +impl Debug for InjectingAdapter { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("InnerInjectingAdapter") + .field("vm", &self.vm) + .field("delegate_class", &self.delegate_class) + .field("host", &"WeakRef") + .field("handle", &self.handle) + .field("inner", &self.inner) + .finish() + } +} + +impl InjectingAdapter { + pub fn new( + env: &mut JNIEnv, + host: &JObject, + activation_handler: impl 'static + ActivationHandler + Send, + action_handler: impl 'static + ActionHandler + Send, + ) -> Self { + let inner = Arc::new(Mutex::new(InnerInjectingAdapter { + adapter: Adapter::default(), + activation_handler: Box::new(activation_handler), + action_handler: Box::new(action_handler), + })); + let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed); + HANDLE_MAP + .lock() + .unwrap() + .insert(handle, Arc::downgrade(&inner)); + let delegate_class = delegate_class(env); + post_to_ui_thread( + env, + delegate_class, + host, + move |env, delegate_class, host| { + let prev_delegate = get_accessibility_delegate(env, host).unwrap(); + if !prev_delegate.is_null() { + panic!("host already has an accessibility delegate"); + } + let delegate = env + .new_object(delegate_class, "(J)V", &[handle.into()]) + .unwrap(); + env.call_method( + host, + "setAccessibilityDelegate", + "(Landroid/view/View$AccessibilityDelegate;)V", + &[(&delegate).into()], + ) + .unwrap(); + env.call_method( + host, + "setOnHoverListener", + "(Landroid/view/View$OnHoverListener;)V", + &[(&delegate).into()], + ) + .unwrap(); + }, + ); + Self { + vm: env.get_java_vm().unwrap(), + delegate_class, + host: env.new_weak_ref(host).unwrap().unwrap(), + handle, + inner, + } + } + + /// If and only if the tree has been initialized, call the provided function + /// and apply the resulting update. Note: If the caller's implementation of + /// [`ActivationHandler::request_initial_tree`] initially returned `None`, + /// the [`TreeUpdate`] returned by the provided function must contain + /// a full tree. + pub fn update_if_active(&mut self, update_factory: impl FnOnce() -> TreeUpdate) { + let mut env = self.vm.get_env().unwrap(); + let Some(host) = self.host.upgrade_local(&env).unwrap() else { + return; + }; + let mut inner = self.inner.lock().unwrap(); + let Some(events) = inner.adapter.update_if_active(update_factory) else { + return; + }; + drop(inner); + post_to_ui_thread( + &mut env, + self.delegate_class, + &host, + |env, _delegate_class, host| { + raise_events(events, env, host); + }, + ); + } +} + +impl Drop for InjectingAdapter { + fn drop(&mut self) { + fn drop_impl(env: &mut JNIEnv, delegate_class: &JClass, host: &WeakRef) -> Result<()> { + let Some(host) = host.upgrade_local(env)? else { + return Ok(()); + }; + post_to_ui_thread(env, delegate_class, &host, |env, delegate_class, host| { + let prev_delegate = get_accessibility_delegate(env, host).unwrap(); + if prev_delegate.is_null() + || !env.is_instance_of(&prev_delegate, delegate_class).unwrap() + { + return; + } + let null = JObject::null(); + env.call_method( + host, + "setAccessibilityDelegate", + "(Landroid/view/View$AccessibilityDelegate;)V", + &[(&null).into()], + ) + .unwrap(); + env.call_method( + host, + "setOnHoverListener", + "(Landroid/view/View$OnHoverListener;)V", + &[(&null).into()], + ) + .unwrap(); + }); + Ok(()) + } + + let res = match self.vm.get_env() { + Ok(mut env) => drop_impl(&mut env, self.delegate_class, &self.host), + Err(_) => self + .vm + .attach_current_thread() + .and_then(|mut env| drop_impl(&mut env, self.delegate_class, &self.host)), + }; + + if let Err(err) = res { + debug!("error dropping InjectingAdapter: {:#?}", err); + } + + HANDLE_MAP.lock().unwrap().remove(&self.handle); + } +} + +/// Query the existing delegate without calling the API 29 getter on Android 8/9. +/// The field is on Android 9's light greylist, so JNI access remains permitted. +pub(super) fn get_accessibility_delegate<'local>( + env: &mut JNIEnv<'local>, + view: &JObject, +) -> Result> { + let sdk = env + .get_static_field("android/os/Build$VERSION", "SDK_INT", "I")? + .i()?; + if sdk >= 29 { + env.call_method( + view, + "getAccessibilityDelegate", + "()Landroid/view/View$AccessibilityDelegate;", + &[], + )? + .l() + } else { + env.get_field( + view, + "mAccessibilityDelegate", + "Landroid/view/View$AccessibilityDelegate;", + )? + .l() + } +} + +/// Backport of AccessKit c4e9eeb: check at delivery on the UI thread, rather +/// than when GPUI queues the update. Also covers action and hover events. +pub(crate) fn raise_events(events: QueuedEvents, env: &mut JNIEnv, host: &JObject) { + let context = env + .call_method(host, "getContext", "()Landroid/content/Context;", &[]) + .unwrap() + .l() + .unwrap(); + let service = env.new_string("accessibility").unwrap(); + let manager = env + .call_method( + &context, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[(&service).into()], + ) + .unwrap() + .l() + .unwrap(); + if env + .call_method(&manager, "isEnabled", "()Z", &[]) + .unwrap() + .z() + .unwrap() + { + events.raise(env, host); + } +} diff --git a/src/android/accesskit_classes.dex b/src/android/accesskit_classes.dex new file mode 100644 index 0000000000000000000000000000000000000000..23dbaef5755e7181fcb957ef5de6a0e98b7112ad GIT binary patch literal 3108 zcmb7GYiJx*6h3!$vYUNx9@^TM>o(P-O|qL!`Y;=`k0dl<`=ALy)Beb0ce0sgGqc&* z-BiR9v|zC+EwqAA5Ts~P3Kp$Dv{Z#ESPF&W55i7A@C7!0k{ZU z0=@*k0=z}M7fM7ZH?0IgSqN!S z6r3fa6&#^S^nfEYdEY*8_FY@t)ei5+_!@_^&02@2!Jh=@SVzI54xa#j%Haj@n8O#q zgNSS!`awkYEMy3oZ%^EgkhBZFYvXMdX^L&%2vAyj9h zBR5Iuryj^KD!}?M=B<}fp%y7s+Th5ir3}(DPM`gR5fS?dBZ77*-P9#zh>}tUs9VaQ zb005lK|it>Cpxl^H9#GBeYRl}yvN1I#e0A+UKhStzaRQf9X;<4-W$AkBF=nJy5Z;;RqW zyRdG&J~y2>4+@12DlX)~(QzL9!?O!L{)nrABFTWP&7-=;MV$-zasIzx62}vRuR_9u z+#As6AJMOl&v`dLZ9c3uuZK(O?n1}U&L!v*RZ?9bxz@HZa`zdgQF@Mo{d!){ zX(he$uvXDX43ZckF-*#Enw0bqsp(;~N;*xhbb6SY(wdnqT1GZ*+3}raGn>~_v}$p% zV(9bnJyyxE%z=t-mQvLAux<z6UzXYRHtdNFO-CEe7E zsU-o(d$GS1B_E!tE%hIN=x%$htUhE;St;7`zvk>OS`{P90jy7FwS2{xiEE~5l{8K@ zK450@mTj21J$h-{%BE=DGDbrt64;g3Y&%73m(h}3Ad`& zyjH80uK!?@Oi}b-{G_Nc&C4FoYi2IKZ}PC7!ICvE89PumO>L4FS&We~HcYND>;g1t z662(f4-KWsJ&r9hI9QeFyT+mQRikf7Bod3fYY9&-;oVDk54gWNcQCKzYzk^wtx(d7 zyHSL^POCFT9lLeeMJ4Z)VP*%dOxdP2Ilc6VQEe5;E493Kq=LR6+ZRbSwOO6~rar$* z%jYMx%nW&~>JFrUL*%}uU_mcVS;g5M8R^7TESuym|4B z64il~b+V2g?certjBliy*;sEZs~?HA$1>Jz!N|+4)iH|=D;1OF98}Yq-I1BrGc$I1 z){gZ~X?a_3kIfophgL9Ry^% literal 0 HcmV?d00001 diff --git a/src/android/accesskit_java/Delegate.java b/src/android/accesskit_java/Delegate.java new file mode 100644 index 00000000..99b4eb84 --- /dev/null +++ b/src/android/accesskit_java/Delegate.java @@ -0,0 +1,70 @@ +// Copyright 2025 The AccessKit Authors. All rights reserved. +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file) or the MIT license (found in +// the LICENSE-MIT file), at your option. + +package dev.accesskit.android; + +import android.os.Bundle; +import android.view.MotionEvent; +import android.view.View; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityNodeProvider; + +public final class Delegate extends View.AccessibilityDelegate implements View.OnHoverListener { + private final long adapterHandle; + + private Delegate(long adapterHandle) { + super(); + this.adapterHandle = adapterHandle; + } + + private static native void runCallback(View host, long handle); + + private static native AccessibilityNodeInfo createAccessibilityNodeInfo( + long adapterHandle, View host, int virtualViewId); + + private static native AccessibilityNodeInfo findFocus( + long adapterHandle, View host, int focusType); + + private static native boolean performAction( + long adapterHandle, View host, int virtualViewId, int action, Bundle arguments); + + private static native boolean onHoverEvent( + long adapterHandle, View host, int action, float x, float y); + + public static Runnable newCallback(final View host, final long handle) { + return new Runnable() { + @Override + public void run() { + runCallback(host, handle); + } + }; + } + + @Override + public AccessibilityNodeProvider getAccessibilityNodeProvider(final View host) { + return new AccessibilityNodeProvider() { + @Override + public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { + return Delegate.createAccessibilityNodeInfo(adapterHandle, host, virtualViewId); + } + + @Override + public AccessibilityNodeInfo findFocus(int focusType) { + return Delegate.findFocus(adapterHandle, host, focusType); + } + + @Override + public boolean performAction(int virtualViewId, int action, Bundle arguments) { + return Delegate.performAction( + adapterHandle, host, virtualViewId, action, arguments); + } + }; + } + + @Override + public boolean onHover(View v, MotionEvent event) { + return onHoverEvent(adapterHandle, v, event.getAction(), event.getX(), event.getY()); + } +} diff --git a/src/android/mod.rs b/src/android/mod.rs index eefff4dc..ccf4258d 100644 --- a/src/android/mod.rs +++ b/src/android/mod.rs @@ -150,6 +150,7 @@ pub fn size(width: T, height: T) -> Size { // ── sub-modules ────────────────────────────────────────────────────────────── mod accessibility; +mod accesskit_adapter; pub mod dispatcher; pub mod display; mod document_picker; diff --git a/tests/android-accessibility/AndroidManifest.xml b/tests/android-accessibility/AndroidManifest.xml new file mode 100644 index 00000000..6ab8d861 --- /dev/null +++ b/tests/android-accessibility/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/android-accessibility/Cargo.toml b/tests/android-accessibility/Cargo.toml new file mode 100644 index 00000000..595de183 --- /dev/null +++ b/tests/android-accessibility/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "gpui-android-accessibility-regression" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +name = "a11y_regression" +crate-type = ["cdylib"] + +[dependencies] +accesskit = "0.24.1" +accesskit_android = "0.7.5" +log = "0.4" diff --git a/tests/android-accessibility/java/dev/gpui/a11ytest/MainActivity.java b/tests/android-accessibility/java/dev/gpui/a11ytest/MainActivity.java new file mode 100644 index 00000000..f2a0872e --- /dev/null +++ b/tests/android-accessibility/java/dev/gpui/a11ytest/MainActivity.java @@ -0,0 +1,51 @@ +package dev.gpui.a11ytest; + +import android.app.Activity; +import android.os.Bundle; +import android.view.View; +import android.view.accessibility.AccessibilityManager; +import android.widget.TextView; + +public final class MainActivity extends Activity { + static { System.loadLibrary("a11y_regression"); } + private static native void test(View view, View.AccessibilityDelegate expected); + + private static native void install(View view); + private static native void dropAdapter(); + + @Override public void onCreate(Bundle state) { + super.onCreate(state); + TextView view = new TextView(this); + setContentView(view); + view.post(() -> { + AccessibilityManager manager = (AccessibilityManager)getSystemService(ACCESSIBILITY_SERVICE); + if (manager.isEnabled()) throw new AssertionError("Disable accessibility before running"); + test(view, null); + View.AccessibilityDelegate foreign = new View.AccessibilityDelegate(); + view.setAccessibilityDelegate(foreign); + test(view, foreign); + view.setAccessibilityDelegate(null); + test(view, null); + install(view); + view.post(() -> { + if (view.getAccessibilityNodeProvider() == null) throw new AssertionError("No provider"); + if (view.getAccessibilityNodeProvider().createAccessibilityNodeInfo(-1) == null) + throw new AssertionError("No initial tree"); + dropAdapter(); + view.post(() -> { + test(view, null); + install(view); + view.post(() -> { + view.setAccessibilityDelegate(foreign); + dropAdapter(); + view.post(() -> { + test(view, foreign); + view.setText("PASS: delegate lifecycle and disabled event delivery"); + android.util.Log.i("GpuiA11yRegression", "PASS API " + android.os.Build.VERSION.SDK_INT); + }); + }); + }); + }); + }); + } +} diff --git a/tests/android-accessibility/run.sh b/tests/android-accessibility/run.sh new file mode 100755 index 00000000..1eb823b7 --- /dev/null +++ b/tests/android-accessibility/run.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Requires cargo-ndk, Android SDK/build-tools 34, JDK, a debug keystore, and +# an arm64 emulator with accessibility disabled. Usage: ./run.sh [adb-serial] +set -euo pipefail +cd "$(dirname "$0")" +SDK="${ANDROID_HOME:-$HOME/Library/Android/sdk}" +TOOLS="$SDK/build-tools/34.0.0" +ANDROID_JAR="$SDK/platforms/android-34/android.jar" +SERIAL="${1:-emulator-5554}" +export ANDROID_HOME="$SDK" +mkdir -p target/apk/lib/arm64-v8a target/classes target/dex +cargo ndk -t arm64-v8a -P 26 -o target/apk/lib build +javac -source 8 -target 8 -classpath "$ANDROID_JAR" -d target/classes java/dev/gpui/a11ytest/MainActivity.java +"$TOOLS/d8" --lib "$ANDROID_JAR" --min-api 26 --output target/dex target/classes/dev/gpui/a11ytest/*.class +"$TOOLS/aapt" package -f -M AndroidManifest.xml -I "$ANDROID_JAR" -F target/test-unsigned.apk +cp target/dex/classes.dex target/apk/ +(cd target/apk && zip -q -r ../test-unsigned.apk classes.dex lib) +"$TOOLS/zipalign" -f 4 target/test-unsigned.apk target/test.apk +"$TOOLS/apksigner" sign --ks "$HOME/.android/debug.keystore" --ks-pass pass:android target/test.apk +ADB="$SDK/platform-tools/adb" +"$ADB" -s "$SERIAL" install --no-incremental -r target/test.apk +"$ADB" -s "$SERIAL" logcat -c +"$ADB" -s "$SERIAL" shell am start -S -n dev.gpui.a11ytest/.MainActivity +sleep 2 +"$ADB" -s "$SERIAL" logcat -d -b all > target/test.log +rg "GpuiA11yRegression|panicked|FATAL|Fatal signal" target/test.log || true +rg 'PASS API' target/test.log diff --git a/tests/android-accessibility/src/lib.rs b/tests/android-accessibility/src/lib.rs new file mode 100644 index 00000000..e48b858d --- /dev/null +++ b/tests/android-accessibility/src/lib.rs @@ -0,0 +1,116 @@ +extern crate self as gpui; + +pub mod accesskit { + pub use ::accesskit::*; +} +#[path = "../../../src/android/accesskit_adapter.rs"] +#[allow(dead_code)] +mod accesskit_adapter; +use accesskit_adapter::{get_accessibility_delegate, raise_events}; + +use accesskit::{ActivationHandler, Node, NodeId, Role, Tree, TreeId, TreeUpdate}; +use accesskit_android::{ + jni::{ + objects::{JClass, JObject, JValue}, + JNIEnv, + }, + Adapter, +}; + +struct Activate; +impl ActivationHandler for Activate { + fn request_initial_tree(&mut self) -> Option { + None + } +} + +// Runs on the Android UI thread with accessibility disabled. Creating an active +// tree queues a real window-content-changed event, reproducing delivery of an +// update queued before TalkBack was turned off, independently of GPUI's precheck. +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_gpui_a11ytest_MainActivity_test( + mut env: JNIEnv, + _class: JClass, + view: JObject, + expected_delegate: JObject, +) { + std::panic::set_hook(Box::new(|info| { + let message = std::ffi::CString::new(info.to_string()).unwrap(); + unsafe { + android_log_write(6, c"GpuiA11yRegression".as_ptr(), message.as_ptr()); + } + })); + let delegate = get_accessibility_delegate(&mut env, &view).unwrap(); + assert!(env.is_same_object(&delegate, &expected_delegate).unwrap()); + // Exercise pre-29 dispatch on the installed runtime too. Restore SDK_INT + // before any Android framework code runs; actual API 26/28 runs additionally + // verify the OS's method availability and hidden-API enforcement. + let version = env.find_class("android/os/Build$VERSION").unwrap(); + let sdk_field = env.get_static_field_id(&version, "SDK_INT", "I").unwrap(); + let sdk = env + .get_static_field(&version, "SDK_INT", "I") + .unwrap() + .i() + .unwrap(); + for legacy_sdk in [26, 28] { + env.set_static_field(&version, sdk_field, JValue::Int(legacy_sdk)) + .unwrap(); + let result = get_accessibility_delegate(&mut env, &view); + env.set_static_field(&version, sdk_field, JValue::Int(sdk)) + .unwrap(); + let delegate = result.unwrap(); + assert!(env.is_same_object(&delegate, &expected_delegate).unwrap()); + } + let mut adapter = Adapter::default(); + let info = adapter.create_accessibility_node_info(&mut Activate, &mut env, &view, -1); + assert!(!info.is_null()); + let mut root = Node::new(Role::Window); + root.set_label("Regression test"); + let root_id = NodeId(1); + let events = adapter + .update_if_active(|| TreeUpdate { + nodes: vec![(root_id, root)], + tree: Some(Tree::new(root_id)), + tree_id: TreeId::ROOT, + focus: root_id, + }) + .expect("placeholder must activate and queue events"); + raise_events(events, &mut env, &view); + assert!(!env.exception_check().unwrap()); +} + +use accesskit::{ActionHandler, ActionRequest}; +use accesskit_adapter::InjectingAdapter; +use std::sync::Mutex; +static ADAPTER: Mutex> = Mutex::new(None); +struct Actions; +impl ActionHandler for Actions { + fn do_action(&mut self, _: ActionRequest) {} +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_gpui_a11ytest_MainActivity_install( + mut env: JNIEnv, + _class: JClass, + view: JObject, +) { + *ADAPTER.lock().unwrap() = Some(InjectingAdapter::new(&mut env, &view, Activate, Actions)); +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_gpui_a11ytest_MainActivity_dropAdapter( + _env: JNIEnv, + _class: JClass, +) { + *ADAPTER.lock().unwrap() = None; +} + +#[link(name = "log")] +unsafe extern "C" { + #[link_name = "__android_log_write"] + fn android_log_write( + priority: i32, + tag: *const std::ffi::c_char, + text: *const std::ffi::c_char, + ) -> i32; +}