diff --git a/.gitignore b/.gitignore index 5e8e720..6afd8c8 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 10f46d8..6c8ff0a 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 = "0.7.5" + # 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 0000000..e477dd5 --- /dev/null +++ b/src/android/accessibility.rs @@ -0,0 +1,363 @@ +//! 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`). +//! +//! 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. 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 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. 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, +}; +use gpui::{ + accesskit::{ActionHandler, ActionRequest, ActivationHandler, TreeUpdate}, + A11yCallbacks, +}; + +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; + +struct Bridge { + callbacks: Handlers, + adapter: Option, +} + +struct Adapter { + 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)] +pub(super) struct Handlers(Arc>, Option); + +impl Handlers { + pub(super) fn new(callbacks: A11yCallbacks, host: Option) -> Self { + Self(Arc::new(Mutex::new(callbacks)), host) + } + + fn deactivate(&self) { + (self.0.lock().expect("poisoned").deactivation)(); + } +} + +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) + } +} + +/// 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_mut() { + if Arc::ptr_eq(&previous.callbacks.0, &callbacks.0) { + // An earlier attach may have failed (no JavaVM or Activity yet, a JNI + // error), or the Activity was recreated; attach again in both cases. + if !previous.is_current() { + previous.attach(); + } + return; + } + // The previous window is off screen; stop it building trees. + previous.callbacks.deactivate(); + } + bridge + .insert(Bridge { + callbacks: callbacks.clone(), + adapter: None, + }) + .attach(); +} + +/// `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() + .filter(|bridge| Arc::ptr_eq(&bridge.callbacks.0, &callbacks.0)) + else { + return; + }; + let Some(adapter) = bridge.adapter.as_mut() else { + return; + }; + if enabled { + 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(())); + } + 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.deactivate(); + 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; + } +} + +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(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(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())); + } + 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)? { + return Ok(Err(format!( + "the view already has an accessibility delegate ({class})" + ))); + } + if MANAGER.get().is_none() { + let _ = MANAGER.set(accessibility_manager(env, view)?); + } + Ok(Ok(Adapter { + inner: ManuallyDrop::new(InjectingAdapter::new( + env, + view, + callbacks.clone(), + callbacks, + )), + activity: env.new_global_ref(activity)?, + })) + }) { + Ok(adapter) => self.adapter = Some(adapter), + Err(err) => log::warn!("accessibility: cannot attach to the host view: {err}"), + } + } +} + +/// `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 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)) +} + +/// 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. +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 +} + +/// 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 = super::accesskit_adapter::get_accessibility_delegate(env, view)?; + 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, +) -> 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) +} + +/// 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 { + if activity.is_null() { + return Err("Activity not available".into()); + } + 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 + .attach_current_thread_permanently() + .map_err(|e| e.to_string())?; + 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/accesskit_adapter.rs b/src/android/accesskit_adapter.rs new file mode 100644 index 0000000..ed0790c --- /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 0000000..23dbaef Binary files /dev/null and b/src/android/accesskit_classes.dex differ diff --git a/src/android/accesskit_java/Delegate.java b/src/android/accesskit_java/Delegate.java new file mode 100644 index 0000000..99b4eb8 --- /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/host.rs b/src/android/host.rs index f6b9c53..c7ed506 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 { @@ -352,6 +361,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", @@ -488,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); @@ -540,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`]. @@ -558,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/jni.rs b/src/android/jni.rs index 03ff1df..16266bc 100644 --- a/src/android/jni.rs +++ b/src/android/jni.rs @@ -551,6 +551,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 diff --git a/src/android/mod.rs b/src/android/mod.rs index 1811cc4..ccf4258 100644 --- a/src/android/mod.rs +++ b/src/android/mod.rs @@ -149,6 +149,8 @@ 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/src/android/window.rs b/src/android/window.rs index efdcee0..29ccfa6 100644 --- a/src/android/window.rs +++ b/src/android/window.rs @@ -397,6 +397,11 @@ 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>, + /// The host this window belongs to, on the host-driven path. + host: std::sync::OnceLock, } // SAFETY: `WindowState` is protected by a `Mutex`. @@ -476,6 +481,8 @@ 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(), + host: Default::default(), })) } @@ -510,6 +517,8 @@ 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(), + host: Default::default(), }) } @@ -988,12 +997,29 @@ impl AndroidWindow { log::info!("AndroidWindow::set_active({}) — done", active); } 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(); } } + /// 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() { + super::accessibility::close(&handlers); + } + } + // ── transparency ────────────────────────────────────────────────────────── /// Enable or disable transparent compositing. @@ -1382,6 +1408,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. @@ -1547,6 +1574,19 @@ impl PlatformWindow for AndroidPlatformWindow { } } + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + 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); + } + + fn a11y_tree_update(&self, tree_update: gpui::accesskit::TreeUpdate) { + if let Some(handlers) = self.window.a11y.lock().expect("poisoned").clone() { + super::accessibility::update(&handlers, tree_update); + } + } + fn prompt( &self, _level: PromptLevel, diff --git a/tests/android-accessibility/AndroidManifest.xml b/tests/android-accessibility/AndroidManifest.xml new file mode 100644 index 0000000..6ab8d86 --- /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 0000000..595de18 --- /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 0000000..f2a0872 --- /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 0000000..1eb823b --- /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 0000000..e48b858 --- /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; +}