From 17016d337c6191fc8cca2f76b9290a11004ee79e Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:40:17 +0100 Subject: [PATCH] Add AccessibilityInfo.getContrastLevel and contrastLevelChanged on Android Android 14 (API 34) exposes the system contrast level, from -1 to 1, through UiModeManager.getContrast(). It is the Android equivalent of Increase Contrast on iOS; isHighTextContrastEnabled reads a different setting that only changes how text is drawn. Register a ContrastChangeListener while the app is resumed and read the value again on resume. Earlier versions and iOS resolve to 0. Adds Jest tests for the getter and the numeric event, and an RNTester example that shows the value and the last event. Co-Authored-By: Claude Opus 5.5 --- .../jest/mocks/AccessibilityInfo.js | 4 + .../AccessibilityInfo/AccessibilityInfo.js | 34 +++++++++ ...ssibilityInfo-contrastLevelChanged-test.js | 74 +++++++++++++++++++ .../__tests__/AccessibilityInfo-test.js | 50 +++++++++++++ .../AccessibilityInfoModule.kt | 62 ++++++++++++++++ packages/react-native/ReactNativeApi.d.ts | 6 +- packages/react-native/__typetests__/index.tsx | 6 ++ .../modules/NativeAccessibilityInfo.js | 3 + .../AccessibilityInfo/AccessibilityInfo.d.ts | 17 +++++ .../Accessibility/AccessibilityExample.js | 30 ++++++++ 10 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-contrastLevelChanged-test.js 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,