diff --git a/packages/jest-preset/jest/mocks/AccessibilityInfo.js b/packages/jest-preset/jest/mocks/AccessibilityInfo.js index 4dc215bcabd7..3cc2fa5437a3 100644 --- a/packages/jest-preset/jest/mocks/AccessibilityInfo.js +++ b/packages/jest-preset/jest/mocks/AccessibilityInfo.js @@ -20,6 +20,10 @@ const AccessibilityInfo = { isAccessibilityServiceEnabled: jest.fn(() => Promise.resolve(false), ) as JestMockFn<$FlowFixMe, $FlowFixMe>, + getContrastLevel: jest.fn(() => Promise.resolve(0)) as JestMockFn< + $FlowFixMe, + $FlowFixMe, + >, isBoldTextEnabled: 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 b922c77f0626..a7cfb3e977e1 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -21,6 +21,7 @@ import NativeAccessibilityManagerIOS from './NativeAccessibilityManager'; // Events that are only supported on Android. type AccessibilityEventDefinitionsAndroid = { accessibilityServiceChanged: [boolean], + contrastLevelChanged: [number], highTextContrastChanged: [boolean], }; @@ -52,6 +53,7 @@ const EventNames: Map = ['change', 'touchExplorationDidChange'], ['reduceMotionChanged', 'reduceMotionDidChange'], ['highTextContrastChanged', 'highTextContrastDidChange'], + ['contrastLevelChanged', 'contrastLevelDidChange'], ['screenReaderChanged', 'touchExplorationDidChange'], ['accessibilityServiceChanged', 'accessibilityServiceDidChange'], ['invertColorsChanged', 'invertColorDidChange'], @@ -223,6 +225,35 @@ const AccessibilityInfo = { } }, + /** + * Query the contrast level chosen in the system settings. + * + * Returns a promise which resolves to a number from -1 to 1, where 0 is + * the standard contrast and higher values mean more contrast. This is the + * Android equivalent of "Increase Contrast" on iOS + * (see `isDarkerSystemColorsEnabled`). Requires Android 14 (API 34) or + * later; earlier versions resolve to 0. + * + * @platform android + */ + getContrastLevel(): Promise { + if (Platform.OS === 'android') { + return new Promise((resolve, reject) => { + if (NativeAccessibilityInfoAndroid?.getContrastLevel != null) { + NativeAccessibilityInfoAndroid.getContrastLevel(resolve); + } else { + reject( + new Error( + 'NativeAccessibilityInfoAndroid.getContrastLevel is not available', + ), + ); + } + }); + } else { + return Promise.resolve(0); + } + }, + /** * Query whether darker system colors is currently enabled. * @@ -409,6 +440,9 @@ const AccessibilityInfo = { * * These events are only supported on Android: * + * - `contrastLevelChanged`: Android-only event. Fires when the contrast level + * changes. The argument to the event handler is a number from -1 to 1 + * (see `getContrastLevel`). Requires Android 14 (API 34) or later. * - `highTextContrastChanged`: Android-only event. Fires when the state of the high text contrast * toggle changes. The argument to the event handler is a boolean. The boolean is `true` when * high text contrast is enabled and `false` otherwise. diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-contrastLevelChanged-test.js b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-contrastLevelChanged-test.js new file mode 100644 index 000000000000..17c031e7c79a --- /dev/null +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-contrastLevelChanged-test.js @@ -0,0 +1,74 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import typeof RCTDeviceEventEmitterType from '../../../EventEmitter/RCTDeviceEventEmitter'; +import typeof AccessibilityInfoType from '../AccessibilityInfo'; + +const invariant = require('invariant'); + +jest.unmock('../AccessibilityInfo'); + +type LoadedModules = { + AccessibilityInfo: AccessibilityInfoType, + RCTDeviceEventEmitter: RCTDeviceEventEmitterType, +}; + +// `AccessibilityInfo` picks its native event names when it is first loaded, +// so each platform needs a fresh module registry. +function loadForPlatform(os: 'android' | 'ios'): LoadedModules { + let modules: ?LoadedModules = null; + jest.isolateModules(() => { + const Platform = require('../../../Utilities/Platform').default; + /* $FlowFixMe[incompatible-type] */ + Platform.OS = os; + modules = { + AccessibilityInfo: require('../AccessibilityInfo').default, + RCTDeviceEventEmitter: + require('../../../EventEmitter/RCTDeviceEventEmitter').default, + }; + }); + invariant(modules != null, 'Expected modules to load'); + return modules; +} + +describe('AccessibilityInfo contrastLevelChanged', () => { + it('delivers contrastLevelDidChange as contrastLevelChanged on Android', () => { + const {AccessibilityInfo, RCTDeviceEventEmitter} = + loadForPlatform('android'); + const handler = jest.fn(); + + const subscription = AccessibilityInfo.addEventListener( + 'contrastLevelChanged', + handler, + ); + RCTDeviceEventEmitter.emit('contrastLevelDidChange', 0.5); + RCTDeviceEventEmitter.emit('contrastLevelDidChange', -0.25); + subscription.remove(); + RCTDeviceEventEmitter.emit('contrastLevelDidChange', 1); + + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenNthCalledWith(1, 0.5); + expect(handler).toHaveBeenNthCalledWith(2, -0.25); + }); + + it('does not subscribe on iOS', () => { + const {AccessibilityInfo, RCTDeviceEventEmitter} = loadForPlatform('ios'); + const handler = jest.fn(); + + const subscription = AccessibilityInfo.addEventListener( + 'contrastLevelChanged', + handler, + ); + RCTDeviceEventEmitter.emit('contrastLevelDidChange', 0.5); + subscription.remove(); + + expect(handler).not.toHaveBeenCalled(); + }); +}); 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..a74e1a5ce586 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js @@ -105,6 +105,7 @@ let mockNativeAccessibilityManagerIOS: { const ANDROID_RECOMMENDED_TIMEOUT = 6000; const mockIsHighTextContrastEnabled = jest.fn(onSuccess => onSuccess(true)); +const mockGetContrastLevel = jest.fn(onSuccess => onSuccess(0.5)); const mockIsGrayscaleEnabled = jest.fn(onSuccess => onSuccess(true)); const mockIsInvertColorsEnabled = jest.fn(onSuccess => onSuccess(true)); const mockIsReduceMotionEnabled = jest.fn(onSuccess => onSuccess(true)); @@ -114,6 +115,10 @@ const mockGetRecommendedTimeoutMillis = jest.fn((originalTimeout, onSuccess) => onSuccess(ANDROID_RECOMMENDED_TIMEOUT), ); let mockNativeAccessibilityInfoAndroid: { + getContrastLevel: JestMockFn< + [onSuccess: (contrastLevel: number) => void], + void, + > | null, isHighTextContrastEnabled: JestMockFn< [onSuccess: (isHighTextContrastEnabled: boolean) => void], void, @@ -143,6 +148,7 @@ let mockNativeAccessibilityInfoAndroid: { void, > | null, } = { + getContrastLevel: mockGetContrastLevel, isHighTextContrastEnabled: mockIsHighTextContrastEnabled, isGrayscaleEnabled: mockIsGrayscaleEnabled, isInvertColorsEnabled: mockIsInvertColorsEnabled, @@ -706,6 +712,49 @@ describe('AccessibilityInfo', () => { }); }); + describe('getContrastLevel', () => { + describe('Android', () => { + it('should call getContrastLevel if available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'android'; + + const contrastLevel = await AccessibilityInfo.getContrastLevel(); + + expect(mockGetContrastLevel).toHaveBeenCalled(); + expect(contrastLevel).toBe(0.5); + }); + + it('should throw error if getContrastLevel is not available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'android'; + + mockNativeAccessibilityInfoAndroid.getContrastLevel = null; + + const result: unknown = + await AccessibilityInfo.getContrastLevel().catch(e => e); + + invariant( + result instanceof Error, + 'Expected getContrastLevel to reject', + ); + expect(result.message).toEqual( + 'NativeAccessibilityInfoAndroid.getContrastLevel is not available', + ); + }); + }); + + describe('iOS', () => { + it('should return 0', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'ios'; + + const contrastLevel = await AccessibilityInfo.getContrastLevel(); + + expect(contrastLevel).toBe(0); + }); + }); + }); + describe('isHighTextContrastEnabled', () => { describe('Android', () => { it('should call isHighTextContrastEnabled if available', async () => { @@ -805,6 +854,7 @@ describe('AccessibilityInfo', () => { mockNativeAccessibilityManagerIOS; mockNativeAccessibilityInfoAndroid = { + getContrastLevel: mockGetContrastLevel, isHighTextContrastEnabled: mockIsHighTextContrastEnabled, isGrayscaleEnabled: mockIsGrayscaleEnabled, isInvertColorsEnabled: mockIsInvertColorsEnabled, 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..eb6ec8283836 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.app.UiModeManager import android.content.ContentResolver import android.content.Context import android.database.ContentObserver @@ -72,6 +73,11 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : } } } + private val uiModeManager: UiModeManager? = + context.applicationContext.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager? + // Listener that is notified when the contrast level changes (API 34+). Created lazily so that + // older versions never load the ContrastChangeListener interface. + private var contrastChangeListener: Any? = null private val accessibilityManager: AccessibilityManager? private val touchExplorationStateChangeListener: ReactTouchExplorationStateChangeListener = ReactTouchExplorationStateChangeListener() @@ -80,6 +86,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : private val contentResolver: ContentResolver private var reduceMotionEnabled = false private var highTextContrastEnabled = false + private var contrastLevel = 0f private var touchExplorationEnabled = false private var accessibilityServiceEnabled = false private var recommendedTimeout = 0 @@ -95,6 +102,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : accessibilityServiceEnabled = accessibilityManager?.isEnabled ?: false reduceMotionEnabled = isReduceMotionEnabledValue highTextContrastEnabled = isHighTextContrastEnabledValue + contrastLevel = contrastLevelValue grayscaleModeEnabled = isGrayscaleEnabledValue } @@ -154,6 +162,14 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : ) != 0 } + private val contrastLevelValue: Float + get() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + uiModeManager?.contrast ?: 0f + } else { + 0f + } + override fun isReduceMotionEnabled(successCallback: Callback) { successCallback.invoke(reduceMotionEnabled) } @@ -170,6 +186,10 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : successCallback.invoke(highTextContrastEnabled) } + override fun getContrastLevel(successCallback: Callback) { + successCallback.invoke(contrastLevel.toDouble()) + } + override fun isTouchExplorationEnabled(successCallback: Callback) { successCallback.invoke(touchExplorationEnabled) } @@ -208,6 +228,42 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : } } + private fun updateAndSendContrastLevelChangeEvent() { + val newContrastLevel = contrastLevelValue + if (contrastLevel != newContrastLevel) { + contrastLevel = newContrastLevel + getReactApplicationContextIfActiveOrWarn() + ?.emitDeviceEvent(CONTRAST_LEVEL_EVENT_NAME, contrastLevel.toDouble()) + } + } + + private fun registerContrastChangeListener() { + if ( + Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || + contrastChangeListener != null + ) { + return + } + val manager = uiModeManager ?: return + val listener = + UiModeManager.ContrastChangeListener { + if (reactApplicationContext.hasActiveReactInstance()) { + updateAndSendContrastLevelChangeEvent() + } + } + manager.addContrastChangeListener(reactApplicationContext.mainExecutor, listener) + contrastChangeListener = listener + } + + private fun unregisterContrastChangeListener() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return + } + val listener = contrastChangeListener as? UiModeManager.ContrastChangeListener ?: return + uiModeManager?.removeContrastChangeListener(listener) + contrastChangeListener = null + } + private fun updateAndSendTouchExplorationChangeEvent(enabled: Boolean) { if (touchExplorationEnabled != enabled) { touchExplorationEnabled = enabled @@ -243,12 +299,15 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : val highTextContrastUri = Settings.Secure.getUriFor(ACCESSIBILITY_HIGH_TEXT_CONTRAST_ENABLED_CONSTANT) contentResolver.registerContentObserver(highTextContrastUri, false, highTextContrastObserver) + // Registering does not report the current value, so read it again below. + registerContrastChangeListener() updateAndSendTouchExplorationChangeEvent( accessibilityManager?.isTouchExplorationEnabled == true, ) updateAndSendAccessibilityServiceChangeEvent(accessibilityManager?.isEnabled == true) updateAndSendReduceMotionChangeEvent() updateAndSendHighTextContrastChangeEvent() + updateAndSendContrastLevelChangeEvent() updateAndSendInvertColorsChangeEvent() updateAndSendGrayscaleModeChangeEvent() } @@ -260,6 +319,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : accessibilityManager?.removeAccessibilityStateChangeListener(accessibilityServiceChangeListener) contentResolver.unregisterContentObserver(animationScaleObserver) contentResolver.unregisterContentObserver(highTextContrastObserver) + unregisterContrastChangeListener() } override fun initialize() { @@ -274,6 +334,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : override fun invalidate() { reactApplicationContext.removeLifecycleEventListener(this) + unregisterContrastChangeListener() super.invalidate() } @@ -318,5 +379,6 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : "high_text_contrast_enabled" // constant is marked with @hide private const val INVERT_COLOR_EVENT_NAME = "invertColorDidChange" private const val GRAYSCALE_MODE_EVENT_NAME = "grayscaleModeDidChange" + private const val CONTRAST_LEVEL_EVENT_NAME = "contrastLevelDidChange" } } diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 74c407136ff1..40f845fb3122 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<> + * @generated SignedSource<<335f95a2532beddafb06133c6cbd1856>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -91,6 +91,7 @@ declare const AccessibilityInfo_default: { queue?: boolean }, ): void + getContrastLevel(): Promise getRecommendedTimeoutMillis(originalTimeout: number): Promise isAccessibilityServiceEnabled(): Promise isBoldTextEnabled(): Promise @@ -962,6 +963,7 @@ declare type AccessibilityEventDefinitions = } declare type AccessibilityEventDefinitionsAndroid = { accessibilityServiceChanged: [boolean] + contrastLevelChanged: [number] highTextContrastChanged: [boolean] } declare type AccessibilityEventDefinitionsIOS = { @@ -5770,7 +5772,7 @@ declare type WrapperComponentProvider = ( export { AccessibilityActionEvent, // a276ec6e AccessibilityActionInfo, // c94545a7 - AccessibilityInfo, // d92c5eae + AccessibilityInfo, // ccdd8b3c AccessibilityProps, // 95dbb1d8 AccessibilityRole, // f2f2e066 AccessibilityState, // b0c2b3f7 diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx index 54ea8b7694ba..f0a5ad5280b3 100644 --- a/packages/react-native/__typetests__/index.tsx +++ b/packages/react-native/__typetests__/index.tsx @@ -1464,6 +1464,12 @@ class AccessibilityTest extends React.Component { } } +AccessibilityInfo.getContrastLevel().then((contrastLevel: number) => + console.log(`AccessibilityInfo.getContrastLevel => ${contrastLevel}`), +); +AccessibilityInfo.addEventListener('contrastLevelChanged', (level: number) => + console.log(`AccessibilityInfo.contrastLevelChanged => ${level}`), +); AccessibilityInfo.isBoldTextEnabled().then(isEnabled => console.log(`AccessibilityInfo.isBoldTextEnabled => ${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..5fe0dad360e4 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityInfo.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityInfo.js @@ -22,6 +22,9 @@ export interface Spec extends TurboModule { readonly isHighTextContrastEnabled?: ( onSuccess: (isHighTextContrastEnabled: boolean) => void, ) => void; + readonly getContrastLevel?: ( + onSuccess: (contrastLevel: number) => void, + ) => void; readonly isTouchExplorationEnabled: ( onSuccess: (isScreenReaderEnabled: boolean) => void, ) => void; 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 80e5fe6a3295..9cc7e298c4a0 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 @@ -86,6 +86,14 @@ export interface AccessibilityInfoStatic { */ isHighTextContrastEnabled: () => Promise; + /** + * Query the contrast level chosen in the system settings, from -1 to 1. + * Requires Android 14 (API 34) or later; resolves to 0 otherwise. + * + * @platform android + */ + getContrastLevel: () => Promise; + /** * Query whether darker system colors is currently enabled. * @@ -142,6 +150,15 @@ export interface AccessibilityInfoStatic { eventName: AccessibilityAnnouncementEventName, handler: AccessibilityAnnouncementFinishedEventHandler, ): EmitterSubscription; + /** + * Fires when the contrast level changes. The argument is a number from -1 to 1. + * + * @platform android + */ + addEventListener( + eventName: 'contrastLevelChanged', + handler: (contrastLevel: number) => void, + ): EmitterSubscription; /** * Set accessibility focus to a react component. diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index 2ac5879c9458..4eb80cf24fe9 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js @@ -1577,6 +1577,7 @@ class DisplayOptionsStatusExample extends React.Component<{}> { optionChecker={AccessibilityInfo.isScreenReaderEnabled} notification={'screenReaderChanged'} /> + {isAndroid ? : null} {isAndroid ? null : ( <> { } } +function ContrastLevelExample(): React.Node { + const [contrastLevel, setContrastLevel] = useState(null); + const [lastEvent, setLastEvent] = useState(null); + useEffect(() => { + const listener = AccessibilityInfo.addEventListener( + 'contrastLevelChanged', + level => { + setLastEvent(level); + setContrastLevel(level); + }, + ); + // $FlowFixMe[unused-promise] + AccessibilityInfo.getContrastLevel().then(setContrastLevel); + return () => listener.remove(); + }, []); + return ( + + + {'Contrast level is '} + {contrastLevel ?? '…'}. + + + {'Last contrastLevelChanged event: '} + {lastEvent ?? 'none'}. + + + ); +} + function DisplayOptionStatusExample({ optionName, optionChecker,