diff --git a/packages/jest-preset/jest/mocks/AccessibilityInfo.js b/packages/jest-preset/jest/mocks/AccessibilityInfo.js index d49f63d51ebf..6ba003ab03a4 100644 --- a/packages/jest-preset/jest/mocks/AccessibilityInfo.js +++ b/packages/jest-preset/jest/mocks/AccessibilityInfo.js @@ -20,6 +20,9 @@ const AccessibilityInfo = { isAccessibilityServiceEnabled: jest.fn(() => Promise.resolve(false), ) as JestMockFn<$FlowFixMe, $FlowFixMe>, + getEnabledAccessibilityServices: jest.fn(() => + Promise.resolve([]), + ) as JestMockFn<$FlowFixMe, $FlowFixMe>, isShowBordersEnabled: jest.fn(() => Promise.resolve(false)) as JestMockFn< $FlowFixMe, $FlowFixMe, diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js index 017c1d3c842e..7dd35a9e8e1b 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -404,6 +404,42 @@ const AccessibilityInfo = { }); }, + /** + * Query the accessibility services that are currently enabled. + * + * Returns a promise which resolves to an array of service IDs, such as + * `com.google.android.marvin.talkback/.TalkBackService`. The array is empty + * when no service is enabled. + * + * @platform android + */ + getEnabledAccessibilityServices(): Promise> { + return new Promise((resolve, reject) => { + if (Platform.OS === 'android') { + if ( + NativeAccessibilityInfoAndroid != null && + NativeAccessibilityInfoAndroid.getEnabledAccessibilityServices != null + ) { + NativeAccessibilityInfoAndroid.getEnabledAccessibilityServices( + resolve, + ); + } else { + reject( + new Error( + 'NativeAccessibilityInfoAndroid.getEnabledAccessibilityServices is not available', + ), + ); + } + } else { + reject( + new Error( + 'getEnabledAccessibilityServices is only available on Android', + ), + ); + } + }); + }, + /** * Add an event handler. Supported events: * diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js index 380be9fdd394..6e6659117a3b 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js @@ -121,6 +121,12 @@ const mockIsInvertColorsEnabled = jest.fn(onSuccess => onSuccess(true)); const mockIsReduceMotionEnabled = jest.fn(onSuccess => onSuccess(true)); const mockIsTouchExplorationEnabled = jest.fn(onSuccess => onSuccess(true)); const mockIsAccessibilityServiceEnabled = jest.fn(onSuccess => onSuccess(true)); +const ANDROID_ENABLED_ACCESSIBILITY_SERVICES = [ + 'com.google.android.marvin.talkback/.TalkBackService', +]; +const mockGetEnabledAccessibilityServices = jest.fn(onSuccess => + onSuccess(ANDROID_ENABLED_ACCESSIBILITY_SERVICES), +); const mockGetRecommendedTimeoutMillis = jest.fn((originalTimeout, onSuccess) => onSuccess(ANDROID_RECOMMENDED_TIMEOUT), ); @@ -149,6 +155,10 @@ let mockNativeAccessibilityInfoAndroid: { [onSuccess: (isAccessibilityServiceEnabled: boolean) => void], void, > | null, + getEnabledAccessibilityServices: JestMockFn< + [onSuccess: (enabledAccessibilityServices: Array) => void], + void, + > | null, getRecommendedTimeoutMillis: JestMockFn< [originalTimeout: number, onSuccess: (recommendedTimeout: number) => void], void, @@ -160,6 +170,7 @@ let mockNativeAccessibilityInfoAndroid: { isReduceMotionEnabled: mockIsReduceMotionEnabled, isTouchExplorationEnabled: mockIsTouchExplorationEnabled, isAccessibilityServiceEnabled: mockIsAccessibilityServiceEnabled, + getEnabledAccessibilityServices: mockGetEnabledAccessibilityServices, getRecommendedTimeoutMillis: mockGetRecommendedTimeoutMillis, }; @@ -553,6 +564,87 @@ describe('AccessibilityInfo', () => { }); }); + describe('getEnabledAccessibilityServices', () => { + describe('Android', () => { + it('should call getEnabledAccessibilityServices if available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'android'; + + const enabledAccessibilityServices = + await AccessibilityInfo.getEnabledAccessibilityServices(); + + expect(mockGetEnabledAccessibilityServices).toHaveBeenCalled(); + expect(enabledAccessibilityServices).toEqual( + ANDROID_ENABLED_ACCESSIBILITY_SERVICES, + ); + }); + + it('should throw error if getEnabledAccessibilityServices is not available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'android'; + + mockNativeAccessibilityInfoAndroid.getEnabledAccessibilityServices = + null; + + const result: unknown = + await AccessibilityInfo.getEnabledAccessibilityServices().catch( + e => e, + ); + + invariant( + result instanceof Error, + 'Expected getEnabledAccessibilityServices to reject', + ); + expect(result.message).toEqual( + 'NativeAccessibilityInfoAndroid.getEnabledAccessibilityServices is not available', + ); + }); + + it('should throw error if NativeAccessibilityInfoAndroid is not available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'android'; + + const nativeAccessibilityInfoModule = jest.requireMock( + '../NativeAccessibilityInfo', + ); + nativeAccessibilityInfoModule.default = null; + + const result: unknown = + await AccessibilityInfo.getEnabledAccessibilityServices().catch( + e => e, + ); + + invariant( + result instanceof Error, + 'Expected getEnabledAccessibilityServices to reject', + ); + expect(result.message).toEqual( + 'NativeAccessibilityInfoAndroid.getEnabledAccessibilityServices is not available', + ); + }); + }); + + describe('iOS', () => { + it('should reject because getEnabledAccessibilityServices is only available on Android', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'ios'; + + const result: unknown = + await AccessibilityInfo.getEnabledAccessibilityServices().catch( + e => e, + ); + + invariant( + result instanceof Error, + 'Expected getEnabledAccessibilityServices to reject', + ); + expect(result.message).toEqual( + 'getEnabledAccessibilityServices is only available on Android', + ); + }); + }); + }); + describe('isReduceMotionEnabled', () => { describe('Android', () => { it('should call isReduceMotionEnabled if available', async () => { @@ -866,6 +958,7 @@ describe('AccessibilityInfo', () => { isReduceMotionEnabled: mockIsReduceMotionEnabled, isTouchExplorationEnabled: mockIsTouchExplorationEnabled, isAccessibilityServiceEnabled: mockIsAccessibilityServiceEnabled, + getEnabledAccessibilityServices: mockGetEnabledAccessibilityServices, getRecommendedTimeoutMillis: mockGetRecommendedTimeoutMillis, }; jest.requireMock('../NativeAccessibilityInfo').default = diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModule.kt index 2bad463beacf..bb2a30e31a8c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModule.kt @@ -7,6 +7,7 @@ package com.facebook.react.modules.accessibilityinfo +import android.accessibilityservice.AccessibilityServiceInfo import android.content.ContentResolver import android.content.Context import android.database.ContentObserver @@ -16,6 +17,7 @@ import android.provider.Settings import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityManager import com.facebook.fbreact.specs.NativeAccessibilityInfoSpec +import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Callback import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.ReactApplicationContext @@ -178,6 +180,14 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : successCallback.invoke(accessibilityServiceEnabled) } + override fun getEnabledAccessibilityServices(successCallback: Callback) { + val enabledServices = Arguments.createArray() + accessibilityManager + ?.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) + ?.forEach { serviceInfo -> serviceInfo.id?.let { enabledServices.pushString(it) } } + successCallback.invoke(enabledServices) + } + private fun updateAndSendReduceMotionChangeEvent() { val isReduceMotionEnabled = isReduceMotionEnabledValue if (reduceMotionEnabled != isReduceMotionEnabled) { diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 89b0fe69edd8..6e34ad5c08bc 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<<29785e09db994cf0d43e7426f47c1b30>> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -91,6 +91,7 @@ declare const AccessibilityInfo_default: { queue?: boolean }, ): void + getEnabledAccessibilityServices(): Promise> getRecommendedTimeoutMillis(originalTimeout: number): Promise isAccessibilityServiceEnabled(): Promise isBoldTextEnabled(): Promise @@ -5773,7 +5774,7 @@ declare type WrapperComponentProvider = ( export { AccessibilityActionEvent, // a276ec6e AccessibilityActionInfo, // c94545a7 - AccessibilityInfo, // 7d8718ed + AccessibilityInfo, // 132a4e01 AccessibilityProps, // 95dbb1d8 AccessibilityRole, // f2f2e066 AccessibilityState, // b0c2b3f7 diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityInfo.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityInfo.js index d17c50925e9f..bb8d54b8c867 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityInfo.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityInfo.js @@ -28,6 +28,9 @@ export interface Spec extends TurboModule { readonly isAccessibilityServiceEnabled?: ?( onSuccess: (isAccessibilityServiceEnabled: boolean) => void, ) => void; + readonly getEnabledAccessibilityServices?: ?( + onSuccess: (enabledAccessibilityServices: Array) => void, + ) => void; readonly setAccessibilityFocus: (reactTag: number) => void; readonly announceForAccessibility: (announcement: string) => void; readonly getRecommendedTimeoutMillis?: ( diff --git a/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts b/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts index 6f2ccb478328..96cfb3478382 100644 --- a/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts +++ b/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts @@ -132,6 +132,17 @@ export interface AccessibilityInfoStatic { */ isAccessibilityServiceEnabled(): Promise; + /** + * Query the accessibility services that are currently enabled. + * + * Returns a promise which resolves to an array of service IDs, such as + * `com.google.android.marvin.talkback/.TalkBackService`. The array is empty + * when no service is enabled. + * + * @platform android + */ + getEnabledAccessibilityServices(): Promise>; + /** * Add an event handler. Supported events: * - announcementFinished: iOS-only event. Fires when the screen reader has finished making an announcement. diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 483efc9b46d2..b112dccb128d 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -2134,6 +2134,7 @@ interface _RCTTypedModuleConstants : public NSDictionary : public NSDictionary : public NSDictionary : public NSDictionary