diff --git a/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js b/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js index 3933de41bc9d95..ed2c4a3644e1d2 100644 --- a/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -19,6 +19,11 @@ import NativeAccessibilityManagerIOS from './NativeAccessibilityManager'; import legacySendAccessibilityEvent from './legacySendAccessibilityEvent'; import type {ElementRef} from 'react'; +// Events that are only supported on Android. +type AccessibilityEventDefinitionsAndroid = { + accessibilityServiceChanged: [boolean], +}; + // Events that are only supported on iOS. type AccessibilityEventDefinitionsIOS = { announcementFinished: [{announcement: string, success: boolean}], @@ -29,6 +34,7 @@ type AccessibilityEventDefinitionsIOS = { }; type AccessibilityEventDefinitions = { + ...AccessibilityEventDefinitionsAndroid, ...AccessibilityEventDefinitionsIOS, change: [boolean], // screenReaderChanged reduceMotionChanged: [boolean], @@ -44,6 +50,7 @@ const EventNames: Map<$Keys, string> = ['change', 'touchExplorationDidChange'], ['reduceMotionChanged', 'reduceMotionDidChange'], ['screenReaderChanged', 'touchExplorationDidChange'], + ['accessibilityServiceChanged', 'accessibilityServiceDidChange'], ]) : new Map([ ['announcementFinished', 'announcementFinished'], @@ -224,6 +231,33 @@ const AccessibilityInfo = { }); }, + /** + * Query whether Accessibility Service is currently enabled. + * + * Returns a promise which resolves to a boolean. + * The result is `true` when any service is enabled and `false` otherwise. + * + * @platform android + * + * See https://reactnative.dev/docs/accessibilityinfo/#isaccessibilityserviceenabled-android + */ + isAccessibilityServiceEnabled(): Promise { + return new Promise((resolve, reject) => { + if (Platform.OS === 'android') { + if ( + NativeAccessibilityInfoAndroid != null && + NativeAccessibilityInfoAndroid.isAccessibilityServiceEnabled != null + ) { + NativeAccessibilityInfoAndroid.isAccessibilityServiceEnabled(resolve); + } else { + reject(null); + } + } else { + reject(null); + } + }); + }, + /** * Add an event handler. Supported events: * diff --git a/Libraries/Components/AccessibilityInfo/NativeAccessibilityInfo.js b/Libraries/Components/AccessibilityInfo/NativeAccessibilityInfo.js index 916667aa5c2c0a..c9eecfde41ce53 100644 --- a/Libraries/Components/AccessibilityInfo/NativeAccessibilityInfo.js +++ b/Libraries/Components/AccessibilityInfo/NativeAccessibilityInfo.js @@ -18,6 +18,9 @@ export interface Spec extends TurboModule { +isTouchExplorationEnabled: ( onSuccess: (isScreenReaderEnabled: boolean) => void, ) => void; + +isAccessibilityServiceEnabled?: ?( + onSuccess: (isAccessibilityServiceEnabled: boolean) => void, + ) => void; +setAccessibilityFocus: (reactTag: number) => void; +announceForAccessibility: (announcement: string) => void; +getRecommendedTimeoutMillis?: ( diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModule.java b/ReactAndroid/src/main/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModule.java index ad148228cd9bc0..b68412bcef7271 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModule.java @@ -46,6 +46,19 @@ public void onTouchExplorationStateChanged(boolean enabled) { } } + // Android can listen for accessibility service enable with `accessibilityStateChange`, but + // `accessibilityState` conflicts with React Native props and confuses developers. Therefore, the + // name `accessibilityServiceChange` is used here instead. + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private class ReactAccessibilityServiceChangeListener + implements AccessibilityManager.AccessibilityStateChangeListener { + + @Override + public void onAccessibilityStateChanged(boolean enabled) { + updateAndSendAccessibilityServiceChangeEvent(enabled); + } + } + // Listener that is notified when the global TRANSITION_ANIMATION_SCALE. private final ContentObserver animationScaleObserver = new ContentObserver(new Handler(Looper.getMainLooper())) { @@ -64,13 +77,16 @@ public void onChange(boolean selfChange, Uri uri) { private @Nullable AccessibilityManager mAccessibilityManager; private @Nullable ReactTouchExplorationStateChangeListener mTouchExplorationStateChangeListener; + private @Nullable ReactAccessibilityServiceChangeListener mAccessibilityServiceChangeListener; private final ContentResolver mContentResolver; private boolean mReduceMotionEnabled = false; private boolean mTouchExplorationEnabled = false; + private boolean mAccessibilityServiceEnabled = false; private int mRecommendedTimeout; private static final String REDUCE_MOTION_EVENT_NAME = "reduceMotionDidChange"; private static final String TOUCH_EXPLORATION_EVENT_NAME = "touchExplorationDidChange"; + private static final String ACCESSIBILITY_SERVICE_EVENT_NAME = "accessibilityServiceDidChange"; public AccessibilityInfoModule(ReactApplicationContext context) { super(context); @@ -79,8 +95,10 @@ public AccessibilityInfoModule(ReactApplicationContext context) { (AccessibilityManager) appContext.getSystemService(Context.ACCESSIBILITY_SERVICE); mContentResolver = getReactApplicationContext().getContentResolver(); mTouchExplorationEnabled = mAccessibilityManager.isTouchExplorationEnabled(); + mAccessibilityServiceEnabled = mAccessibilityManager.isEnabled(); mReduceMotionEnabled = this.getIsReduceMotionEnabledValue(); mTouchExplorationStateChangeListener = new ReactTouchExplorationStateChangeListener(); + mAccessibilityServiceChangeListener = new ReactAccessibilityServiceChangeListener(); } @Override @@ -106,6 +124,11 @@ public void isTouchExplorationEnabled(Callback successCallback) { successCallback.invoke(mTouchExplorationEnabled); } + @Override + public void isAccessibilityServiceEnabled(Callback successCallback) { + successCallback.invoke(mAccessibilityServiceEnabled); + } + private void updateAndSendReduceMotionChangeEvent() { boolean isReduceMotionEnabled = this.getIsReduceMotionEnabledValue(); @@ -134,16 +157,31 @@ private void updateAndSendTouchExplorationChangeEvent(boolean enabled) { } } + private void updateAndSendAccessibilityServiceChangeEvent(boolean enabled) { + if (mAccessibilityServiceEnabled != enabled) { + mAccessibilityServiceEnabled = enabled; + + ReactApplicationContext reactApplicationContext = getReactApplicationContextIfActiveOrWarn(); + if (reactApplicationContext != null) { + getReactApplicationContext() + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit(ACCESSIBILITY_SERVICE_EVENT_NAME, mAccessibilityServiceEnabled); + } + } + } + @Override @TargetApi(Build.VERSION_CODES.LOLLIPOP) public void onHostResume() { mAccessibilityManager.addTouchExplorationStateChangeListener( mTouchExplorationStateChangeListener); + mAccessibilityManager.addAccessibilityStateChangeListener(mAccessibilityServiceChangeListener); Uri transitionUri = Settings.Global.getUriFor(Settings.Global.TRANSITION_ANIMATION_SCALE); mContentResolver.registerContentObserver(transitionUri, false, animationScaleObserver); updateAndSendTouchExplorationChangeEvent(mAccessibilityManager.isTouchExplorationEnabled()); + updateAndSendAccessibilityServiceChangeEvent(mAccessibilityManager.isEnabled()); updateAndSendReduceMotionChangeEvent(); } @@ -152,6 +190,8 @@ public void onHostResume() { public void onHostPause() { mAccessibilityManager.removeTouchExplorationStateChangeListener( mTouchExplorationStateChangeListener); + mAccessibilityManager.removeAccessibilityStateChangeListener( + mAccessibilityServiceChangeListener); mContentResolver.unregisterContentObserver(animationScaleObserver); } @@ -160,6 +200,7 @@ public void onHostPause() { public void initialize() { getReactApplicationContext().addLifecycleEventListener(this); updateAndSendTouchExplorationChangeEvent(mAccessibilityManager.isTouchExplorationEnabled()); + updateAndSendAccessibilityServiceChangeEvent(mAccessibilityManager.isEnabled()); updateAndSendReduceMotionChangeEvent(); } diff --git a/jest/setup.js b/jest/setup.js index 778778859da0ce..8aed65f3b82feb 100644 --- a/jest/setup.js +++ b/jest/setup.js @@ -123,6 +123,7 @@ jest default: { addEventListener: jest.fn(), announceForAccessibility: jest.fn(), + isAccessibilityServiceEnabled: jest.fn(), isBoldTextEnabled: jest.fn(), isGrayscaleEnabled: jest.fn(), isInvertColorsEnabled: jest.fn(), diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index 6133080a4adfd0..95734b01d612a2 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js @@ -942,6 +942,19 @@ class EnabledExamples extends React.Component<{}> { ) : null} + {Platform.OS === 'android' ? ( + + + + ) : null} + { this.setState({isEnabled: state}); }); + case 'accessibilityServiceChanged': + return AccessibilityInfo.isAccessibilityServiceEnabled().then(state => { + this.setState({isEnabled: state}); + }); default: return null; }