diff --git a/packages/jest-preset/jest/mocks/AccessibilityInfo.js b/packages/jest-preset/jest/mocks/AccessibilityInfo.js index 4dc215bcabd7..6ea06ae40baf 100644 --- a/packages/jest-preset/jest/mocks/AccessibilityInfo.js +++ b/packages/jest-preset/jest/mocks/AccessibilityInfo.js @@ -17,6 +17,9 @@ const AccessibilityInfo = { $FlowFixMe, $FlowFixMe, >, + getEnabledAccessibilityServices: jest.fn(() => + Promise.resolve([]), + ) as JestMockFn<$FlowFixMe, $FlowFixMe>, isAccessibilityServiceEnabled: jest.fn(() => Promise.resolve(false), ) as JestMockFn<$FlowFixMe, $FlowFixMe>, diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts index f2405fd59b5e..b0c04cbd2eb4 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts @@ -123,6 +123,18 @@ export interface AccessibilityInfoStatic { */ isAccessibilityServiceEnabled(): Promise; + /** + * Query the enabled accessibility services. + * + * Returns a promise which resolves to an array of enabled accessibility + * service IDs (from `AccessibilityServiceInfo.getId()`). + * + * @platform android + */ + getEnabledAccessibilityServices( + feedbackTypeFlags?: number, + ): Promise>; + /** * Add an event handler. Supported events: * - announcementFinished: iOS-only event. Fires when the screen reader has finished making an announcement. diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js index b922c77f0626..084303fe96e1 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -372,6 +372,40 @@ const AccessibilityInfo = { }); }, + /** + * Query the enabled accessibility services. + * + * Returns a promise which resolves to an array of enabled accessibility + * service IDs (from `AccessibilityServiceInfo.getId()`). + * + * @platform android + */ + getEnabledAccessibilityServices( + feedbackTypeFlags: number = -1, + ): Promise> { + if (Platform.OS === 'android') { + return new Promise((resolve, reject) => { + if ( + NativeAccessibilityInfoAndroid != null && + NativeAccessibilityInfoAndroid.getEnabledAccessibilityServices != null + ) { + NativeAccessibilityInfoAndroid.getEnabledAccessibilityServices( + feedbackTypeFlags, + resolve, + ); + } else { + reject( + new Error( + 'NativeAccessibilityInfoAndroid.getEnabledAccessibilityServices is not available', + ), + ); + } + }); + } else { + return Promise.resolve([]); + } + }, + /** * 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 9e3d63ca330d..504aa369a4d6 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js @@ -104,6 +104,10 @@ let mockNativeAccessibilityManagerIOS: { }; const ANDROID_RECOMMENDED_TIMEOUT = 6000; +const ANDROID_ENABLED_SERVICES = [ + 'com.example.AccessibilityService1', + 'com.example.AccessibilityService2', +]; const mockIsHighTextContrastEnabled = jest.fn(onSuccess => onSuccess(true)); const mockIsGrayscaleEnabled = jest.fn(onSuccess => onSuccess(true)); const mockIsInvertColorsEnabled = jest.fn(onSuccess => onSuccess(true)); @@ -113,6 +117,9 @@ const mockIsAccessibilityServiceEnabled = jest.fn(onSuccess => onSuccess(true)); const mockGetRecommendedTimeoutMillis = jest.fn((originalTimeout, onSuccess) => onSuccess(ANDROID_RECOMMENDED_TIMEOUT), ); +const mockGetEnabledAccessibilityServices = jest.fn( + (feedbackTypeFlags, onSuccess) => onSuccess(ANDROID_ENABLED_SERVICES), +); let mockNativeAccessibilityInfoAndroid: { isHighTextContrastEnabled: JestMockFn< [onSuccess: (isHighTextContrastEnabled: boolean) => void], @@ -142,6 +149,13 @@ let mockNativeAccessibilityInfoAndroid: { [originalTimeout: number, onSuccess: (recommendedTimeout: number) => void], void, > | null, + getEnabledAccessibilityServices: JestMockFn< + [ + feedbackTypeFlags: number, + onSuccess: (enabledServices: Array) => void, + ], + void, + > | null, } = { isHighTextContrastEnabled: mockIsHighTextContrastEnabled, isGrayscaleEnabled: mockIsGrayscaleEnabled, @@ -150,6 +164,7 @@ let mockNativeAccessibilityInfoAndroid: { isTouchExplorationEnabled: mockIsTouchExplorationEnabled, isAccessibilityServiceEnabled: mockIsAccessibilityServiceEnabled, getRecommendedTimeoutMillis: mockGetRecommendedTimeoutMillis, + getEnabledAccessibilityServices: mockGetEnabledAccessibilityServices, }; jest.mock('../NativeAccessibilityManager', () => ({ @@ -789,6 +804,69 @@ describe('AccessibilityInfo', () => { }); }); + describe('getEnabledAccessibilityServices', () => { + describe('Android', () => { + it('should return the enabled accessibility service IDs', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'android'; + + const enabledServices = + await AccessibilityInfo.getEnabledAccessibilityServices(); + + expect(mockGetEnabledAccessibilityServices).toHaveBeenCalledWith( + -1, + expect.any(Function), + ); + expect(enabledServices).toEqual(ANDROID_ENABLED_SERVICES); + }); + + it('should pass the provided feedbackTypeFlags to native', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'android'; + + await AccessibilityInfo.getEnabledAccessibilityServices(1); + + expect(mockGetEnabledAccessibilityServices).toHaveBeenCalledWith( + 1, + expect.any(Function), + ); + }); + + 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', + ); + }); + }); + + describe('iOS', () => { + it('should return an empty array', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'ios'; + + const enabledServices = + await AccessibilityInfo.getEnabledAccessibilityServices(); + + expect(enabledServices).toEqual([]); + }); + }); + }); + afterEach(() => { mockNativeAccessibilityManagerIOS = { getCurrentPrefersCrossFadeTransitionsState: @@ -812,6 +890,7 @@ describe('AccessibilityInfo', () => { isTouchExplorationEnabled: mockIsTouchExplorationEnabled, isAccessibilityServiceEnabled: mockIsAccessibilityServiceEnabled, getRecommendedTimeoutMillis: mockGetRecommendedTimeoutMillis, + getEnabledAccessibilityServices: mockGetEnabledAccessibilityServices, }; jest.requireMock('../NativeAccessibilityInfo').default = mockNativeAccessibilityInfoAndroid; 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..388706f396c8 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 @@ -20,6 +21,7 @@ import com.facebook.react.bridge.Callback import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.bridge.WritableNativeArray import com.facebook.react.module.annotations.ReactModule /** @@ -178,6 +180,20 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : successCallback.invoke(accessibilityServiceEnabled) } + override fun getEnabledAccessibilityServices( + feedbackTypeFlags: Double, + successCallback: Callback, + ) { + val enabledServices = + accessibilityManager?.getEnabledAccessibilityServiceList(feedbackTypeFlags.toInt()) + ?: emptyList() + val serviceIds = WritableNativeArray() + for (service in enabledServices) { + service.id?.let { serviceIds.pushString(it) } + } + successCallback.invoke(serviceIds) + } + 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 556c8117198d..ba525c1b4614 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<<53280bff7703ea0e9e912e22543fd568>> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -102,6 +102,9 @@ declare const AccessibilityInfo_default: { queue?: boolean }, ): void + getEnabledAccessibilityServices( + feedbackTypeFlags?: number, + ): Promise> getRecommendedTimeoutMillis(originalTimeout: number): Promise isAccessibilityServiceEnabled(): Promise isBoldTextEnabled(): Promise @@ -5705,7 +5708,7 @@ declare type WrapperComponentProvider = ( export { AccessibilityActionEvent, // a0d4daa0 AccessibilityActionInfo, // db47a917 - AccessibilityInfo, // 23a3aa9b + AccessibilityInfo, // ec383129 AccessibilityProps, // eda80653 AccessibilityRole, // f2f2e066 AccessibilityState, // b0c2b3f7 diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx index 54ea8b7694ba..59d57877d47f 100644 --- a/packages/react-native/__typetests__/index.tsx +++ b/packages/react-native/__typetests__/index.tsx @@ -1487,6 +1487,11 @@ AccessibilityInfo.getRecommendedTimeoutMillis(5000).then(timeoutMiles => `AccessibilityInfo.getRecommendedTimeoutMillis => ${timeoutMiles}`, ), ); +AccessibilityInfo.getEnabledAccessibilityServices(1).then(services => + console.log( + `AccessibilityInfo.getEnabledAccessibilityServices => ${services}`, + ), +); AccessibilityInfo.addEventListener('accessibilityServiceChanged', isEnabled => console.log(`AccessibilityInfo.accessibilityServiceChanged => ${isEnabled}`), 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..02d18a976dab 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityInfo.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityInfo.js @@ -34,6 +34,10 @@ export interface Spec extends TurboModule { mSec: number, onSuccess: (recommendedTimeoutMillis: number) => void, ) => void; + readonly getEnabledAccessibilityServices?: ( + feedbackTypeFlags: number, + onSuccess: (enabledServices: Array) => void, + ) => void; readonly isGrayscaleEnabled?: ( onSuccess: (isGrayscaleEnabled: boolean) => void, ) => void;