From 7eb9457a4571661044d620e5e8955f95443c0f43 Mon Sep 17 00:00:00 2001 From: richerfu Date: Mon, 31 Aug 2026 20:39:25 +0800 Subject: [PATCH 01/11] feat(ohos): add ArkUI accessibility adapter --- Cargo.lock | 29 ++ Cargo.toml | 1 + README.md | 1 + platforms/ohos/CHANGELOG.md | 7 + platforms/ohos/Cargo.toml | 17 + platforms/ohos/README.md | 29 ++ platforms/ohos/src/adapter.rs | 737 ++++++++++++++++++++++++++++++++++ platforms/ohos/src/lib.rs | 11 + platforms/ohos/src/node.rs | 228 +++++++++++ 9 files changed, 1060 insertions(+) create mode 100644 platforms/ohos/CHANGELOG.md create mode 100644 platforms/ohos/Cargo.toml create mode 100644 platforms/ohos/README.md create mode 100644 platforms/ohos/src/adapter.rs create mode 100644 platforms/ohos/src/lib.rs create mode 100644 platforms/ohos/src/node.rs diff --git a/Cargo.lock b/Cargo.lock index b31cd234d..c802c98fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,6 +72,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "accesskit_ohos" +version = "0.1.0" +dependencies = [ + "accesskit", + "accesskit_consumer", + "ohos-accessibility-binding", +] + [[package]] name = "accesskit_unix" version = "0.14.0" @@ -1317,6 +1326,26 @@ dependencies = [ "memchr", ] +[[package]] +name = "ohos-accessibility-binding" +version = "0.1.0" +dependencies = [ + "bitflags 2.8.0", + "ohos-accessibility-sys", + "ohos-arkui-input-sys", +] + +[[package]] +name = "ohos-accessibility-sys" +version = "0.0.4" +dependencies = [ + "ohos-arkui-input-sys", +] + +[[package]] +name = "ohos-arkui-input-sys" +version = "0.1.3" + [[package]] name = "once_cell" version = "1.19.0" diff --git a/Cargo.toml b/Cargo.toml index 79b966788..0f3a8f612 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "platforms/android", "platforms/atspi-common", "platforms/macos", + "platforms/ohos", "platforms/unix", "platforms/windows", "platforms/winit", diff --git a/README.md b/README.md index 07919cc4a..8c803d187 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ The current released platform adapters are all at rough feature parity. They don The following platform adapters are currently available: * [Android adapter](https://crates.io/crates/accesskit_android): This adapter implements the Java-based Android accessibility API. +* [OpenHarmony adapter](https://crates.io/crates/accesskit_ohos): This adapter implements the ArkUI native accessibility provider API. * [macOS adapter](https://crates.io/crates/accesskit_macos): This adapter implements the NSAccessibility protocols in the AppKit framework. * [Unix adapter](https://crates.io/crates/accesskit_unix): This adapter implements the AT-SPI D-Bus interfaces, using [zbus](https://github.com/dbus2/zbus), a pure-Rust implementation of D-Bus. * [Windows adapter](https://crates.io/crates/accesskit_windows): This adapter implements UI Automation, the current Windows accessibility API. diff --git a/platforms/ohos/CHANGELOG.md b/platforms/ohos/CHANGELOG.md new file mode 100644 index 000000000..e515023d0 --- /dev/null +++ b/platforms/ohos/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +- Add the initial OpenHarmony ArkUI accessibility adapter. diff --git a/platforms/ohos/Cargo.toml b/platforms/ohos/Cargo.toml new file mode 100644 index 000000000..b97e5854a --- /dev/null +++ b/platforms/ohos/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "accesskit_ohos" +version = "0.1.0" +authors.workspace = true +license.workspace = true +description = "AccessKit UI accessibility infrastructure: OpenHarmony adapter" +categories.workspace = true +keywords = ["gui", "ui", "accessibility", "openharmony"] +repository.workspace = true +readme = "README.md" +edition.workspace = true +rust-version = "1.88" + +[target.'cfg(target_env = "ohos")'.dependencies] +accesskit = { version = "0.18.0", path = "../../common" } +accesskit_consumer = { version = "0.27.0", path = "../../consumer" } +ohos-accessibility-binding = { version = "0.1.0", features = ["api-24"] } diff --git a/platforms/ohos/README.md b/platforms/ohos/README.md new file mode 100644 index 000000000..a096e9fc7 --- /dev/null +++ b/platforms/ohos/README.md @@ -0,0 +1,29 @@ +# accesskit_ohos + +AccessKit adapter for the ArkUI native accessibility provider API on +OpenHarmony/HarmonyOS NEXT. + +The adapter is attached to an accessibility `Provider` obtained from an +XComponent or an `ARKUI_NODE_CUSTOM`. It retains the AccessKit tree, answers +ArkUI's synchronous node queries, forwards native actions to AccessKit, and +reports tree/focus changes back to ArkUI. + +```rust,ignore +let provider = xcomponent.accessibility_provider()?; +let adapter = accesskit_ohos::Adapter::new( + provider, + "canvas-1", + activation_handler, + action_handler, +)?; + +adapter.update_if_active(|| next_tree_update()); +``` + +The OpenHarmony native API does not expose an unregister function. Dropping +the adapter disables its Rust callback target, so any late native callback +returns a failure without touching released application state. + +## License + +MIT OR Apache-2.0 diff --git a/platforms/ohos/src/adapter.rs b/platforms/ohos/src/adapter.rs new file mode 100644 index 000000000..eb9bc0723 --- /dev/null +++ b/platforms/ohos/src/adapter.rs @@ -0,0 +1,737 @@ +// Copyright 2026 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. + +use std::{ + collections::{HashMap, HashSet}, + ffi::CStr, + sync::{Arc, Mutex, MutexGuard}, +}; + +use accesskit::{ + Action, ActionData, ActionHandler, ActionRequest, ActivationHandler, Node as NodeData, NodeId, + Role, TextSelection, Tree as TreeData, TreeUpdate, +}; +use accesskit_consumer::{FilterResult, Node, Tree}; +use ohos_accessibility_binding::{ + AccessibilityError, ActionArguments, ActionType, ElementInfo, ElementInfoList, EventInfo, + EventType, FocusMoveDirection, FocusType, Provider, ProviderCallbacks, ProviderRegistration, + Result, SearchMode, +}; + +use crate::node::{filter, populate_element, usize_to_i32}; + +const ROOT_QUERY_ID: i64 = -1; +const ROOT_ELEMENT_ID: i32 = 0; +pub(crate) const ROOT_PARENT_ID: i32 = -2_100_000; +const PLACEHOLDER_ROOT_ID: NodeId = NodeId(0); + +#[derive(Debug, Default)] +pub(crate) struct NodeIdMap { + platform_to_accesskit: HashMap, + accesskit_to_platform: HashMap, + next_platform_id: i32, +} + +impl NodeIdMap { + pub(crate) fn get_or_create(&mut self, node: &Node) -> i32 { + if node.is_root() { + self.platform_to_accesskit + .insert(ROOT_ELEMENT_ID, node.id()); + self.accesskit_to_platform + .insert(node.id(), ROOT_ELEMENT_ID); + self.next_platform_id = self.next_platform_id.max(1); + return ROOT_ELEMENT_ID; + } + if let Some(id) = self.accesskit_to_platform.get(&node.id()) { + return *id; + } + let id = self.next_platform_id.max(1); + self.next_platform_id = id.saturating_add(1); + self.platform_to_accesskit.insert(id, node.id()); + self.accesskit_to_platform.insert(node.id(), id); + id + } + + fn get_accesskit(&self, platform_id: i64, root: NodeId) -> Option { + if platform_id == ROOT_QUERY_ID || platform_id == i64::from(ROOT_ELEMENT_ID) { + return Some(root); + } + let platform_id = i32::try_from(platform_id).ok()?; + self.platform_to_accesskit.get(&platform_id).copied() + } +} + +enum TreeLifecycle { + Inactive, + Placeholder(Tree), + Active(Tree), +} + +impl TreeLifecycle { + fn get(&self) -> Option<&Tree> { + match self { + Self::Inactive => None, + Self::Placeholder(tree) | Self::Active(tree) => Some(tree), + } + } + + fn get_active_mut(&mut self) -> Option<&mut Tree> { + match self { + Self::Active(tree) => Some(tree), + Self::Inactive | Self::Placeholder(_) => None, + } + } +} + +#[derive(Clone, Copy)] +struct ProviderAddress(usize); + +impl ProviderAddress { + fn send( + self, + event_type: EventType, + node: Option<&Node>, + ids: &mut NodeIdMap, + accessibility_focus: Option, + screen_offset: (i32, i32), + ) -> Result<()> { + let mut event = EventInfo::new()?; + event.set_event_type(event_type)?; + let mut element = None; + if let Some(node) = node { + let mut value = ElementInfo::new()?; + populate_element(node, ids, accessibility_focus, screen_offset, &mut value)?; + event.set_element_info(&value)?; + element = Some(value); + } + + let provider = unsafe { Provider::from_raw(self.0 as *mut _) }?; + provider.send_event(&event, None); + // Keep the associated element alive through the send call. + drop(element); + Ok(()) + } +} + +struct State { + tree: TreeLifecycle, + activation_handler: Box, + ids: NodeIdMap, + accessibility_focus: Option, + host_is_focused: bool, + screen_offset: (i32, i32), + provider: ProviderAddress, +} + +impl State { + fn initialize_tree(&mut self) { + if !matches!(self.tree, TreeLifecycle::Inactive) { + return; + } + self.tree = match self.activation_handler.request_initial_tree() { + Some(update) => TreeLifecycle::Active(Tree::new(update, self.host_is_focused)), + None => { + let update = TreeUpdate { + nodes: vec![(PLACEHOLDER_ROOT_ID, NodeData::new(Role::Window))], + tree: Some(TreeData::new(PLACEHOLDER_ROOT_ID)), + focus: PLACEHOLDER_ROOT_ID, + }; + TreeLifecycle::Placeholder(Tree::new(update, self.host_is_focused)) + } + }; + } +} + +struct Callbacks { + state: Arc>, + action_handler: Arc>>, +} + +impl Callbacks { + fn lock(&self) -> Result> { + self.state + .lock() + .map_err(|_| AccessibilityError::LockPoisoned) + } +} + +impl ProviderCallbacks for Callbacks { + fn find_node_infos_by_id( + &self, + element_id: i64, + mode: SearchMode, + _request_id: i32, + elements: &mut ElementInfoList<'_>, + ) -> Result<()> { + let mut state = self.lock()?; + state.initialize_tree(); + let State { + tree, + ids, + accessibility_focus, + screen_offset, + .. + } = &mut *state; + let tree = tree.get().expect("tree initialized above"); + let root = tree.state().root_id(); + let target = ids + .get_accesskit(element_id, root) + .and_then(|id| tree.state().node_by_id(id)) + .ok_or(AccessibilityError::BadParameter)?; + let node_ids = search_node_ids(&target, mode); + for id in node_ids { + let node = tree + .state() + .node_by_id(id) + .ok_or(AccessibilityError::Failed)?; + let mut element = elements.add()?; + populate_element( + &node, + ids, + *accessibility_focus, + *screen_offset, + &mut element, + )?; + } + Ok(()) + } + + fn find_node_infos_by_text( + &self, + element_id: i64, + text: &CStr, + _request_id: i32, + elements: &mut ElementInfoList<'_>, + ) -> Result<()> { + let needle = text.to_string_lossy().to_lowercase(); + let mut state = self.lock()?; + state.initialize_tree(); + let State { + tree, + ids, + accessibility_focus, + screen_offset, + .. + } = &mut *state; + let tree = tree.get().expect("tree initialized above"); + let root = tree.state().root_id(); + let target = ids + .get_accesskit(element_id, root) + .and_then(|id| tree.state().node_by_id(id)) + .ok_or(AccessibilityError::BadParameter)?; + let node_ids = subtree_node_ids(&target); + for id in node_ids { + let node = tree + .state() + .node_by_id(id) + .ok_or(AccessibilityError::Failed)?; + if !node_search_text(&node).to_lowercase().contains(&needle) { + continue; + } + let mut element = elements.add()?; + populate_element( + &node, + ids, + *accessibility_focus, + *screen_offset, + &mut element, + )?; + } + Ok(()) + } + + fn find_focused_node( + &self, + _element_id: i64, + focus_type: FocusType, + _request_id: i32, + element: &mut ElementInfo<'_>, + ) -> Result<()> { + let mut state = self.lock()?; + state.initialize_tree(); + let State { + tree, + ids, + accessibility_focus, + screen_offset, + .. + } = &mut *state; + let tree = tree.get().expect("tree initialized above"); + let id = match focus_type { + FocusType::Input => tree.state().focus_id().ok_or(AccessibilityError::Failed)?, + FocusType::Accessibility => accessibility_focus.ok_or(AccessibilityError::Failed)?, + FocusType::Invalid => return Err(AccessibilityError::BadParameter), + }; + let node = tree + .state() + .node_by_id(id) + .ok_or(AccessibilityError::Failed)?; + populate_element(&node, ids, *accessibility_focus, *screen_offset, element) + } + + fn find_next_focus_node( + &self, + element_id: i64, + direction: FocusMoveDirection, + _request_id: i32, + element: &mut ElementInfo<'_>, + ) -> Result<()> { + let mut state = self.lock()?; + state.initialize_tree(); + let State { + tree, + ids, + accessibility_focus, + screen_offset, + .. + } = &mut *state; + let tree = tree.get().expect("tree initialized above"); + let root = tree.state().root_id(); + let current = ids + .get_accesskit(element_id, root) + .ok_or(AccessibilityError::BadParameter)?; + let focusable = subtree_node_ids(&tree.state().root()) + .into_iter() + .filter(|id| { + tree.state() + .node_by_id(*id) + .is_some_and(|node| node.is_focusable()) + }) + .collect::>(); + let current_index = focusable.iter().position(|id| *id == current); + let next = match direction { + FocusMoveDirection::Forward | FocusMoveDirection::Right | FocusMoveDirection::Down => { + current_index.map_or_else(|| focusable.first(), |index| focusable.get(index + 1)) + } + FocusMoveDirection::Backward | FocusMoveDirection::Left | FocusMoveDirection::Up => { + current_index.map_or_else( + || focusable.last(), + |index| index.checked_sub(1).and_then(|index| focusable.get(index)), + ) + } + FocusMoveDirection::Invalid => None, + } + .copied() + .ok_or(AccessibilityError::Failed)?; + let node = tree + .state() + .node_by_id(next) + .ok_or(AccessibilityError::Failed)?; + populate_element(&node, ids, *accessibility_focus, *screen_offset, element) + } + + fn execute_action( + &self, + element_id: i64, + action: ActionType, + arguments: &ActionArguments<'_>, + _request_id: i32, + ) -> Result<()> { + let mut state = self.lock()?; + state.initialize_tree(); + let root = state + .tree + .get() + .expect("tree initialized above") + .state() + .root_id(); + let target = state + .ids + .get_accesskit(element_id, root) + .ok_or(AccessibilityError::BadParameter)?; + + match action { + ActionType::GainAccessibilityFocus => { + state.accessibility_focus = Some(target); + return send_target_event(&mut state, target, EventType::AccessibilityFocused); + } + ActionType::ClearAccessibilityFocus => { + state.accessibility_focus = None; + return send_target_event(&mut state, target, EventType::AccessibilityFocusCleared); + } + ActionType::NextHtmlItem | ActionType::PreviousHtmlItem => { + let forward = action == ActionType::NextHtmlItem; + let next = adjacent_focusable_node(&state, target, forward) + .ok_or(AccessibilityError::Failed)?; + state.accessibility_focus = Some(next); + return send_target_event(&mut state, next, EventType::AccessibilityFocused); + } + _ => {} + } + + let request = action_request(&state, target, action, arguments)?; + drop(state); + self.action_handler + .lock() + .map_err(|_| AccessibilityError::LockPoisoned)? + .do_action(request); + Ok(()) + } + + fn clear_focused_node(&self) -> Result<()> { + let mut state = self.lock()?; + let Some(target) = state.accessibility_focus.take() else { + return Ok(()); + }; + send_target_event(&mut state, target, EventType::AccessibilityFocusCleared) + } + + fn cursor_position(&self, element_id: i64, _request_id: i32) -> Result { + let mut state = self.lock()?; + state.initialize_tree(); + let State { tree, ids, .. } = &mut *state; + let tree = tree.get().expect("tree initialized above"); + let root = tree.state().root_id(); + let target = ids + .get_accesskit(element_id, root) + .ok_or(AccessibilityError::BadParameter)?; + let node = tree + .state() + .node_by_id(target) + .ok_or(AccessibilityError::BadParameter)?; + node.text_selection_focus() + .map(|position| position.to_global_utf16_index()) + .map(usize_to_i32) + .transpose()? + .ok_or(AccessibilityError::Failed) + } +} + +/// AccessKit adapter for ArkUI native accessibility providers. +pub struct Adapter<'a> { + _registration: ProviderRegistration<'a>, + state: Arc>, +} + +impl<'a> Adapter<'a> { + pub fn new( + provider: Provider<'a>, + instance_id: &str, + activation_handler: impl ActivationHandler + Send + 'static, + action_handler: impl ActionHandler + Send + 'static, + ) -> Result { + let action_handler: Arc>> = + Arc::new(Mutex::new(Box::new(action_handler))); + let state = Arc::new(Mutex::new(State { + tree: TreeLifecycle::Inactive, + activation_handler: Box::new(activation_handler), + ids: NodeIdMap::default(), + accessibility_focus: None, + host_is_focused: true, + screen_offset: (0, 0), + provider: ProviderAddress(provider.as_raw() as usize), + })); + let callbacks = Callbacks { + state: state.clone(), + action_handler, + }; + let registration = provider.register_callbacks_with_instance(instance_id, callbacks)?; + Ok(Self { + _registration: registration, + state, + }) + } + + /// Apply an update only after ArkUI has activated this adapter by querying + /// its tree. + pub fn update_if_active(&self, update_factory: impl FnOnce() -> TreeUpdate) -> Result<()> { + let mut state = self + .state + .lock() + .map_err(|_| AccessibilityError::LockPoisoned)?; + let old_focus = state.tree.get().map(|tree| tree.state().focus_id_in_tree()); + match &mut state.tree { + TreeLifecycle::Inactive => return Ok(()), + TreeLifecycle::Placeholder(_) => { + state.tree = + TreeLifecycle::Active(Tree::new(update_factory(), state.host_is_focused)); + } + TreeLifecycle::Active(tree) => tree.update(update_factory()), + } + let new_focus = state.tree.get().map(|tree| tree.state().focus_id_in_tree()); + if state.accessibility_focus.is_some_and(|id| { + state + .tree + .get() + .is_none_or(|tree| tree.state().node_by_id(id).is_none()) + }) { + state.accessibility_focus = None; + } + send_root_event(&mut state, EventType::PageContentUpdate)?; + if old_focus != new_focus { + if let Some(new_focus) = new_focus { + send_target_event(&mut state, new_focus, EventType::FocusNodeUpdate)?; + } + } + Ok(()) + } + + pub fn set_host_focus_state(&self, is_focused: bool) -> Result<()> { + let mut state = self + .state + .lock() + .map_err(|_| AccessibilityError::LockPoisoned)?; + state.host_is_focused = is_focused; + if let Some(tree) = state.tree.get_active_mut() { + tree.update_host_focus_state(is_focused); + } + Ok(()) + } + + /// Set the XComponent/custom-node origin in global screen pixels. AccessKit + /// bounds are relative to the tree container, while ArkUI requires screen + /// coordinates for accessibility focus rectangles. + pub fn set_screen_offset(&self, x: i32, y: i32) -> Result<()> { + let mut state = self + .state + .lock() + .map_err(|_| AccessibilityError::LockPoisoned)?; + if state.screen_offset == (x, y) { + return Ok(()); + } + state.screen_offset = (x, y); + if state.tree.get().is_some() { + send_root_event(&mut state, EventType::PageStateUpdate)?; + } + Ok(()) + } + + /// Drop the retained tree. It will be requested again on the next native + /// accessibility query. + pub fn deactivate(&self) -> Result<()> { + let mut state = self + .state + .lock() + .map_err(|_| AccessibilityError::LockPoisoned)?; + state.tree = TreeLifecycle::Inactive; + state.accessibility_focus = None; + Ok(()) + } +} + +fn search_node_ids(target: &Node, mode: SearchMode) -> Vec { + let mut result = Vec::new(); + let mut seen = HashSet::new(); + let push = |node: Node, result: &mut Vec, seen: &mut HashSet| { + if filter(&node) == FilterResult::Include && seen.insert(node.id()) { + result.push(node.id()); + } + }; + + // CURRENT is represented by zero. ArkUI's recursive example also expects + // the queried node itself in the result. + push(*target, &mut result, &mut seen); + if mode.contains(SearchMode::PREDECESSORS) { + let mut parent = target.filtered_parent(&filter); + while let Some(node) = parent { + push(node, &mut result, &mut seen); + parent = node.filtered_parent(&filter); + } + } + if mode.contains(SearchMode::SIBLINGS) { + if let Some(parent) = target.filtered_parent(&filter) { + for node in parent.filtered_children(&filter) { + push(node, &mut result, &mut seen); + } + } + } + if mode.contains(SearchMode::CHILDREN) { + for node in target.filtered_children(&filter) { + push(node, &mut result, &mut seen); + } + } + if mode.contains(SearchMode::RECURSIVE_CHILDREN) { + for id in subtree_node_ids(target) { + if seen.insert(id) { + result.push(id); + } + } + } + result +} + +fn subtree_node_ids(root: &Node) -> Vec { + let mut result = Vec::new(); + let mut stack = vec![*root]; + while let Some(node) = stack.pop() { + match filter(&node) { + FilterResult::Include => result.push(node.id()), + FilterResult::ExcludeSubtree => continue, + FilterResult::ExcludeNode => {} + } + let children = node.children().collect::>(); + stack.extend(children.into_iter().rev()); + } + result +} + +fn node_search_text(node: &Node) -> String { + [node.label(), node.value(), node.description()] + .into_iter() + .flatten() + .collect::>() + .join(" ") +} + +fn adjacent_focusable_node(state: &State, target: NodeId, forward: bool) -> Option { + let tree = state.tree.get()?; + let nodes = subtree_node_ids(&tree.state().root()) + .into_iter() + .filter(|id| { + tree.state() + .node_by_id(*id) + .is_some_and(|node| node.is_focusable()) + }) + .collect::>(); + let index = nodes.iter().position(|id| *id == target)?; + if forward { + nodes.get(index + 1).copied() + } else { + index + .checked_sub(1) + .and_then(|index| nodes.get(index).copied()) + } +} + +fn action_request( + state: &State, + target: NodeId, + action: ActionType, + arguments: &ActionArguments<'_>, +) -> Result { + let tree = state.tree.get().ok_or(AccessibilityError::Failed)?; + let node = tree + .state() + .node_by_id(target) + .ok_or(AccessibilityError::BadParameter)?; + let (action, data) = match action { + ActionType::Click => (Action::Click, None), + ActionType::ScrollForward => { + let action = [ + Action::ScrollForward, + Action::ScrollDown, + Action::ScrollRight, + ] + .into_iter() + .find(|action| node.supports_action(*action)) + .ok_or(AccessibilityError::Unsupported)?; + (action, None) + } + ActionType::ScrollBackward => { + let action = [Action::ScrollBackward, Action::ScrollUp, Action::ScrollLeft] + .into_iter() + .find(|action| node.supports_action(*action)) + .ok_or(AccessibilityError::Unsupported)?; + (action, None) + } + ActionType::SetText => { + let value = argument_string( + arguments, + &["ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE", "setText", "text"], + ) + .ok_or(AccessibilityError::BadParameter)?; + (Action::SetValue, Some(ActionData::Value(value.into()))) + } + ActionType::SelectText | ActionType::SetCursorPosition => { + let start = argument_usize( + arguments, + &["ACTION_ARGUMENT_SELECTION_START_INT", "selectTextBegin"], + ) + .ok_or(AccessibilityError::BadParameter)?; + let end = if action == ActionType::SetCursorPosition { + start + } else { + argument_usize( + arguments, + &[ + "ACTION_ARGUMENT_SELECTION_END_INT", + "selectTextEnd", + "TextEnd", + ], + ) + .ok_or(AccessibilityError::BadParameter)? + }; + let anchor = node + .text_position_from_global_utf16_index(start) + .ok_or(AccessibilityError::BadParameter)?; + let focus = node + .text_position_from_global_utf16_index(end) + .ok_or(AccessibilityError::BadParameter)?; + ( + Action::SetTextSelection, + Some(ActionData::SetTextSelection(TextSelection { + anchor: anchor.to_raw(), + focus: focus.to_raw(), + })), + ) + } + _ => return Err(AccessibilityError::Unsupported), + }; + if !node.supports_action(action) { + return Err(AccessibilityError::Unsupported); + } + Ok(ActionRequest { + action, + target, + data, + }) +} + +fn argument_string(arguments: &ActionArguments<'_>, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + arguments + .get(key) + .ok() + .flatten() + .map(|value| value.to_string_lossy().into_owned()) + }) +} + +fn argument_usize(arguments: &ActionArguments<'_>, keys: &[&str]) -> Option { + argument_string(arguments, keys)?.parse().ok() +} + +fn send_target_event(state: &mut State, target: NodeId, event_type: EventType) -> Result<()> { + let State { + tree, + ids, + accessibility_focus, + screen_offset, + provider, + .. + } = state; + let tree = tree.get().ok_or(AccessibilityError::Failed)?; + let node = tree + .state() + .node_by_id(target) + .ok_or(AccessibilityError::BadParameter)?; + provider.send( + event_type, + Some(&node), + ids, + *accessibility_focus, + *screen_offset, + ) +} + +fn send_root_event(state: &mut State, event_type: EventType) -> Result<()> { + let State { + tree, + ids, + accessibility_focus, + screen_offset, + provider, + .. + } = state; + let tree = tree.get().ok_or(AccessibilityError::Failed)?; + let root = tree.state().root(); + provider.send( + event_type, + Some(&root), + ids, + *accessibility_focus, + *screen_offset, + ) +} diff --git a/platforms/ohos/src/lib.rs b/platforms/ohos/src/lib.rs new file mode 100644 index 000000000..90314cd98 --- /dev/null +++ b/platforms/ohos/src/lib.rs @@ -0,0 +1,11 @@ +// Copyright 2026 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. + +#![cfg(target_env = "ohos")] + +mod adapter; +mod node; + +pub use adapter::Adapter; diff --git a/platforms/ohos/src/node.rs b/platforms/ohos/src/node.rs new file mode 100644 index 000000000..556821781 --- /dev/null +++ b/platforms/ohos/src/node.rs @@ -0,0 +1,228 @@ +// Copyright 2026 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. + +use accesskit::{Action, Role, Toggled}; +use accesskit_consumer::{common_filter_with_root_exception, FilterResult, Node}; +use ohos_accessibility_binding::{ + AccessibilityError, AccessibleRect, ActionType, ElementInfo, GridInfo, GridItemInfo, RangeInfo, + Result, +}; + +use crate::adapter::{NodeIdMap, ROOT_PARENT_ID}; + +pub(crate) fn filter(node: &Node) -> FilterResult { + common_filter_with_root_exception(node) +} + +pub(crate) fn populate_element( + node: &Node, + ids: &mut NodeIdMap, + accessibility_focus: Option, + screen_offset: (i32, i32), + element: &mut ElementInfo<'_>, +) -> Result<()> { + let element_id = ids.get_or_create(node); + let parent_id = node + .filtered_parent(&filter) + .map(|parent| ids.get_or_create(&parent)) + .unwrap_or(ROOT_PARENT_ID); + let child_ids = node + .filtered_children(&filter) + .map(|child| i64::from(ids.get_or_create(&child))) + .collect::>(); + + element + .set_element_id(element_id)? + .set_parent_id(parent_id)? + .set_component_type(component_type(node.role()))? + .set_child_node_ids(&child_ids)? + .set_enabled(!node.is_disabled())? + .set_visible(!node.is_hidden())? + .set_focusable(node.is_focusable())? + .set_focused(node.is_focused())? + .set_accessibility_focused(accessibility_focus == Some(node.id()))? + .set_selected(node.is_selected().unwrap_or(false))? + .set_clickable(node.is_clickable())? + .set_long_clickable(false)? + .set_scrollable(is_scrollable(node))? + .set_editable(node.is_text_input() && !node.is_read_only())? + .set_password(node.role() == Role::PasswordInput)? + .set_accessibility_level(if node.is_root() { "no" } else { "yes" })?; + + if let Some(contents) = node.value() { + element.set_contents(&contents)?; + } + if let Some(label) = node.label() { + element.set_accessibility_text(&label)?; + } + if let Some(description) = node.description() { + element.set_accessibility_description(&description)?; + } + if let Some(hint) = node.placeholder() { + element.set_hint_text(hint)?.set_is_hint(true)?; + } + if let Some(identifier) = node.author_id() { + element.set_component_identifier(identifier)?; + } + if let Some(bounds) = node.bounding_box() { + element.set_screen_rect(AccessibleRect { + left: coordinate_to_i32(bounds.x0).saturating_add(screen_offset.0), + top: coordinate_to_i32(bounds.y0).saturating_add(screen_offset.1), + right: coordinate_to_i32(bounds.x1).saturating_add(screen_offset.0), + bottom: coordinate_to_i32(bounds.y1).saturating_add(screen_offset.1), + })?; + } + + if let Some(toggled) = node.toggled() { + element + .set_checkable(true)? + .set_checked(toggled != Toggled::False)?; + } + + if let (Some(min), Some(max), Some(current)) = ( + node.min_numeric_value(), + node.max_numeric_value(), + node.numeric_value(), + ) { + element.set_range_info(RangeInfo { min, max, current })?; + } + + let data = node.data(); + if data.row_count().is_some() || data.column_count().is_some() { + element.set_grid_info(GridInfo { + rows: usize_to_i32(data.row_count().unwrap_or(0))?, + columns: usize_to_i32(data.column_count().unwrap_or(0))?, + selection_mode: i32::from(data.is_multiselectable()), + })?; + } + if data.row_index().is_some() || data.column_index().is_some() { + element.set_grid_item_info(GridItemInfo { + heading: matches!( + node.role(), + Role::RowHeader | Role::ColumnHeader | Role::Heading + ), + selected: node.is_selected().unwrap_or(false), + row_index: usize_to_i32(data.row_index().unwrap_or(0))?, + column_index: usize_to_i32(data.column_index().unwrap_or(0))?, + row_span: usize_to_i32(data.row_span().unwrap_or(1))?, + column_span: usize_to_i32(data.column_span().unwrap_or(1))?, + })?; + } + + if let Some(selection) = node.text_selection() { + element + .set_selected_text_start(usize_to_i32(selection.start().to_global_utf16_index())?)? + .set_selected_text_end(usize_to_i32(selection.end().to_global_utf16_index())?)?; + } + if let Some(position) = node.position_in_set() { + element.set_current_item_index(usize_to_i32(position)?)?; + } + if let Some(count) = node.size_of_set() { + element.set_item_count(usize_to_i32(count)?)?; + } + + let actions = supported_actions(node); + if !actions.is_empty() { + element.set_operation_actions(&actions)?; + } + + Ok(()) +} + +fn supported_actions(node: &Node) -> Vec<(ActionType, &'static str)> { + let mut actions = vec![ + ( + ActionType::GainAccessibilityFocus, + "gain accessibility focus", + ), + ( + ActionType::ClearAccessibilityFocus, + "clear accessibility focus", + ), + ]; + if node.supports_action(Action::Click) { + actions.push((ActionType::Click, "click")); + } + if node.supports_action(Action::ScrollForward) + || node.supports_action(Action::ScrollDown) + || node.supports_action(Action::ScrollRight) + { + actions.push((ActionType::ScrollForward, "scroll forward")); + } + if node.supports_action(Action::ScrollBackward) + || node.supports_action(Action::ScrollUp) + || node.supports_action(Action::ScrollLeft) + { + actions.push((ActionType::ScrollBackward, "scroll backward")); + } + if node.supports_action(Action::SetTextSelection) { + actions.push((ActionType::SelectText, "select text")); + actions.push((ActionType::SetCursorPosition, "set cursor position")); + } + if node.supports_action(Action::SetValue) { + actions.push((ActionType::SetText, "set text")); + } + actions +} + +fn is_scrollable(node: &Node) -> bool { + [ + Action::ScrollForward, + Action::ScrollBackward, + Action::ScrollDown, + Action::ScrollLeft, + Action::ScrollRight, + Action::ScrollUp, + ] + .into_iter() + .any(|action| node.supports_action(action)) +} + +fn coordinate_to_i32(value: f64) -> i32 { + if value.is_nan() { + 0 + } else { + value.round().clamp(i32::MIN as f64, i32::MAX as f64) as i32 + } +} + +pub(crate) fn usize_to_i32(value: usize) -> Result { + i32::try_from(value).map_err(|_| AccessibilityError::BadParameter) +} + +fn component_type(role: Role) -> &'static str { + match role { + Role::Button | Role::DefaultButton | Role::PdfActionableHighlight => "button", + Role::CheckBox => "checkbox", + Role::RadioButton => "radio", + Role::Switch => "switch", + Role::TextInput + | Role::MultilineTextInput + | Role::SearchInput + | Role::EmailInput + | Role::NumberInput + | Role::PasswordInput + | Role::PhoneNumberInput + | Role::UrlInput => "textInput", + Role::Label | Role::TextRun => "text", + Role::Image | Role::SvgRoot => "image", + Role::Link => "link", + Role::List | Role::ListBox => "list", + Role::ListItem | Role::ListBoxOption => "listItem", + Role::Grid | Role::Table | Role::TreeGrid => "grid", + Role::Row => "row", + Role::Cell | Role::LayoutTableCell => "cell", + Role::Slider => "slider", + Role::ProgressIndicator | Role::Meter => "progress", + Role::ScrollView | Role::ScrollBar => "scroll", + Role::Dialog | Role::AlertDialog => "dialog", + Role::Menu | Role::MenuBar | Role::MenuListPopup => "menu", + Role::MenuItem | Role::MenuItemCheckBox | Role::MenuItemRadio => "menuItem", + Role::TabList => "tabs", + Role::Tab => "tab", + Role::Window | Role::RootWebArea | Role::Document => "root", + _ => "container", + } +} From b4bb66b662652a59baf26b78251bf3ed99b3f229 Mon Sep 17 00:00:00 2001 From: richerfu Date: Mon, 31 Aug 2026 20:39:34 +0800 Subject: [PATCH 02/11] chore(release): register AccessKit OHOS adapter --- .release-please-manifest.json | 2 +- release-please-config.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ca6dcb312..ab679ffe2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{"common":"0.18.0","consumer":"0.27.0","platforms/macos":"0.19.0","platforms/windows":"0.26.0","platforms/winit":"0.26.0","platforms/unix":"0.14.0","platforms/atspi-common":"0.11.0","platforms/android":"0.1.1"} \ No newline at end of file +{"common":"0.18.0","consumer":"0.27.0","platforms/macos":"0.19.0","platforms/ohos":"0.1.0","platforms/windows":"0.26.0","platforms/winit":"0.26.0","platforms/unix":"0.14.0","platforms/atspi-common":"0.11.0","platforms/android":"0.1.1"} diff --git a/release-please-config.json b/release-please-config.json index addf935fe..0e17861e7 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -9,6 +9,7 @@ "platforms/android": {}, "platforms/atspi-common": {}, "platforms/macos": {}, + "platforms/ohos": {}, "platforms/unix": {}, "platforms/windows": {}, "platforms/winit": {} From 75a3a78256bc8242496a52a94a4682f1034dd302 Mon Sep 17 00:00:00 2001 From: richerfu Date: Tue, 1 Sep 2026 09:38:11 +0800 Subject: [PATCH 03/11] fix(ohos): use single-provider callback registration --- platforms/ohos/README.md | 6 +++++- platforms/ohos/src/adapter.rs | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/platforms/ohos/README.md b/platforms/ohos/README.md index a096e9fc7..264e1f0e3 100644 --- a/platforms/ohos/README.md +++ b/platforms/ohos/README.md @@ -12,7 +12,6 @@ reports tree/focus changes back to ArkUI. let provider = xcomponent.accessibility_provider()?; let adapter = accesskit_ohos::Adapter::new( provider, - "canvas-1", activation_handler, action_handler, )?; @@ -20,6 +19,11 @@ let adapter = accesskit_ohos::Adapter::new( adapter.update_if_active(|| next_tree_update()); ``` +`Adapter::new` uses the standard callback registration recommended for a +single ArkUI custom node or XComponent. Frameworks that intentionally use +ArkUI's API 15 multi-instance callback contract can use +`Adapter::new_with_instance` instead. + The OpenHarmony native API does not expose an unregister function. Dropping the adapter disables its Rust callback target, so any late native callback returns a failure without touching released application state. diff --git a/platforms/ohos/src/adapter.rs b/platforms/ohos/src/adapter.rs index eb9bc0723..0edf43229 100644 --- a/platforms/ohos/src/adapter.rs +++ b/platforms/ohos/src/adapter.rs @@ -407,10 +407,43 @@ pub struct Adapter<'a> { impl<'a> Adapter<'a> { pub fn new( + provider: Provider<'a>, + activation_handler: impl ActivationHandler + Send + 'static, + action_handler: impl ActionHandler + Send + 'static, + ) -> Result { + Self::new_with_registrar( + provider, + activation_handler, + action_handler, + |provider, callbacks| provider.register_callbacks(callbacks), + ) + } + + /// Register the multi-instance callback shape introduced in API 15. + /// + /// Use this only when a third-party framework intentionally multiplexes + /// multiple trees through one native provider and supplies the same + /// instance ID through ArkUI. Normal custom-node and XComponent hosts + /// should use [`Self::new`]. + pub fn new_with_instance( provider: Provider<'a>, instance_id: &str, activation_handler: impl ActivationHandler + Send + 'static, action_handler: impl ActionHandler + Send + 'static, + ) -> Result { + Self::new_with_registrar( + provider, + activation_handler, + action_handler, + |provider, callbacks| provider.register_callbacks_with_instance(instance_id, callbacks), + ) + } + + fn new_with_registrar( + provider: Provider<'a>, + activation_handler: impl ActivationHandler + Send + 'static, + action_handler: impl ActionHandler + Send + 'static, + register: impl FnOnce(Provider<'a>, Callbacks) -> Result>, ) -> Result { let action_handler: Arc>> = Arc::new(Mutex::new(Box::new(action_handler))); @@ -427,7 +460,7 @@ impl<'a> Adapter<'a> { state: state.clone(), action_handler, }; - let registration = provider.register_callbacks_with_instance(instance_id, callbacks)?; + let registration = register(provider, callbacks)?; Ok(Self { _registration: registration, state, From cc63be3e6e36052843711842ab7bf495e488fdb7 Mon Sep 17 00:00:00 2001 From: richerfu Date: Tue, 1 Sep 2026 10:02:44 +0800 Subject: [PATCH 04/11] fix(ohos): skip empty accessibility child lists --- platforms/ohos/src/node.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/platforms/ohos/src/node.rs b/platforms/ohos/src/node.rs index 556821781..96a44e9d8 100644 --- a/platforms/ohos/src/node.rs +++ b/platforms/ohos/src/node.rs @@ -37,7 +37,6 @@ pub(crate) fn populate_element( .set_element_id(element_id)? .set_parent_id(parent_id)? .set_component_type(component_type(node.role()))? - .set_child_node_ids(&child_ids)? .set_enabled(!node.is_disabled())? .set_visible(!node.is_hidden())? .set_focusable(node.is_focusable())? @@ -51,6 +50,13 @@ pub(crate) fn populate_element( .set_password(node.role() == Role::PasswordInput)? .set_accessibility_level(if node.is_root() { "no" } else { "yes" })?; + // ArkUI rejects a zero child count with BAD_PARAMETER. Leaf nodes already + // have an empty child list by default, so only call the setter when there + // are IDs to copy. + if !child_ids.is_empty() { + element.set_child_node_ids(&child_ids)?; + } + if let Some(contents) = node.value() { element.set_contents(&contents)?; } From 859f12694159268560aade777ba5b9bfad39d918 Mon Sep 17 00:00:00 2001 From: richerfu Date: Tue, 1 Sep 2026 10:07:18 +0800 Subject: [PATCH 05/11] docs(ohos): clarify provider-local bounds --- platforms/ohos/src/adapter.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/platforms/ohos/src/adapter.rs b/platforms/ohos/src/adapter.rs index 0edf43229..47be4eb66 100644 --- a/platforms/ohos/src/adapter.rs +++ b/platforms/ohos/src/adapter.rs @@ -513,9 +513,11 @@ impl<'a> Adapter<'a> { Ok(()) } - /// Set the XComponent/custom-node origin in global screen pixels. AccessKit - /// bounds are relative to the tree container, while ArkUI requires screen - /// coordinates for accessibility focus rectangles. + /// Apply an additional provider-local pixel offset to AccessKit bounds. + /// + /// ArkUI positions the provider's child tree at the XComponent/custom-node + /// origin automatically, so the default `(0, 0)` is correct for normal + /// hosts. Use this only when the AccessKit tree has a nested local origin. pub fn set_screen_offset(&self, x: i32, y: i32) -> Result<()> { let mut state = self .state From b851d8a7ca9a0739078d88d3805d88a8780c07ab Mon Sep 17 00:00:00 2001 From: richerfu Date: Tue, 1 Sep 2026 10:32:10 +0800 Subject: [PATCH 06/11] refactor(ohos): lower adapter baseline to API 13 --- platforms/ohos/CHANGELOG.md | 1 + platforms/ohos/Cargo.toml | 17 ++++++++++++++++- platforms/ohos/README.md | 19 ++++++++++++++++++- platforms/ohos/src/adapter.rs | 3 +++ platforms/ohos/src/node.rs | 1 + 5 files changed, 39 insertions(+), 2 deletions(-) diff --git a/platforms/ohos/CHANGELOG.md b/platforms/ohos/CHANGELOG.md index e515023d0..3a0bfac6e 100644 --- a/platforms/ohos/CHANGELOG.md +++ b/platforms/ohos/CHANGELOG.md @@ -5,3 +5,4 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - Add the initial OpenHarmony ArkUI accessibility adapter. +- Default the adapter to API 13 and make API 24 component identifiers opt-in. diff --git a/platforms/ohos/Cargo.toml b/platforms/ohos/Cargo.toml index b97e5854a..32fa74d84 100644 --- a/platforms/ohos/Cargo.toml +++ b/platforms/ohos/Cargo.toml @@ -11,7 +11,22 @@ readme = "README.md" edition.workspace = true rust-version = "1.88" +[features] +default = ["api-13"] +api-13 = ["ohos-accessibility-binding/api-13"] +api-14 = ["api-13", "ohos-accessibility-binding/api-14"] +api-15 = ["api-14", "ohos-accessibility-binding/api-15"] +api-16 = ["api-15", "ohos-accessibility-binding/api-16"] +api-17 = ["api-16", "ohos-accessibility-binding/api-17"] +api-18 = ["api-17", "ohos-accessibility-binding/api-18"] +api-19 = ["api-18", "ohos-accessibility-binding/api-19"] +api-20 = ["api-19", "ohos-accessibility-binding/api-20"] +api-21 = ["api-20", "ohos-accessibility-binding/api-21"] +api-22 = ["api-21", "ohos-accessibility-binding/api-22"] +api-23 = ["api-22", "ohos-accessibility-binding/api-23"] +api-24 = ["api-23", "ohos-accessibility-binding/api-24"] + [target.'cfg(target_env = "ohos")'.dependencies] accesskit = { version = "0.18.0", path = "../../common" } accesskit_consumer = { version = "0.27.0", path = "../../consumer" } -ohos-accessibility-binding = { version = "0.1.0", features = ["api-24"] } +ohos-accessibility-binding = { version = "0.1.0", default-features = false } diff --git a/platforms/ohos/README.md b/platforms/ohos/README.md index 264e1f0e3..dfd73a586 100644 --- a/platforms/ohos/README.md +++ b/platforms/ohos/README.md @@ -22,12 +22,29 @@ adapter.update_if_active(|| next_tree_update()); `Adapter::new` uses the standard callback registration recommended for a single ArkUI custom node or XComponent. Frameworks that intentionally use ArkUI's API 15 multi-instance callback contract can use -`Adapter::new_with_instance` instead. +`Adapter::new_with_instance` instead when the `api-15` feature is enabled. The OpenHarmony native API does not expose an unregister function. Dropping the adapter disables its Rust callback target, so any late native callback returns a failure without touching released application state. +## API levels + +The adapter defaults to the `api-13` feature, which is sufficient for an +XComponent accessibility provider and for all core tree, action, and event +operations. Higher API features form an additive ladder: + +- API 15 adds multi-instance callback registration and next/previous HTML item + action handling. +- An ArkUI `ARKUI_NODE_CUSTOM` host needs API 23 to obtain its accessibility + provider. This requirement belongs to the ArkUI host integration; the + adapter still only consumes the resulting `Provider`. +- Enable `api-24` when AccessKit `author_id` values must be exposed through + ArkUI's `componentIdentifier` property. + +Applications should select the highest API feature supported by their minimum +deployment target. Enabling `api-24` is not required for the core adapter. + ## License MIT OR Apache-2.0 diff --git a/platforms/ohos/src/adapter.rs b/platforms/ohos/src/adapter.rs index 47be4eb66..820dbee24 100644 --- a/platforms/ohos/src/adapter.rs +++ b/platforms/ohos/src/adapter.rs @@ -351,6 +351,7 @@ impl ProviderCallbacks for Callbacks { state.accessibility_focus = None; return send_target_event(&mut state, target, EventType::AccessibilityFocusCleared); } + #[cfg(feature = "api-15")] ActionType::NextHtmlItem | ActionType::PreviousHtmlItem => { let forward = action == ActionType::NextHtmlItem; let next = adjacent_focusable_node(&state, target, forward) @@ -425,6 +426,7 @@ impl<'a> Adapter<'a> { /// multiple trees through one native provider and supplies the same /// instance ID through ArkUI. Normal custom-node and XComponent hosts /// should use [`Self::new`]. + #[cfg(feature = "api-15")] pub fn new_with_instance( provider: Provider<'a>, instance_id: &str, @@ -610,6 +612,7 @@ fn node_search_text(node: &Node) -> String { .join(" ") } +#[cfg(feature = "api-15")] fn adjacent_focusable_node(state: &State, target: NodeId, forward: bool) -> Option { let tree = state.tree.get()?; let nodes = subtree_node_ids(&tree.state().root()) diff --git a/platforms/ohos/src/node.rs b/platforms/ohos/src/node.rs index 96a44e9d8..98920c274 100644 --- a/platforms/ohos/src/node.rs +++ b/platforms/ohos/src/node.rs @@ -69,6 +69,7 @@ pub(crate) fn populate_element( if let Some(hint) = node.placeholder() { element.set_hint_text(hint)?.set_is_hint(true)?; } + #[cfg(feature = "api-24")] if let Some(identifier) = node.author_id() { element.set_component_identifier(identifier)?; } From 06b8a529c3f7aaccf7973e115e43f8ca63591d5b Mon Sep 17 00:00:00 2001 From: richerfu Date: Wed, 23 Sep 2026 20:55:16 +0800 Subject: [PATCH 07/11] Port OHOS accessibility adapter to AccessKit 0.24 --- Cargo.lock | 104 ++++++++++++++++++++++++++++------ platforms/ohos/Cargo.toml | 4 +- platforms/ohos/src/adapter.rs | 48 +++++++++------- platforms/ohos/src/node.rs | 26 ++++----- 4 files changed, 128 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c802c98fb..1091aa634 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,12 +28,21 @@ dependencies = [ "serde", ] +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + [[package]] name = "accesskit_android" version = "0.1.1" dependencies = [ - "accesskit", - "accesskit_consumer", + "accesskit 0.18.0", + "accesskit_consumer 0.27.0", "jni", "log", "once_cell", @@ -43,8 +52,8 @@ dependencies = [ name = "accesskit_atspi_common" version = "0.11.0" dependencies = [ - "accesskit", - "accesskit_consumer", + "accesskit 0.18.0", + "accesskit_consumer 0.27.0", "atspi-common", "serde", "thiserror", @@ -55,18 +64,28 @@ dependencies = [ name = "accesskit_consumer" version = "0.27.0" dependencies = [ - "accesskit", - "hashbrown", + "accesskit 0.18.0", + "hashbrown 0.15.2", "immutable-chunkmap", ] +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit 0.24.1", + "hashbrown 0.16.1", +] + [[package]] name = "accesskit_macos" version = "0.19.0" dependencies = [ - "accesskit", - "accesskit_consumer", - "hashbrown", + "accesskit 0.18.0", + "accesskit_consumer 0.27.0", + "hashbrown 0.15.2", "objc2", "objc2-app-kit", "objc2-foundation", @@ -76,8 +95,8 @@ dependencies = [ name = "accesskit_ohos" version = "0.1.0" dependencies = [ - "accesskit", - "accesskit_consumer", + "accesskit 0.24.1", + "accesskit_consumer 0.35.0", "ohos-accessibility-binding", ] @@ -85,7 +104,7 @@ dependencies = [ name = "accesskit_unix" version = "0.14.0" dependencies = [ - "accesskit", + "accesskit 0.18.0", "accesskit_atspi_common", "async-channel", "async-executor", @@ -103,9 +122,9 @@ dependencies = [ name = "accesskit_windows" version = "0.26.0" dependencies = [ - "accesskit", - "accesskit_consumer", - "hashbrown", + "accesskit 0.18.0", + "accesskit_consumer 0.27.0", + "hashbrown 0.15.2", "once_cell", "scopeguard", "static_assertions", @@ -118,7 +137,7 @@ dependencies = [ name = "accesskit_winit" version = "0.26.0" dependencies = [ - "accesskit", + "accesskit 0.18.0", "accesskit_android", "accesskit_macos", "accesskit_unix", @@ -545,6 +564,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.3" @@ -720,6 +748,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f81ec6369c545a7d40e4589b5597581fa1c441fe1cce96dd1de43159910a36a2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.5.0" @@ -848,7 +882,16 @@ version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" dependencies = [ - "foldhash", + "foldhash 0.1.3", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", ] [[package]] @@ -885,7 +928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.2", ] [[package]] @@ -1329,15 +1372,20 @@ dependencies = [ [[package]] name = "ohos-accessibility-binding" version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9a8980b8d4e16cc38d2659a8c109c4b345c4187a916523112344d0a7b2dfe6d" dependencies = [ "bitflags 2.8.0", "ohos-accessibility-sys", "ohos-arkui-input-sys", + "ohos-enum-derive", ] [[package]] name = "ohos-accessibility-sys" version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49b0e83fb2b9ea8d1d079e5c73920f2e30743922091b095ed2cfdf2953fc602" dependencies = [ "ohos-arkui-input-sys", ] @@ -1345,6 +1393,20 @@ dependencies = [ [[package]] name = "ohos-arkui-input-sys" version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8363f015a1ad653f01c620f43b166577ede75cb5eb8b8cd7bc7622480287ffe2" + +[[package]] +name = "ohos-enum-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfaa27b9a5519879fcccdb845993e2d2c28df2f84e60f1016bad4557458a4889" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] [[package]] name = "once_cell" @@ -2028,6 +2090,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" + [[package]] name = "version_check" version = "0.9.4" diff --git a/platforms/ohos/Cargo.toml b/platforms/ohos/Cargo.toml index 32fa74d84..8420ab5a2 100644 --- a/platforms/ohos/Cargo.toml +++ b/platforms/ohos/Cargo.toml @@ -27,6 +27,6 @@ api-23 = ["api-22", "ohos-accessibility-binding/api-23"] api-24 = ["api-23", "ohos-accessibility-binding/api-24"] [target.'cfg(target_env = "ohos")'.dependencies] -accesskit = { version = "0.18.0", path = "../../common" } -accesskit_consumer = { version = "0.27.0", path = "../../consumer" } +accesskit = "0.24.0" +accesskit_consumer = "0.35.0" ohos-accessibility-binding = { version = "0.1.0", default-features = false } diff --git a/platforms/ohos/src/adapter.rs b/platforms/ohos/src/adapter.rs index 820dbee24..069ecae31 100644 --- a/platforms/ohos/src/adapter.rs +++ b/platforms/ohos/src/adapter.rs @@ -10,10 +10,10 @@ use std::{ }; use accesskit::{ - Action, ActionData, ActionHandler, ActionRequest, ActivationHandler, Node as NodeData, NodeId, - Role, TextSelection, Tree as TreeData, TreeUpdate, + Action, ActionData, ActionHandler, ActionRequest, ActivationHandler, Node as NodeData, + NodeId as LocalNodeId, Role, TextSelection, Tree as TreeData, TreeId, TreeUpdate, }; -use accesskit_consumer::{FilterResult, Node, Tree}; +use accesskit_consumer::{FilterResult, Node, NodeId, Tree, TreeChangeHandler}; use ohos_accessibility_binding::{ AccessibilityError, ActionArguments, ActionType, ElementInfo, ElementInfoList, EventInfo, EventType, FocusMoveDirection, FocusType, Provider, ProviderCallbacks, ProviderRegistration, @@ -25,7 +25,16 @@ use crate::node::{filter, populate_element, usize_to_i32}; const ROOT_QUERY_ID: i64 = -1; const ROOT_ELEMENT_ID: i32 = 0; pub(crate) const ROOT_PARENT_ID: i32 = -2_100_000; -const PLACEHOLDER_ROOT_ID: NodeId = NodeId(0); +const PLACEHOLDER_ROOT_ID: LocalNodeId = LocalNodeId(0); + +struct IgnoreChanges; + +impl TreeChangeHandler for IgnoreChanges { + fn node_added(&mut self, _node: &Node) {} + fn node_updated(&mut self, _old_node: &Node, _new_node: &Node) {} + fn focus_moved(&mut self, _old_node: Option<&Node>, _new_node: Option<&Node>) {} + fn node_removed(&mut self, _node: &Node) {} +} #[derive(Debug, Default)] pub(crate) struct NodeIdMap { @@ -136,6 +145,7 @@ impl State { let update = TreeUpdate { nodes: vec![(PLACEHOLDER_ROOT_ID, NodeData::new(Role::Window))], tree: Some(TreeData::new(PLACEHOLDER_ROOT_ID)), + tree_id: TreeId::ROOT, focus: PLACEHOLDER_ROOT_ID, }; TreeLifecycle::Placeholder(Tree::new(update, self.host_is_focused)) @@ -297,7 +307,7 @@ impl ProviderCallbacks for Callbacks { .filter(|id| { tree.state() .node_by_id(*id) - .is_some_and(|node| node.is_focusable()) + .is_some_and(|node| node.is_focusable(&filter)) }) .collect::>(); let current_index = focusable.iter().position(|id| *id == current); @@ -483,7 +493,9 @@ impl<'a> Adapter<'a> { state.tree = TreeLifecycle::Active(Tree::new(update_factory(), state.host_is_focused)); } - TreeLifecycle::Active(tree) => tree.update(update_factory()), + TreeLifecycle::Active(tree) => { + tree.update_and_process_changes(update_factory(), &mut IgnoreChanges) + } } let new_focus = state.tree.get().map(|tree| tree.state().focus_id_in_tree()); if state.accessibility_focus.is_some_and(|id| { @@ -510,7 +522,7 @@ impl<'a> Adapter<'a> { .map_err(|_| AccessibilityError::LockPoisoned)?; state.host_is_focused = is_focused; if let Some(tree) = state.tree.get_active_mut() { - tree.update_host_focus_state(is_focused); + tree.update_host_focus_state_and_process_changes(is_focused, &mut IgnoreChanges); } Ok(()) } @@ -647,20 +659,16 @@ fn action_request( let (action, data) = match action { ActionType::Click => (Action::Click, None), ActionType::ScrollForward => { - let action = [ - Action::ScrollForward, - Action::ScrollDown, - Action::ScrollRight, - ] - .into_iter() - .find(|action| node.supports_action(*action)) - .ok_or(AccessibilityError::Unsupported)?; + let action = [Action::ScrollDown, Action::ScrollRight] + .into_iter() + .find(|action| node.supports_action(*action, &filter)) + .ok_or(AccessibilityError::Unsupported)?; (action, None) } ActionType::ScrollBackward => { - let action = [Action::ScrollBackward, Action::ScrollUp, Action::ScrollLeft] + let action = [Action::ScrollUp, Action::ScrollLeft] .into_iter() - .find(|action| node.supports_action(*action)) + .find(|action| node.supports_action(*action, &filter)) .ok_or(AccessibilityError::Unsupported)?; (action, None) } @@ -707,12 +715,14 @@ fn action_request( } _ => return Err(AccessibilityError::Unsupported), }; - if !node.supports_action(action) { + if !node.supports_action(action, &filter) { return Err(AccessibilityError::Unsupported); } + let local_target = LocalNodeId((u128::from(target) >> 64) as u64); Ok(ActionRequest { action, - target, + target_tree: TreeId::ROOT, + target_node: local_target, data, }) } diff --git a/platforms/ohos/src/node.rs b/platforms/ohos/src/node.rs index 98920c274..b662d7cc2 100644 --- a/platforms/ohos/src/node.rs +++ b/platforms/ohos/src/node.rs @@ -19,7 +19,7 @@ pub(crate) fn filter(node: &Node) -> FilterResult { pub(crate) fn populate_element( node: &Node, ids: &mut NodeIdMap, - accessibility_focus: Option, + accessibility_focus: Option, screen_offset: (i32, i32), element: &mut ElementInfo<'_>, ) -> Result<()> { @@ -39,11 +39,11 @@ pub(crate) fn populate_element( .set_component_type(component_type(node.role()))? .set_enabled(!node.is_disabled())? .set_visible(!node.is_hidden())? - .set_focusable(node.is_focusable())? + .set_focusable(node.is_focusable(&filter))? .set_focused(node.is_focused())? .set_accessibility_focused(accessibility_focus == Some(node.id()))? .set_selected(node.is_selected().unwrap_or(false))? - .set_clickable(node.is_clickable())? + .set_clickable(node.is_clickable(&filter))? .set_long_clickable(false)? .set_scrollable(is_scrollable(node))? .set_editable(node.is_text_input() && !node.is_read_only())? @@ -149,26 +149,24 @@ fn supported_actions(node: &Node) -> Vec<(ActionType, &'static str)> { "clear accessibility focus", ), ]; - if node.supports_action(Action::Click) { + if node.supports_action(Action::Click, &filter) { actions.push((ActionType::Click, "click")); } - if node.supports_action(Action::ScrollForward) - || node.supports_action(Action::ScrollDown) - || node.supports_action(Action::ScrollRight) + if node.supports_action(Action::ScrollDown, &filter) + || node.supports_action(Action::ScrollRight, &filter) { actions.push((ActionType::ScrollForward, "scroll forward")); } - if node.supports_action(Action::ScrollBackward) - || node.supports_action(Action::ScrollUp) - || node.supports_action(Action::ScrollLeft) + if node.supports_action(Action::ScrollUp, &filter) + || node.supports_action(Action::ScrollLeft, &filter) { actions.push((ActionType::ScrollBackward, "scroll backward")); } - if node.supports_action(Action::SetTextSelection) { + if node.supports_action(Action::SetTextSelection, &filter) { actions.push((ActionType::SelectText, "select text")); actions.push((ActionType::SetCursorPosition, "set cursor position")); } - if node.supports_action(Action::SetValue) { + if node.supports_action(Action::SetValue, &filter) { actions.push((ActionType::SetText, "set text")); } actions @@ -176,15 +174,13 @@ fn supported_actions(node: &Node) -> Vec<(ActionType, &'static str)> { fn is_scrollable(node: &Node) -> bool { [ - Action::ScrollForward, - Action::ScrollBackward, Action::ScrollDown, Action::ScrollLeft, Action::ScrollRight, Action::ScrollUp, ] .into_iter() - .any(|action| node.supports_action(action)) + .any(|action| node.supports_action(action, &filter)) } fn coordinate_to_i32(value: f64) -> i32 { From ea2d8c44b2a5d68a7f12fa26f942320f283181b3 Mon Sep 17 00:00:00 2001 From: richerfu Date: Wed, 23 Sep 2026 21:00:14 +0800 Subject: [PATCH 08/11] Use filtered focusability in API 15 navigation --- platforms/ohos/src/adapter.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platforms/ohos/src/adapter.rs b/platforms/ohos/src/adapter.rs index 069ecae31..f17da24bb 100644 --- a/platforms/ohos/src/adapter.rs +++ b/platforms/ohos/src/adapter.rs @@ -632,7 +632,7 @@ fn adjacent_focusable_node(state: &State, target: NodeId, forward: bool) -> Opti .filter(|id| { tree.state() .node_by_id(*id) - .is_some_and(|node| node.is_focusable()) + .is_some_and(|node| node.is_focusable(&filter)) }) .collect::>(); let index = nodes.iter().position(|id| *id == target)?; From 783745bc9a2f475321789b3c3b97e4fe61419f1a Mon Sep 17 00:00:00 2001 From: richerfu Date: Fri, 16 Oct 2026 10:50:38 +0800 Subject: [PATCH 09/11] refactor(ohos): declare binding dependency in adapter crate Use an unconditional dependency table for the OHOS adapter and declare ohos-accessibility-binding 0.1 directly in its manifest. --- Cargo.toml | 1 - adapters/ohos/Cargo.toml | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7b4079090..8c2c1ed42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,6 @@ rust-version = "1.85" accesskit = { version = "0.25.0", path = "accesskit" } accesskit_consumer = { version = "0.39.0", path = "accesskit_consumer" } hashbrown = { version = "0.17", default-features = false, features = ["default-hasher"] } -ohos-accessibility-binding = { version = "0.1.2", default-features = false } [profile.release] lto = true diff --git a/adapters/ohos/Cargo.toml b/adapters/ohos/Cargo.toml index bf05fa832..11b940c07 100644 --- a/adapters/ohos/Cargo.toml +++ b/adapters/ohos/Cargo.toml @@ -26,7 +26,7 @@ api-22 = ["api-21", "ohos-accessibility-binding/api-22"] api-23 = ["api-22", "ohos-accessibility-binding/api-23"] api-24 = ["api-23", "ohos-accessibility-binding/api-24"] -[target.'cfg(target_env = "ohos")'.dependencies] +[dependencies] accesskit.workspace = true accesskit_consumer.workspace = true -ohos-accessibility-binding.workspace = true +ohos-accessibility-binding = { version = "0.1", default-features = false } From 478d608b8aebd93f976b34fc7ad805f82fa1f2e1 Mon Sep 17 00:00:00 2001 From: richerfu Date: Fri, 16 Oct 2026 10:52:48 +0800 Subject: [PATCH 10/11] refactor(ohos): declare AccessKit dependencies in adapter manifest --- Cargo.toml | 2 -- adapters/ohos/Cargo.toml | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8c2c1ed42..ef918bf7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,6 @@ repository = "https://github.com/AccessKit/accesskit" rust-version = "1.85" [workspace.dependencies] -accesskit = { version = "0.25.0", path = "accesskit" } -accesskit_consumer = { version = "0.39.0", path = "accesskit_consumer" } hashbrown = { version = "0.17", default-features = false, features = ["default-hasher"] } [profile.release] diff --git a/adapters/ohos/Cargo.toml b/adapters/ohos/Cargo.toml index 11b940c07..5eb1ffecb 100644 --- a/adapters/ohos/Cargo.toml +++ b/adapters/ohos/Cargo.toml @@ -27,6 +27,6 @@ api-23 = ["api-22", "ohos-accessibility-binding/api-23"] api-24 = ["api-23", "ohos-accessibility-binding/api-24"] [dependencies] -accesskit.workspace = true -accesskit_consumer.workspace = true +accesskit = { version = "0.25.0", path = "../../accesskit" } +accesskit_consumer = { version = "0.39.0", path = "../../accesskit_consumer" } ohos-accessibility-binding = { version = "0.1", default-features = false } From 4ee40c311b97337ae08d6417bbccdf9be5bce7a5 Mon Sep 17 00:00:00 2001 From: richerfu Date: Thu, 22 Oct 2026 15:34:26 +0800 Subject: [PATCH 11/11] fix(ohos): retain accessibility action descriptions and event elements Co-Authored-By: Claude Code --- adapters/ohos/README.md | 4 ++++ adapters/ohos/src/adapter.rs | 18 ++++++++++-------- adapters/ohos/src/node.rs | 19 ++++++++++--------- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/adapters/ohos/README.md b/adapters/ohos/README.md index 12e6d4448..bd062080b 100644 --- a/adapters/ohos/README.md +++ b/adapters/ohos/README.md @@ -32,6 +32,10 @@ The OpenHarmony native API does not expose an unregister function. Dropping the adapter disables its Rust callback target, so any late native callback returns a failure without touching released application state. +Action descriptions use static C strings because ArkUI snapshots retain their +description pointers. Event submission keeps its attached element alive through +the binding's borrowed event lifetime. + ## API levels The adapter defaults to the `api-13` feature, which is sufficient for an diff --git a/adapters/ohos/src/adapter.rs b/adapters/ohos/src/adapter.rs index e3037f942..33e664bff 100644 --- a/adapters/ohos/src/adapter.rs +++ b/adapters/ohos/src/adapter.rs @@ -108,20 +108,22 @@ impl ProviderAddress { accessibility_focus: Option, screen_offset: (i32, i32), ) -> Result<()> { - let mut event = EventInfo::new()?; - event.set_event_type(event_type)?; - let mut element = None; - if let Some(node) = node { + let element = if let Some(node) = node { let mut value = ElementInfo::new()?; populate_element(node, ids, accessibility_focus, screen_offset, &mut value)?; - event.set_element_info(&value)?; - element = Some(value); + Some(value) + } else { + None + }; + // The event borrows this final element allocation until it is dropped. + let mut event = EventInfo::new()?; + event.set_event_type(event_type)?; + if let Some(element) = element.as_ref() { + event.set_element_info(element)?; } let provider = unsafe { Provider::from_raw(self.0 as *mut _) }?; provider.send_event(&event, None); - // Keep the associated element alive through the send call. - drop(element); Ok(()) } } diff --git a/adapters/ohos/src/node.rs b/adapters/ohos/src/node.rs index 438774c0c..8bd6015b0 100644 --- a/adapters/ohos/src/node.rs +++ b/adapters/ohos/src/node.rs @@ -11,6 +11,7 @@ use ohos_accessibility_binding::{ AccessibilityError, AccessibleRect, ActionType, ElementInfo, GridInfo, GridItemInfo, RangeInfo, Result, }; +use std::ffi::CStr; use crate::adapter::{NodeIdMap, ROOT_PARENT_ID}; @@ -140,36 +141,36 @@ pub(crate) fn populate_element( Ok(()) } -fn supported_actions(node: &Node) -> Vec<(ActionType, &'static str)> { +fn supported_actions(node: &Node) -> Vec<(ActionType, &'static CStr)> { let mut actions = vec![ ( ActionType::GainAccessibilityFocus, - "gain accessibility focus", + c"gain accessibility focus", ), ( ActionType::ClearAccessibilityFocus, - "clear accessibility focus", + c"clear accessibility focus", ), ]; if node.supports_action(Action::Click, &filter) { - actions.push((ActionType::Click, "click")); + actions.push((ActionType::Click, c"click")); } if node.supports_action(Action::ScrollDown, &filter) || node.supports_action(Action::ScrollRight, &filter) { - actions.push((ActionType::ScrollForward, "scroll forward")); + actions.push((ActionType::ScrollForward, c"scroll forward")); } if node.supports_action(Action::ScrollUp, &filter) || node.supports_action(Action::ScrollLeft, &filter) { - actions.push((ActionType::ScrollBackward, "scroll backward")); + actions.push((ActionType::ScrollBackward, c"scroll backward")); } if node.supports_action(Action::SetTextSelection, &filter) { - actions.push((ActionType::SelectText, "select text")); - actions.push((ActionType::SetCursorPosition, "set cursor position")); + actions.push((ActionType::SelectText, c"select text")); + actions.push((ActionType::SetCursorPosition, c"set cursor position")); } if node.supports_action(Action::SetValue, &filter) { - actions.push((ActionType::SetText, "set text")); + actions.push((ActionType::SetText, c"set text")); } actions }