diff --git a/adapters/android/src/action.rs b/adapters/android/src/action.rs index 5f668e55f..21e0668b0 100644 --- a/adapters/android/src/action.rs +++ b/adapters/android/src/action.rs @@ -3,7 +3,11 @@ // the LICENSE-APACHE file) or the MIT license (found in // the LICENSE-MIT file), at your option. -use jni::{JNIEnv, objects::JObject, sys::jint}; +use jni::{ + JNIEnv, + objects::JObject, + sys::{jfloat, jint}, +}; use crate::util::*; @@ -21,6 +25,9 @@ pub(crate) enum PlatformActionInner { forward: bool, extend_selection: bool, }, + SetProgress { + value: jfloat, + }, } pub struct PlatformAction(pub(crate) PlatformActionInner); @@ -63,6 +70,15 @@ impl PlatformAction { extend_selection, })) } + ACTION_SET_PROGRESS => { + if arguments.is_null() + || !bundle_contains_key(env, arguments, ACTION_ARGUMENT_PROGRESS_VALUE) + { + return None; + } + let value = bundle_get_float(env, arguments, ACTION_ARGUMENT_PROGRESS_VALUE); + Some(Self(PlatformActionInner::SetProgress { value })) + } _ => Some(Self(PlatformActionInner::Simple { action })), } } diff --git a/adapters/android/src/adapter.rs b/adapters/android/src/adapter.rs index 34caf7b72..2a2b50c2f 100644 --- a/adapters/android/src/adapter.rs +++ b/adapters/android/src/adapter.rs @@ -462,6 +462,23 @@ impl Adapter { target_node, data: None, }, + ACTION_SCROLL_BACKWARD | ACTION_SCROLL_FORWARD + if { + let node = tree_state.node_by_id(target).unwrap(); + node.supports_increment(&filter) || node.supports_decrement(&filter) + } => + { + ActionRequest { + action: if action == ACTION_SCROLL_BACKWARD { + Action::Decrement + } else { + Action::Increment + }, + target_tree, + target_node, + data: None, + } + } ACTION_SCROLL_BACKWARD | ACTION_SCROLL_FORWARD => ActionRequest { action: { let node = tree_state.node_by_id(target).unwrap(); @@ -532,6 +549,29 @@ impl Adapter { Some(QueuedEvents(events)) } + fn set_progress( + &mut self, + action_handler: &mut H, + virtual_view_id: jint, + value: jfloat, + ) -> Option { + let tree = self.state.get_full_tree()?; + let tree_state = tree.state(); + let target = self.node_id_map.get_accesskit_id(virtual_view_id)?; + let (target_node, target_tree) = tree_state.locate_node(target)?; + let node = tree_state.node_by_id(target)?; + if !node.supports_action(Action::SetValue, &filter) || node.numeric_value().is_none() { + return None; + } + action_handler.do_action(ActionRequest { + action: Action::SetValue, + target_tree, + target_node, + data: Some(ActionData::NumericValue(value.into())), + }); + Some(QueuedEvents(Vec::new())) + } + fn set_text_selection_common( &mut self, action_handler: &mut H, @@ -802,6 +842,9 @@ impl Adapter { forward, extend_selection, ), + PlatformActionInner::SetProgress { value } => { + self.set_progress(action_handler, virtual_view_id, value) + } } } diff --git a/adapters/android/src/event.rs b/adapters/android/src/event.rs index 933409339..e256948b4 100644 --- a/adapters/android/src/event.rs +++ b/adapters/android/src/event.rs @@ -349,6 +349,9 @@ impl QueuedEvents { /// any locks required by the host view's implementations of Android /// framework callbacks. pub fn raise(self, env: &mut JNIEnv, host: &JObject) { + if self.0.is_empty() { + return; + } // The framework throws an exception if events are sent from the // UI thread while accessibility is disabled. if !is_accessibility_enabled(env, host) { diff --git a/adapters/android/src/node.rs b/adapters/android/src/node.rs index 212142b99..46ee278c1 100644 --- a/adapters/android/src/node.rs +++ b/adapters/android/src/node.rs @@ -93,6 +93,14 @@ impl NodeWrapper<'_> { } } + fn supports_set_progress(&self) -> bool { + self.range().is_some() && self.0.supports_action(Action::SetValue, &filter) + } + + fn is_range_control(&self) -> bool { + self.0.supports_increment(&filter) || self.0.supports_decrement(&filter) + } + pub(crate) fn content_description(&self) -> Option { if self.0.label_comes_from_value() { self.0.value() @@ -377,7 +385,11 @@ impl NodeWrapper<'_> { .unwrap(); let can_focus = self.is_focusable() && !self.0.is_focused(); - if self.0.is_clickable(&filter) || can_focus { + // Like the framework's `SeekBar`, a range control doesn't advertise + // a click action unless it's really clickable. TalkBack then handles + // a double-tap by synthesizing a tap at the center of the control, + // which is how users expect to jump to the middle of the range. + if self.0.is_clickable(&filter) || (can_focus && !self.is_range_control()) { add_action(env, node_info, ACTION_CLICK); } if can_focus { @@ -399,13 +411,18 @@ impl NodeWrapper<'_> { ) .unwrap(); } + // Like the framework's own `SeekBar`, a control with a numeric + // value is adjusted through the scroll actions; TalkBack's + // "adjust slider" reading control performs nothing else. if self.0.supports_action(Action::ScrollLeft, &filter) || self.0.supports_action(Action::ScrollUp, &filter) + || self.0.supports_decrement(&filter) { add_action(env, node_info, ACTION_SCROLL_BACKWARD); } if self.0.supports_action(Action::ScrollRight, &filter) || self.0.supports_action(Action::ScrollDown, &filter) + || self.0.supports_increment(&filter) { add_action(env, node_info, ACTION_SCROLL_FORWARD); } @@ -437,6 +454,29 @@ impl NodeWrapper<'_> { ) .unwrap(); } + if self.supports_set_progress() { + // Unlike the legacy actions, this one isn't a bitmask + // and must be added as an `AccessibilityAction` object. + let action_class = env + .find_class("android/view/accessibility/AccessibilityNodeInfo$AccessibilityAction") + .unwrap(); + let action = env + .get_static_field( + &action_class, + "ACTION_SET_PROGRESS", + "Landroid/view/accessibility/AccessibilityNodeInfo$AccessibilityAction;", + ) + .unwrap() + .l() + .unwrap(); + env.call_method( + node_info, + "addAction", + "(Landroid/view/accessibility/AccessibilityNodeInfo$AccessibilityAction;)V", + &[(&action).into()], + ) + .unwrap(); + } let live = match self.0.live() { Live::Off => LIVE_REGION_NONE, diff --git a/adapters/android/src/util.rs b/adapters/android/src/util.rs index 257b5d38c..dee52be6f 100644 --- a/adapters/android/src/util.rs +++ b/adapters/android/src/util.rs @@ -7,7 +7,7 @@ use accesskit_consumer::{FullNodeId, NodeRef}; use jni::{ JNIEnv, objects::{GlobalRef, JObject}, - sys::jint, + sys::{jfloat, jint}, }; use std::{collections::HashMap, sync::OnceLock}; @@ -20,6 +20,9 @@ pub(crate) const ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY: jint = 1 << 9; pub(crate) const ACTION_SCROLL_FORWARD: jint = 1 << 12; pub(crate) const ACTION_SCROLL_BACKWARD: jint = 1 << 13; pub(crate) const ACTION_SET_SELECTION: jint = 1 << 17; +// This is the value of `android.R.id.accessibilityActionSetProgress`, +// a stable public resource ID rather than a legacy bitmask action. +pub(crate) const ACTION_SET_PROGRESS: jint = 0x0102003d; pub(crate) const ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT: &str = "ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT"; @@ -27,6 +30,8 @@ pub(crate) const ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN: &str = "ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN"; pub(crate) const ACTION_ARGUMENT_SELECTION_START_INT: &str = "ACTION_ARGUMENT_SELECTION_START_INT"; pub(crate) const ACTION_ARGUMENT_SELECTION_END_INT: &str = "ACTION_ARGUMENT_SELECTION_END_INT"; +pub(crate) const ACTION_ARGUMENT_PROGRESS_VALUE: &str = + "android.view.accessibility.action.ARGUMENT_PROGRESS_VALUE"; pub(crate) const CONTENT_CHANGE_TYPE_SUBTREE: jint = 1 << 0; @@ -113,6 +118,19 @@ pub(crate) fn bundle_get_int(env: &mut JNIEnv, bundle: &JObject, key: &str) -> j .unwrap() } +pub(crate) fn bundle_get_float(env: &mut JNIEnv, bundle: &JObject, key: &str) -> jfloat { + let key = env.new_string(key).unwrap(); + env.call_method( + bundle, + "getFloat", + "(Ljava/lang/String;)F", + &[(&key).into()], + ) + .unwrap() + .f() + .unwrap() +} + pub(crate) fn bundle_get_bool(env: &mut JNIEnv, bundle: &JObject, key: &str) -> bool { let key = env.new_string(key).unwrap(); env.call_method(