From 39a496c7887809d0ef18674c9eace11b6ff0bf41 Mon Sep 17 00:00:00 2001 From: Andrew Black Date: Wed, 14 Oct 2026 08:21:45 -0700 Subject: [PATCH] Add set-progress accessibility actions on Android Summary: # Robot Summary ## Overview Add the standard Android `ACTION_SET_PROGRESS` accessibility action to React Native. Include its numeric progress argument in the JavaScript event. Update Flow, deprecated TypeScript, and generated public API types for the new action and event value. ## Motivation This base diff lets Android accessibility services set adjustable controls to an absolute value. The later diffs use it for relative gaze slider drags. ## Practical Impact React Native sliders can receive an absolute progress value from Android accessibility services. Changelog: [Android][Added] - Add support for direct progress accessibility actions Differential Revision: D124925585 --- .../Components/View/ViewAccessibility.js | 7 +- .../uimanager/ReactAccessibilityDelegate.kt | 11 ++ .../ReactAccessibilityDelegateTest.kt | 67 +++++++++ packages/react-native/ReactNativeApi.d.ts | 131 +++++++++--------- .../Components/View/ViewAccessibility.d.ts | 6 + 5 files changed, 159 insertions(+), 63 deletions(-) diff --git a/packages/react-native/Libraries/Components/View/ViewAccessibility.js b/packages/react-native/Libraries/Components/View/ViewAccessibility.js index 5c1b250a1462..5997ca929be7 100644 --- a/packages/react-native/Libraries/Components/View/ViewAccessibility.js +++ b/packages/react-native/Libraries/Components/View/ViewAccessibility.js @@ -137,6 +137,11 @@ export type AccessibilityActionName = * Generated when a screen reader user decrements an adjustable component. */ | 'decrement' + /** + * Generated when an accessibility service sets an adjustable component to a specific value. + * @platform android + */ + | 'setProgress' /** * Generated when a TalkBack user places accessibility focus on the component and double taps and holds one finger on the screen. * @platform android @@ -162,7 +167,7 @@ export type AccessibilityActionInfo = Readonly<{ // The info included in the event sent to onAccessibilityAction export type AccessibilityActionEvent = NativeSyntheticEvent< - Readonly<{actionName: string, ...}>, + Readonly<{actionName: string, value?: number, ...}>, >; export type AccessibilityState = { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt index 31752ce67681..321a5897226d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt @@ -15,6 +15,7 @@ import android.os.Message import android.view.View import android.view.ViewGroup import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo import android.widget.EditText import androidx.core.view.ViewCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat @@ -227,6 +228,15 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac if (accessibilityActionsMap.containsKey(action)) { val eventData = createMap() eventData.putString("actionName", accessibilityActionsMap[action]) + if ( + action == AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_PROGRESS.id && + args?.containsKey(AccessibilityNodeInfo.ACTION_ARGUMENT_PROGRESS_VALUE) == true + ) { + eventData.putDouble( + "value", + args.getFloat(AccessibilityNodeInfo.ACTION_ARGUMENT_PROGRESS_VALUE).toDouble(), + ) + } val reactContext = host.context as ReactContext if (reactContext.hasActiveReactInstance()) { val reactTag = host.id @@ -576,6 +586,7 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac "longpress" to AccessibilityActionCompat.ACTION_LONG_CLICK.id, "increment" to AccessibilityActionCompat.ACTION_SCROLL_FORWARD.id, "decrement" to AccessibilityActionCompat.ACTION_SCROLL_BACKWARD.id, + "setProgress" to AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_PROGRESS.id, "expand" to AccessibilityActionCompat.ACTION_EXPAND.id, "collapse" to AccessibilityActionCompat.ACTION_COLLAPSE.id, ) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ReactAccessibilityDelegateTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ReactAccessibilityDelegateTest.kt index abf9d186fa1c..bb87fb9e0812 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ReactAccessibilityDelegateTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ReactAccessibilityDelegateTest.kt @@ -10,18 +10,32 @@ package com.facebook.react.uimanager import android.os.Bundle +import android.view.accessibility.AccessibilityNodeInfo import androidx.core.view.accessibility.AccessibilityNodeInfoCompat import com.facebook.react.R import com.facebook.react.bridge.BridgeReactContext import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance +import com.facebook.react.bridge.UIManager +import com.facebook.react.bridge.WritableMap import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.common.UIManagerType +import com.facebook.react.uimanager.events.Event +import com.facebook.react.uimanager.events.EventDispatcher +import com.facebook.react.uimanager.events.RCTEventEmitter import com.facebook.react.views.view.ReactViewGroup import com.facebook.testutils.shadows.ShadowArguments import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config @@ -38,6 +52,7 @@ class ReactAccessibilityDelegateTest { fun setUp() { ReactNativeFeatureFlagsForTests.setUp() reactContext = BridgeReactContext(RuntimeEnvironment.getApplication()) + reactContext.initializeWithInstance(createMockCatalystInstance()) themedReactContext = ThemedReactContext(reactContext, reactContext, null, -1) view = ReactViewGroup(themedReactContext).apply { id = 100 } accessibilityDelegate = ReactAccessibilityDelegate(view, false, 0) @@ -231,6 +246,58 @@ class ReactAccessibilityDelegateTest { ) } + @Test + fun testPerformAccessibilityAction_setProgressAction_isExposedAndHandled() { + val accessibilityActions = JavaOnlyArray() + val action = JavaOnlyMap().apply { putString("name", "setProgress") } + accessibilityActions.pushMap(action) + view.setTag(R.id.accessibility_actions, accessibilityActions) + + val nodeInfo = AccessibilityNodeInfoCompat.obtain() + accessibilityDelegate.onInitializeAccessibilityNodeInfo(view, nodeInfo) + + assertThat(nodeInfo.actionList.map { it.id }) + .contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_PROGRESS.id) + + val args = + Bundle().apply { putFloat(AccessibilityNodeInfoCompat.ACTION_ARGUMENT_PROGRESS_VALUE, 42f) } + val eventDispatcher = mock() + val uiManager = mock() + whenever(uiManager.eventDispatcher).thenReturn(eventDispatcher) + val uiManagerHelper = mockStatic(UIManagerHelper::class.java) + try { + uiManagerHelper + .`when` { + UIManagerHelper.getUIManager(themedReactContext, UIManagerType.FABRIC) + } + .thenReturn(uiManager) + + val result = + accessibilityDelegate.performAccessibilityAction( + view, + AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_PROGRESS.id, + args, + ) + + assertThat(result).isTrue() + val eventCaptor = argumentCaptor>() + verify(eventDispatcher).dispatchEvent(eventCaptor.capture()) + val eventEmitter = mock() + eventCaptor.firstValue.dispatch(eventEmitter) + val eventDataCaptor = argumentCaptor() + verify(eventEmitter) + .receiveEvent( + eq(view.id), + eq(ReactAccessibilityDelegate.TOP_ACCESSIBILITY_ACTION_EVENT), + eventDataCaptor.capture(), + ) + assertThat(eventDataCaptor.firstValue.getString("actionName")).isEqualTo("setProgress") + assertThat(eventDataCaptor.firstValue.getDouble("value")).isEqualTo(42.0) + } finally { + uiManagerHelper.close() + } + } + @Test fun testPerformAccessibilityAction_adjustableRoleWithTextValue_doesNotScheduleEvent() { val accessibilityActions = JavaOnlyArray() diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index cb67856cb4b3..74c407136ff1 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<2ceaa8e472a53fc667994edb63ccd995>> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -937,13 +937,20 @@ declare type AbstractImageIOS = ( ) => React.ReactNode declare type AccessibilityActionEvent = NativeSyntheticEvent<{ readonly actionName: string + readonly value?: number }> declare type AccessibilityActionInfo = { readonly label?: string readonly name: AccessibilityActionName | string } declare type AccessibilityActionName = - "activate" | "decrement" | "escape" | "increment" | "longpress" | "magicTap" + | "activate" + | "decrement" + | "escape" + | "increment" + | "longpress" + | "magicTap" + | "setProgress" declare type AccessibilityEventDefinitions = AccessibilityEventDefinitionsAndroid & AccessibilityEventDefinitionsIOS & { @@ -5761,25 +5768,25 @@ declare type WrapperComponentProvider = ( appKey?: string, ) => React.ComponentType export { - AccessibilityActionEvent, // ca0e86fd - AccessibilityActionInfo, // db47a917 + AccessibilityActionEvent, // a276ec6e + AccessibilityActionInfo, // c94545a7 AccessibilityInfo, // d92c5eae - AccessibilityProps, // db9967d1 + AccessibilityProps, // 95dbb1d8 AccessibilityRole, // f2f2e066 AccessibilityState, // b0c2b3f7 AccessibilityValue, // cf8bcb74 ActionSheetIOS, // b558559e ActionSheetIOSOptions, // 1756eb5a - ActivityIndicator, // b8c4f05a + ActivityIndicator, // d5bcb272 ActivityIndicatorInstance, // 1b5e0dd5 - ActivityIndicatorProps, // ab46ec7e + ActivityIndicatorProps, // 508a24c6 Alert, // a398a509 AlertButton, // bf1a3b60 AlertButtonStyle, // ec9fb242 AlertOptions, // 8a116d2a AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // ed4648e6 + Animated, // f6f7758c AppConfig, // 35c0ca70 AppRegistry, // edec2a30 AppState, // 12012be5 @@ -5793,9 +5800,9 @@ export { BackPressEventName, // 4620fb76 BlurEvent, // d5e68e96 BoxShadowValue, // b679703f - Button, // 35d2ff2d - ButtonInstance, // 8d4861a8 - ButtonProps, // 5f462ce0 + Button, // f253fc94 + ButtonInstance, // bcf2ef72 + ButtonProps, // 9886bcc6 Clipboard, // 41addb89 CodegenTypes, // 9067fd06 ColorSchemeName, // 6615edd6 @@ -5813,9 +5820,9 @@ export { DimensionsPayload, // 653bc26c DisplayMetrics, // 1dc35cef DisplayMetricsAndroid, // 872e62eb - DrawerLayoutAndroid, // 9385b054 + DrawerLayoutAndroid, // beec8621 DrawerLayoutAndroidInstance, // aa243cbf - DrawerLayoutAndroidProps, // a4ddbda9 + DrawerLayoutAndroidProps, // 53289373 DrawerSlideEvent, // 38213685 DropShadowValue, // e9df2606 DynamicColorIOS, // d96c228c @@ -5831,9 +5838,9 @@ export { EventSubscription, // b8d084aa ExtendedExceptionData, // 5a6ccf5a FilterFunction, // bf24c0e3 - FlatList, // 76dfb3dc - FlatListInstance, // 1d6b2ce4 - FlatListProps, // 14bbe483 + FlatList, // 5ba109bd + FlatListInstance, // 1095fe06 + FlatListProps, // dd43b1d1 FocusEvent, // f2e4363d FontVariant, // 7c7558bb GestureResponderEvent, // 5cd2a663 @@ -5844,17 +5851,17 @@ export { IEventEmitter, // fbef6131 IOSKeyboardEvent, // e67bfe3a IgnorePattern, // ec6f6ece - Image, // 048b0a2a - ImageBackground, // 3d3dc917 - ImageBackgroundInstance, // 400a5719 - ImageBackgroundProps, // 43ae7a8a + Image, // a24a5777 + ImageBackground, // 1fbbacef + ImageBackgroundInstance, // 9c900a60 + ImageBackgroundProps, // b078f0ac ImageErrorEvent, // b6227714 ImageInstance, // df709524 ImageLoadEvent, // 31709c38 ImageProgressEventIOS, // 9243d460 - ImageProps, // 1fcb686b + ImageProps, // e6a7a863 ImagePropsAndroid, // ee00e1d5 - ImagePropsBase, // c744a0da + ImagePropsBase, // f41b5a7c ImagePropsIOS, // 1998f91a ImageRequireSource, // 681d683b ImageResizeMode, // d51106e2 @@ -5872,9 +5879,9 @@ export { KeyEvent, // 20fa4267 KeyUpEvent, // 0b5d0144 Keyboard, // 49414c97 - KeyboardAvoidingView, // 9dab9980 - KeyboardAvoidingViewInstance, // 2772afb3 - KeyboardAvoidingViewProps, // 84e4ee76 + KeyboardAvoidingView, // 1fee106e + KeyboardAvoidingViewInstance, // 26062fd4 + KeyboardAvoidingViewProps, // 635ae7fc KeyboardEvent, // c3f895d4 KeyboardEventEasing, // af4091c8 KeyboardEventName, // 59299ad6 @@ -5899,10 +5906,10 @@ export { MeasureInWindowOnSuccessCallback, // a285f598 MeasureLayoutOnSuccessCallback, // 3592502a MeasureOnSuccessCallback, // 82824e59 - Modal, // 62f03222 + Modal, // f4b88be5 ModalBaseProps, // a6e4334f ModalInstance, // 0f04c1bf - ModalProps, // b9129837 + ModalProps, // c07a247a ModalPropsAndroid, // 515fb173 ModalPropsIOS, // a65a8f5c ModeChangeEvent, // ccf30dc4 @@ -5938,15 +5945,15 @@ export { PointerEvent, // a506d9d7 PressabilityConfig, // 0fd655fe PressabilityEventHandlers, // be359ba3 - Pressable, // c52c0096 + Pressable, // 56235a5f PressableAndroidRippleConfig, // ee32eaca PressableInstance, // ed26f040 - PressableProps, // 97cdaaa1 + PressableProps, // 7a18f8cd PressableStateCallbackType, // f83728cd ProcessedColorValue, // 33f74304 - ProgressBarAndroid, // a2626c30 + ProgressBarAndroid, // 30b4a1eb ProgressBarAndroidInstance, // f17242be - ProgressBarAndroidProps, // 7205b2a5 + ProgressBarAndroidProps, // 98fa6a86 PublicRootInstance, // 8040afd7 PublicTextInstance, // f9adf69a PushNotificationEventName, // 84e7e150 @@ -5954,9 +5961,9 @@ export { PushNotificationPermissions, // c2e7ae4f Rationale, // 5df1b1c1 ReactNativeVersion, // abd76827 - RefreshControl, // db24bb7e - RefreshControlInstance, // d076b876 - RefreshControlProps, // f95b4cd0 + RefreshControl, // e74ab0c4 + RefreshControlInstance, // 7e1aa88d + RefreshControlProps, // e728e273 RefreshControlPropsAndroid, // 8ac931ca RefreshControlPropsIOS, // 72a36381 Registry, // 6c39216d @@ -5969,24 +5976,24 @@ export { Runnable, // 594dd93a Runnables, // 4367c557 SafeAreaInsetsChangeEvent, // cedb5309 - SafeAreaView, // d917b6ec + SafeAreaView, // dda99293 SafeAreaViewInstance, // 2b5d4391 ScaledSize, // 07e417c7 ScrollEvent, // 956fdb27 - ScrollResponderType, // e41ccb2a + ScrollResponderType, // 52c38666 ScrollToLocationParamsType, // d7ecdad1 - ScrollView, // 651915c2 - ScrollViewImperativeMethods, // cb73da62 - ScrollViewInstance, // 099b48e2 - ScrollViewProps, // 7e38d97b + ScrollView, // 08fe7620 + ScrollViewImperativeMethods, // 682315af + ScrollViewInstance, // bcc836b9 + ScrollViewProps, // d845721f ScrollViewPropsAndroid, // 02f3df2e ScrollViewPropsIOS, // c378379b ScrollViewScrollToOptions, // 3313411e SectionBase, // 9f13db00 - SectionList, // 542a3017 + SectionList, // b48c3713 SectionListData, // 1a4de01a - SectionListInstance, // ea2f03e4 - SectionListProps, // d6282d2b + SectionListInstance, // e2814c16 + SectionListProps, // bf7d4c45 SectionListRenderItem, // 715b2086 SectionListRenderItemInfo, // 4a48a922 Separators, // 6a45f7e3 @@ -6005,17 +6012,17 @@ export { StyleProp, // fa0e9b4a StyleSheet, // 8bb13f55 SubmitBehavior, // c4ddf490 - Switch, // 408983ac + Switch, // 7ea8a527 SwitchChangeEvent, // 3b472666 SwitchInstance, // 73dce99f - SwitchProps, // fc2b9dfe + SwitchProps, // aa00dcf4 Systrace, // 626d178c TVViewPropsIOS, // 1c612e4e TargetedEvent, // 16e98910 TaskProvider, // 266dedf2 - Text, // 39a4d091 + Text, // 6f8269c1 TextContentType, // 239b3ecc - TextInput, // 9981d7ab + TextInput, // cf6467dc TextInputAndroidProps, // 9ebbc103 TextInputBlurEvent, // b4c7a58c TextInputChangeEvent, // 92363e10 @@ -6025,34 +6032,34 @@ export { TextInputIOSProps, // fb3c9327 TextInputInstance, // 53fe7b73 TextInputKeyPressEvent, // cbbc322a - TextInputProps, // 253dd17d + TextInputProps, // bb087c92 TextInputSelectionChangeEvent, // 4a8f10c2 TextInputSubmitEditingEvent, // e811fdbd TextInstance, // 7c7e2069 TextLayoutEvent, // 1a21e24a - TextProps, // e4fdaaad + TextProps, // ab00c755 TextStyle, // 898adfd1 ToastAndroid, // 88a8969a - TouchableHighlight, // c5f5b1c2 + TouchableHighlight, // a015710f TouchableHighlightInstance, // 811af7ca - TouchableHighlightProps, // c35edf1d - TouchableNativeFeedback, // bd8a9aa5 - TouchableNativeFeedbackInstance, // 7010d8d4 - TouchableNativeFeedbackProps, // 827f5e03 - TouchableOpacity, // db1102e5 + TouchableHighlightProps, // faaeb188 + TouchableNativeFeedback, // 744ee0ee + TouchableNativeFeedbackInstance, // b90e3bae + TouchableNativeFeedbackProps, // 39abde16 + TouchableOpacity, // 6aec357f TouchableOpacityInstance, // 0cd8a347 - TouchableOpacityProps, // 1bc8fbaa - TouchableWithoutFeedback, // a397bd6c - TouchableWithoutFeedbackProps, // 9dfdc3eb + TouchableOpacityProps, // 0d78e636 + TouchableWithoutFeedback, // 92cfc35d + TouchableWithoutFeedbackProps, // 7e8a325c TransformsStyle, // 65e70f18 TurboModule, // dfe29706 TurboModuleRegistry, // 4ace6db2 UIManager, // 6a18fc05 UTFSequence, // ad625158 Vibration, // 31e4bbf8 - View, // fe623c3d + View, // 85575778 ViewInstance, // 0283d2fc - ViewProps, // 7fb992e4 + ViewProps, // 43cbabe5 ViewPropsAndroid, // 66d760c6 ViewPropsIOS, // 58ee19bf ViewStyle, // 4e2746d6 @@ -6061,10 +6068,10 @@ export { VirtualViewMode, // 6be59722 VirtualizedList, // 68c7345e VirtualizedListInstance, // 423ee7c0 - VirtualizedListProps, // 7a7c618a + VirtualizedListProps, // 09a41c15 VirtualizedSectionList, // 9fd9cd61 VirtualizedSectionListInstance, // 12b706d5 - VirtualizedSectionListProps, // 8373f27c + VirtualizedSectionListProps, // c63aa8a2 WrapperComponentProvider, // 9b4247f6 codegenNativeCommands, // 322f3f4e codegenNativeComponent, // 3da62daa diff --git a/packages/react-native/types_DEPRECATED/Libraries/Components/View/ViewAccessibility.d.ts b/packages/react-native/types_DEPRECATED/Libraries/Components/View/ViewAccessibility.d.ts index ab4fd7a62b05..17e3e719b1d0 100644 --- a/packages/react-native/types_DEPRECATED/Libraries/Components/View/ViewAccessibility.d.ts +++ b/packages/react-native/types_DEPRECATED/Libraries/Components/View/ViewAccessibility.d.ts @@ -115,6 +115,11 @@ export type AccessibilityActionName = * Generated when a screen reader user decrements an adjustable component. */ | 'decrement' + /** + * Generated when an accessibility service sets an adjustable component to a specific value. + * @platform android + */ + | 'setProgress' /** * Generated when a TalkBack user places accessibility focus on the component and double taps and holds one finger on the screen. * @platform android @@ -134,6 +139,7 @@ export type AccessibilityActionName = export type AccessibilityActionEvent = NativeSyntheticEvent< Readonly<{ actionName: string; + value?: number | undefined; }> >;