From bd397ed29e2870090eb4d1620abc4b9259409ed3 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 01/11] 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 d49f63d51ebf..bfd673dba1d1 100644 --- a/packages/jest-preset/jest/mocks/AccessibilityInfo.js +++ b/packages/jest-preset/jest/mocks/AccessibilityInfo.js @@ -24,6 +24,10 @@ const AccessibilityInfo = { $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 017c1d3c842e..a15a74ecf873 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], }; @@ -53,6 +54,7 @@ const EventNames: Map = ['change', 'touchExplorationDidChange'], ['reduceMotionChanged', 'reduceMotionDidChange'], ['highTextContrastChanged', 'highTextContrastDidChange'], + ['contrastLevelChanged', 'contrastLevelDidChange'], ['screenReaderChanged', 'touchExplorationDidChange'], ['accessibilityServiceChanged', 'accessibilityServiceDidChange'], ['invertColorsChanged', 'invertColorDidChange'], @@ -225,6 +227,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. * @@ -445,6 +476,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 380be9fdd394..6c5a88f83480 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js @@ -116,6 +116,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)); @@ -125,6 +126,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, @@ -154,6 +159,7 @@ let mockNativeAccessibilityInfoAndroid: { void, > | null, } = { + getContrastLevel: mockGetContrastLevel, isHighTextContrastEnabled: mockIsHighTextContrastEnabled, isGrayscaleEnabled: mockIsGrayscaleEnabled, isInvertColorsEnabled: mockIsInvertColorsEnabled, @@ -760,6 +766,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 () => { @@ -860,6 +909,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 c0f8309f0ac5..f60c924cb9db 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<<7458478430f85ad63a8cd40905e96833>> * * 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 @@ -963,6 +964,7 @@ declare type AccessibilityEventDefinitions = } declare type AccessibilityEventDefinitionsAndroid = { accessibilityServiceChanged: [boolean] + contrastLevelChanged: [number] highTextContrastChanged: [boolean] } declare type AccessibilityEventDefinitionsIOS = { @@ -5775,7 +5777,7 @@ declare type WrapperComponentProvider = ( export { AccessibilityActionEvent, // a276ec6e AccessibilityActionInfo, // c94545a7 - AccessibilityInfo, // 7d8718ed + AccessibilityInfo, // 63cfc48e AccessibilityProps, // 95dbb1d8 AccessibilityRole, // f2f2e066 AccessibilityState, // b0c2b3f7 diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx index 75e5dce1634c..cbd7174ad139 100644 --- a/packages/react-native/__typetests__/index.tsx +++ b/packages/react-native/__typetests__/index.tsx @@ -1470,6 +1470,12 @@ AccessibilityInfo.isShowBordersEnabled().then(isEnabled => AccessibilityInfo.addEventListener('showBordersChanged', isEnabled => console.log(`AccessibilityInfo.showBordersChanged => ${isEnabled}`), ); +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 6f2ccb478328..ee644159df37 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 @@ -95,6 +95,14 @@ export interface AccessibilityInfoStatic { */ isShowBordersEnabled: () => 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. * @@ -151,6 +159,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 ff2891104139..a342f98b3634 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, From 4c86af4ad33f92a50a979efae8cc7ddc82e56ef1 Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:55:05 +0100 Subject: [PATCH 02/11] Update C++ API snapshots for getContrastLevel The Android NativeAccessibilityInfo spec's getContrastLevel now appears in the generated spec, so it shows up in the Apple snapshots. Generated with Doxygen 1.16.1 and Python 3.12, as in validate-cxx-api-snapshots.yml. Co-Authored-By: Claude Opus 5.5 --- scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api | 1 + scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api | 1 + scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api | 1 + scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api | 1 + 4 files changed, 4 insertions(+) diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 8de2c49759a6..c7572c12acf6 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -2130,6 +2130,7 @@ interface _RCTTypedModuleConstants : public NSDictionary : public NSDictionary : public NSDictionary : public NSDictionary Date: Sat, 24 Oct 2026 01:06:50 +0100 Subject: [PATCH 03/11] Test the Android contrast listener and events with Robolectric Robolectric doesn't call contrast listeners, so on API 34 the test serves a mock UiModeManager and calls the listener the module registers. It checks that a change sends contrastLevelDidChange once (including a negative level), resuming twice registers one listener, pausing and invalidating remove it, and a change made while paused is sent on resume. On API 33 it uses Robolectric's UiModeManager and checks the level stays 0 with no event. Removing the listener registration makes five of the seven tests fail. Co-Authored-By: Claude Opus 5.5 --- .../AccessibilityInfoModuleTest.kt | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt new file mode 100644 index 000000000000..cb1a32744b99 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt @@ -0,0 +1,164 @@ +/* + * 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. + */ + +package com.facebook.react.modules.accessibilityinfo + +import android.app.UiModeManager +import android.content.Context +import android.content.ContextWrapper +import android.os.Build +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.bridge.Callback +import com.facebook.react.bridge.ReactTestHelper +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.testutils.shadows.ShadowNativeLoader +import com.facebook.testutils.shadows.ShadowSoLoader +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.spy +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], shadows = [ShadowSoLoader::class, ShadowNativeLoader::class]) +class AccessibilityInfoModuleTest { + + private lateinit var uiModeManager: UiModeManager + private lateinit var reactContext: BridgeReactContext + private lateinit var module: AccessibilityInfoModule + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + val application = RuntimeEnvironment.getApplication() + val context = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + uiModeManager = mock() + whenever(uiModeManager.contrast).thenReturn(0f) + UiModeContext(application, uiModeManager) + } else { + // Mockito can't mock the API 34 UiModeManager on older versions, so use Robolectric's. + application + } + reactContext = spy(BridgeReactContext(context)) + reactContext.initializeWithInstance(ReactTestHelper.createMockCatalystInstance()) + module = AccessibilityInfoModule(reactContext) + } + + @Test + fun aContrastChangeSendsContrastLevelDidChangeOnce() { + module.onHostResume() + val listener = registeredListener() + + whenever(uiModeManager.contrast).thenReturn(0.5f) + listener.onContrastChanged(0.5f) + listener.onContrastChanged(0.5f) + + verify(reactContext, times(1)).emitDeviceEvent(CONTRAST_LEVEL_EVENT, 0.5) + assertThat(currentContrastLevel()).isEqualTo(0.5) + } + + @Test + fun aNegativeContrastIsSent() { + module.onHostResume() + + whenever(uiModeManager.contrast).thenReturn(-1f) + registeredListener().onContrastChanged(-1f) + + verify(reactContext, times(1)).emitDeviceEvent(CONTRAST_LEVEL_EVENT, -1.0) + } + + @Test + fun resumingTwiceRegistersOneListener() { + module.onHostResume() + module.onHostResume() + + verify(uiModeManager, times(1)).addContrastChangeListener(any(), any()) + } + + @Test + fun pausingRemovesTheListener() { + module.onHostResume() + val listener = registeredListener() + + module.onHostPause() + + verify(uiModeManager).removeContrastChangeListener(listener) + } + + @Test + fun invalidatingRemovesTheListener() { + module.onHostResume() + val listener = registeredListener() + + module.invalidate() + + verify(uiModeManager).removeContrastChangeListener(listener) + } + + @Test + fun aChangeWhilePausedIsSentOnResume() { + module.onHostResume() + module.onHostPause() + + whenever(uiModeManager.contrast).thenReturn(1f) + module.onHostResume() + + verify(reactContext, times(1)).emitDeviceEvent(CONTRAST_LEVEL_EVENT, 1.0) + } + + @Test + @Config(sdk = [33]) + fun theContrastLevelIsAlwaysZeroBeforeAndroid14() { + module.onHostResume() + module.onHostPause() + module.invalidate() + + assertThat(currentContrastLevel()).isEqualTo(0.0) + verify(reactContext, never()) + .emitDeviceEvent( + ArgumentMatchers.eq(CONTRAST_LEVEL_EVENT), + ArgumentMatchers.any(), + ) + } + + private fun registeredListener(): UiModeManager.ContrastChangeListener { + val captor = argumentCaptor() + verify(uiModeManager).addContrastChangeListener(any(), captor.capture()) + return captor.firstValue + } + + private fun currentContrastLevel(): Double { + var result: Double? = null + module.getContrastLevel(Callback { args -> result = args[0] as Double }) + return checkNotNull(result) + } + + /** Serves a mock [UiModeManager], since Robolectric doesn't call contrast listeners. */ + private class UiModeContext(base: Context, private val uiModeManager: UiModeManager) : + ContextWrapper(base) { + override fun getApplicationContext(): Context = this + + override fun getSystemService(name: String): Any? = + if (name == Context.UI_MODE_SERVICE) uiModeManager else super.getSystemService(name) + } + + private companion object { + const val CONTRAST_LEVEL_EVENT = "contrastLevelDidChange" + } +} From 600567fd8fe7a79447fd4cfee2536e38392f73db Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:40:56 +0100 Subject: [PATCH 04/11] Use the contrast listener's value and tear it down on the UI thread The Android contrast listener now lives in a per-module ContrastListenerApi34 helper, and the value Android passes to it is used instead of being read again. Every path (start-up, resume, listener) goes through one normaliser that maps NaN to 0 and clamps to -1..1. invalidate() removes the lifecycle listener first, sets an invalidated flag that resume and queued contrast callbacks check, and unregisters the listener on the UI thread, posting when called from another thread. Co-Authored-By: Claude Opus 5.5 --- .../AccessibilityInfoModule.kt | 142 ++++++++--- .../AccessibilityInfoModuleTest.kt | 234 +++++++++++++++--- 2 files changed, 305 insertions(+), 71 deletions(-) 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 eb6ec8283836..3e72d6ad44d6 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 @@ -16,12 +16,17 @@ import android.os.Build import android.provider.Settings import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityManager +import androidx.annotation.RequiresApi +import com.facebook.common.logging.FLog import com.facebook.fbreact.specs.NativeAccessibilityInfoSpec 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.common.ReactConstants import com.facebook.react.module.annotations.ReactModule +import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicBoolean /** * Module that monitors and provides information about the state of Touch Exploration service on the @@ -73,11 +78,9 @@ 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 + // Notified when the contrast level changes. Only created on Android 14 (API 34) or later, when + // there is a UiModeManager, so that older versions never load ContrastChangeListener. + private val contrastListener: ContrastListenerApi34? private val accessibilityManager: AccessibilityManager? private val touchExplorationStateChangeListener: ReactTouchExplorationStateChangeListener = ReactTouchExplorationStateChangeListener() @@ -86,7 +89,12 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : private val contentResolver: ContentResolver private var reduceMotionEnabled = false private var highTextContrastEnabled = false - private var contrastLevel = 0f + // Written on the main thread and read on the native modules thread. + @Volatile private var contrastLevel = 0f + private val warnedAboutContrast = AtomicBoolean(false) + // Set by invalidate(), so that a resume or a contrast change already queued on the main thread + // can't register listeners or send events after teardown. + @Volatile private var invalidated = false private var touchExplorationEnabled = false private var accessibilityServiceEnabled = false private var recommendedTimeout = 0 @@ -98,6 +106,19 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : accessibilityManager = appContext.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager? contentResolver = reactApplicationContext.contentResolver + val uiModeManager = appContext.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager? + contrastListener = + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && uiModeManager != null + ) { + ContrastListenerApi34(uiModeManager) { rawLevel -> + if (!invalidated && reactApplicationContext.hasActiveReactInstance()) { + updateAndSendContrastLevelChangeEvent(normalizedContrastLevel(rawLevel)) + } + } + } else { + null + } touchExplorationEnabled = accessibilityManager?.isTouchExplorationEnabled ?: false accessibilityServiceEnabled = accessibilityManager?.isEnabled ?: false reduceMotionEnabled = isReduceMotionEnabledValue @@ -163,12 +184,25 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : } private val contrastLevelValue: Float - get() = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - uiModeManager?.contrast ?: 0f - } else { - 0f - } + get() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return 0f + } + val listener = contrastListener ?: return 0f + return normalizedContrastLevel(listener.currentLevel()) + } + + private fun normalizedContrastLevel(rawLevel: Float): Float { + val level = normalizeContrast(rawLevel) + // `level != rawLevel` is also true for NaN. + if (level != rawLevel && !warnedAboutContrast.getAndSet(true)) { + FLog.w( + ReactConstants.TAG, + "UiModeManager reported the contrast level $rawLevel, using $level instead", + ) + } + return level + } override fun isReduceMotionEnabled(successCallback: Callback) { successCallback.invoke(reduceMotionEnabled) @@ -228,40 +262,27 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : } } - private fun updateAndSendContrastLevelChangeEvent() { - val newContrastLevel = contrastLevelValue - if (contrastLevel != newContrastLevel) { - contrastLevel = newContrastLevel + /** [level] must already be normalized. */ + private fun updateAndSendContrastLevelChangeEvent(level: Float) { + if (contrastLevel != level) { + contrastLevel = level getReactApplicationContextIfActiveOrWarn() - ?.emitDeviceEvent(CONTRAST_LEVEL_EVENT_NAME, contrastLevel.toDouble()) + ?.emitDeviceEvent(CONTRAST_LEVEL_EVENT_NAME, level.toDouble()) } } private fun registerContrastChangeListener() { - if ( - Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || - contrastChangeListener != null - ) { + if (invalidated || Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { return } - val manager = uiModeManager ?: return - val listener = - UiModeManager.ContrastChangeListener { - if (reactApplicationContext.hasActiveReactInstance()) { - updateAndSendContrastLevelChangeEvent() - } - } - manager.addContrastChangeListener(reactApplicationContext.mainExecutor, listener) - contrastChangeListener = listener + contrastListener?.register(reactApplicationContext.mainExecutor) } 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 + contrastListener?.unregister() } private fun updateAndSendTouchExplorationChangeEvent(enabled: Boolean) { @@ -290,6 +311,9 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : } override fun onHostResume() { + if (invalidated) { + return + } accessibilityManager?.addTouchExplorationStateChangeListener( touchExplorationStateChangeListener, ) @@ -299,7 +323,8 @@ 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. + // The contrast listener is removed while paused, and registering doesn't report the current + // value, so the level is read again below. registerContrastChangeListener() updateAndSendTouchExplorationChangeEvent( accessibilityManager?.isTouchExplorationEnabled == true, @@ -307,7 +332,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : updateAndSendAccessibilityServiceChangeEvent(accessibilityManager?.isEnabled == true) updateAndSendReduceMotionChangeEvent() updateAndSendHighTextContrastChangeEvent() - updateAndSendContrastLevelChangeEvent() + updateAndSendContrastLevelChangeEvent(contrastLevelValue) updateAndSendInvertColorsChangeEvent() updateAndSendGrayscaleModeChangeEvent() } @@ -333,8 +358,19 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : } override fun invalidate() { + // Removing the lifecycle listener first drops an onHostResume that ReactContext has queued. reactApplicationContext.removeLifecycleEventListener(this) - unregisterContrastChangeListener() + invalidated = true + // The contrast listener is added and removed on the UI thread, but invalidate() can run on + // another thread. + if (UiThreadUtil.isOnUiThread()) { + unregisterContrastChangeListener() + } else if (!UiThreadUtil.runOnUiThread { unregisterContrastChangeListener() }) { + FLog.w( + ReactConstants.TAG, + "Couldn't post the removal of the contrast listener, so it may leak", + ) + } super.invalidate() } @@ -369,6 +405,36 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : successCallback.invoke(recommendedTimeout) } + /** + * Owns the API 34 contrast listener, so that the module never refers to + * [UiModeManager.ContrastChangeListener] directly. Each module has its own instance. [register] + * and [unregister] must be called on the UI thread. + */ + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + private class ContrastListenerApi34( + private val manager: UiModeManager, + onChange: (Float) -> Unit, + ) { + private val listener = UiModeManager.ContrastChangeListener { level -> onChange(level) } + private var registered = false + + fun register(executor: Executor) { + if (!registered) { + manager.addContrastChangeListener(executor, listener) + registered = true + } + } + + fun unregister() { + if (registered) { + manager.removeContrastChangeListener(listener) + registered = false + } + } + + fun currentLevel(): Float = manager.contrast + } + companion object { const val NAME: String = NativeAccessibilityInfoSpec.NAME private const val REDUCE_MOTION_EVENT_NAME = "reduceMotionDidChange" @@ -380,5 +446,9 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : private const val INVERT_COLOR_EVENT_NAME = "invertColorDidChange" private const val GRAYSCALE_MODE_EVENT_NAME = "grayscaleModeDidChange" private const val CONTRAST_LEVEL_EVENT_NAME = "contrastLevelDidChange" + + /** Maps NaN to the standard contrast, 0, and clamps everything else to the range -1 to 1. */ + private fun normalizeContrast(raw: Float): Float = + if (raw.isNaN()) 0f else raw.coerceIn(-1f, 1f) } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt index cb1a32744b99..17979f1d950d 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt @@ -11,12 +11,14 @@ import android.app.UiModeManager import android.content.Context import android.content.ContextWrapper import android.os.Build +import android.os.Looper import com.facebook.react.bridge.BridgeReactContext import com.facebook.react.bridge.Callback import com.facebook.react.bridge.ReactTestHelper import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests import com.facebook.testutils.shadows.ShadowNativeLoader import com.facebook.testutils.shadows.ShadowSoLoader +import java.util.concurrent.Executor import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test @@ -24,6 +26,7 @@ import org.junit.runner.RunWith import org.mockito.ArgumentMatchers import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.spy @@ -32,9 +35,12 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode @RunWith(RobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.PAUSED) @Config(sdk = [34], shadows = [ShadowSoLoader::class, ShadowNativeLoader::class]) class AccessibilityInfoModuleTest { @@ -45,27 +51,18 @@ class AccessibilityInfoModuleTest { @Before fun setUp() { ReactNativeFeatureFlagsForTests.setUp() - val application = RuntimeEnvironment.getApplication() - val context = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - uiModeManager = mock() - whenever(uiModeManager.contrast).thenReturn(0f) - UiModeContext(application, uiModeManager) - } else { - // Mockito can't mock the API 34 UiModeManager on older versions, so use Robolectric's. - application - } - reactContext = spy(BridgeReactContext(context)) - reactContext.initializeWithInstance(ReactTestHelper.createMockCatalystInstance()) - module = AccessibilityInfoModule(reactContext) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + uiModeManager = mock() + whenever(uiModeManager.contrast).thenReturn(0f) + } + startModule() } @Test fun aContrastChangeSendsContrastLevelDidChangeOnce() { - module.onHostResume() + resume() val listener = registeredListener() - whenever(uiModeManager.contrast).thenReturn(0.5f) listener.onContrastChanged(0.5f) listener.onContrastChanged(0.5f) @@ -73,37 +70,102 @@ class AccessibilityInfoModuleTest { assertThat(currentContrastLevel()).isEqualTo(0.5) } + @Test + fun theListenersValueIsUsedRatherThanReadingAgain() { + resume() + + // The manager still reports 0, so only the value passed to the listener can produce 0.5. + registeredListener().onContrastChanged(0.5f) + + verify(reactContext, times(1)).emitDeviceEvent(CONTRAST_LEVEL_EVENT, 0.5) + assertThat(currentContrastLevel()).isEqualTo(0.5) + } + @Test fun aNegativeContrastIsSent() { - module.onHostResume() + resume() - whenever(uiModeManager.contrast).thenReturn(-1f) registeredListener().onContrastChanged(-1f) verify(reactContext, times(1)).emitDeviceEvent(CONTRAST_LEVEL_EVENT, -1.0) } + @Test + fun theStartingValueIsClamped() { + whenever(uiModeManager.contrast).thenReturn(2f) + startModule() + + assertThat(currentContrastLevel()).isEqualTo(1.0) + } + + @Test + fun theValueReadOnResumeIsClamped() { + resume() + pause() + + whenever(uiModeManager.contrast).thenReturn(3f) + resume() + + verify(reactContext, times(1)).emitDeviceEvent(CONTRAST_LEVEL_EVENT, 1.0) + } + + @Test + fun theListenersValueIsClamped() { + resume() + + registeredListener().onContrastChanged(-5f) + + verify(reactContext, times(1)).emitDeviceEvent(CONTRAST_LEVEL_EVENT, -1.0) + assertThat(currentContrastLevel()).isEqualTo(-1.0) + } + + @Test + fun twoValuesThatClampToTheSameLevelSendOneEvent() { + resume() + val listener = registeredListener() + + listener.onContrastChanged(2f) + listener.onContrastChanged(3f) + + verify(reactContext, times(1)).emitDeviceEvent(CONTRAST_LEVEL_EVENT, 1.0) + } + + @Test + fun notANumberIsTreatedAsTheStandardContrast() { + resume() + val listener = registeredListener() + + listener.onContrastChanged(Float.NaN) + listener.onContrastChanged(Float.NaN) + pause() + whenever(uiModeManager.contrast).thenReturn(Float.NaN) + resume() + + assertNoContrastEvent() + assertThat(currentContrastLevel()).isEqualTo(0.0) + } + @Test fun resumingTwiceRegistersOneListener() { - module.onHostResume() - module.onHostResume() + resume() + resume() verify(uiModeManager, times(1)).addContrastChangeListener(any(), any()) } @Test fun pausingRemovesTheListener() { - module.onHostResume() + resume() val listener = registeredListener() - module.onHostPause() + pause() verify(uiModeManager).removeContrastChangeListener(listener) } @Test - fun invalidatingRemovesTheListener() { - module.onHostResume() + fun invalidatingOnTheUiThreadRemovesTheListenerAtOnce() { + resume() val listener = registeredListener() module.invalidate() @@ -112,29 +174,123 @@ class AccessibilityInfoModuleTest { } @Test - fun aChangeWhilePausedIsSentOnResume() { + fun invalidatingOnABackgroundThreadRemovesTheListenerOnTheUiThread() { + resume() + val listener = registeredListener() + val unregisterThreads = mutableListOf() + doAnswer { unregisterThreads.add(Thread.currentThread()) } + .whenever(uiModeManager) + .removeContrastChangeListener(any()) + + val background = Thread { module.invalidate() } + background.start() + background.join() + + verify(uiModeManager, never()).removeContrastChangeListener(any()) + + shadowOf(Looper.getMainLooper()).idle() + + verify(uiModeManager, times(1)).removeContrastChangeListener(listener) + assertThat(unregisterThreads).containsExactly(Looper.getMainLooper().thread) + } + + @Test + fun aContrastChangeQueuedBeforeInvalidateSendsNothing() { + resume() + val executor = argumentCaptor() + val listener = argumentCaptor() + verify(uiModeManager).addContrastChangeListener(executor.capture(), listener.capture()) + + // Android delivers the change through the executor, so it waits in the main looper's queue. + executor.firstValue.execute { listener.firstValue.onContrastChanged(0.5f) } + module.invalidate() + shadowOf(Looper.getMainLooper()).idle() + + assertNoContrastEvent() + } + + @Test + fun aResumeQueuedBeforeInvalidateDoesNotRegister() { + // A listener added while the host is resumed gets its onHostResume posted to the UI queue. + startModule(hostResumed = true) + + module.invalidate() + shadowOf(Looper.getMainLooper()).idle() + + verify(uiModeManager, never()).addContrastChangeListener(any(), any()) + } + + @Test + fun resumingAfterInvalidateDoesNotRegister() { + module.invalidate() + module.onHostResume() - module.onHostPause() + + verify(uiModeManager, never()).addContrastChangeListener(any(), any()) + } + + @Test + fun aChangeWhilePausedIsSentOnResume() { + resume() + pause() whenever(uiModeManager.contrast).thenReturn(1f) - module.onHostResume() + resume() verify(reactContext, times(1)).emitDeviceEvent(CONTRAST_LEVEL_EVENT, 1.0) } + @Test + fun withoutAUiModeManagerTheContrastLevelStaysZero() { + startModule(UiModeContext(RuntimeEnvironment.getApplication(), null)) + + resume() + pause() + module.invalidate() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(currentContrastLevel()).isEqualTo(0.0) + assertNoContrastEvent() + } + @Test @Config(sdk = [33]) fun theContrastLevelIsAlwaysZeroBeforeAndroid14() { - module.onHostResume() - module.onHostPause() + resume() + pause() module.invalidate() assertThat(currentContrastLevel()).isEqualTo(0.0) - verify(reactContext, never()) - .emitDeviceEvent( - ArgumentMatchers.eq(CONTRAST_LEVEL_EVENT), - ArgumentMatchers.any(), - ) + assertNoContrastEvent() + } + + /** Builds a module on [context] and adds it as a lifecycle listener, as the host would. */ + private fun startModule(context: Context = defaultContext(), hostResumed: Boolean = false) { + reactContext = spy(BridgeReactContext(context)) + reactContext.initializeWithInstance(ReactTestHelper.createMockCatalystInstance()) + if (hostResumed) { + reactContext.onHostResume(null) + } + module = AccessibilityInfoModule(reactContext) + module.initialize() + } + + private fun defaultContext(): Context { + val application = RuntimeEnvironment.getApplication() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + UiModeContext(application, uiModeManager) + } else { + // Mockito can't mock the API 34 UiModeManager on older versions, so use Robolectric's. + application + } + } + + private fun resume() { + reactContext.onHostResume(null) + } + + private fun pause() { + reactContext.onHostPause() } private fun registeredListener(): UiModeManager.ContrastChangeListener { @@ -149,8 +305,16 @@ class AccessibilityInfoModuleTest { return checkNotNull(result) } - /** Serves a mock [UiModeManager], since Robolectric doesn't call contrast listeners. */ - private class UiModeContext(base: Context, private val uiModeManager: UiModeManager) : + private fun assertNoContrastEvent() { + verify(reactContext, never()) + .emitDeviceEvent( + ArgumentMatchers.eq(CONTRAST_LEVEL_EVENT), + ArgumentMatchers.any(), + ) + } + + /** Serves a mock [UiModeManager], or none, since Robolectric doesn't call contrast listeners. */ + private class UiModeContext(base: Context, private val uiModeManager: UiModeManager?) : ContextWrapper(base) { override fun getApplicationContext(): Context = this From 88d53b7854ad532932ef0185b27c554dcaa8155e Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:46:17 +0100 Subject: [PATCH 05/11] Document getContrastLevel on iOS, older Android and missing native code The docs now say that iOS always resolves 0 and points to isDarkerSystemColorsEnabled, that this is the closest Android counterpart to Increase Contrast, that 0 also means Android before 14 or no UiModeManager, and that a missing native method rejects. The d.ts event overload notes API 34. Co-Authored-By: Claude Opus 5.5 --- .../AccessibilityInfo/AccessibilityInfo.js | 26 +++++++++++++------ .../AccessibilityInfo/AccessibilityInfo.d.ts | 14 +++++++--- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js index a15a74ecf873..fcd5470861a6 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -230,11 +230,16 @@ 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. + * 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 closest Android counterpart to "Increase + * Contrast" on iOS. Requires Android 14 (API 34) or later. + * + * The promise also resolves to 0 on Android versions before 14, and when the device has no + * `UiModeManager`, so callers can't tell these cases apart from the standard contrast. It rejects + * if the native method is missing, for example when the native code is older than the JavaScript. + * + * On iOS this always resolves to 0; use `isDarkerSystemColorsEnabled` to check whether "Increase + * Contrast" is on. * * @platform android */ @@ -244,6 +249,9 @@ const AccessibilityInfo = { if (NativeAccessibilityInfoAndroid?.getContrastLevel != null) { NativeAccessibilityInfoAndroid.getContrastLevel(resolve); } else { + // This is a new API, so there's no old value to keep resolving: a missing native + // method rejects, as `isShowBordersEnabled` does, rather than looking like the + // standard contrast. reject( new Error( 'NativeAccessibilityInfoAndroid.getContrastLevel is not available', @@ -252,6 +260,8 @@ const AccessibilityInfo = { } }); } else { + // iOS has no contrast level, only the on/off "Increase Contrast" setting, which + // `isDarkerSystemColorsEnabled` reports. return Promise.resolve(0); } }, @@ -476,9 +486,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. + * - `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/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts b/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts index ee644159df37..5c86f09edb51 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 @@ -96,8 +96,15 @@ export interface AccessibilityInfoStatic { isShowBordersEnabled: () => 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. + * Query the contrast level chosen in the system settings, from -1 to 1, where 0 is the standard + * contrast and higher values mean more contrast. This is the closest Android counterpart to + * "Increase Contrast" on iOS. Requires Android 14 (API 34) or later. + * + * Also resolves to 0 on Android versions before 14, and when the device has no `UiModeManager`, + * so callers can't tell these cases apart from the standard contrast. Rejects if the native + * method is missing. + * + * On iOS this always resolves to 0; use `isDarkerSystemColorsEnabled`. * * @platform android */ @@ -160,7 +167,8 @@ export interface AccessibilityInfoStatic { handler: AccessibilityAnnouncementFinishedEventHandler, ): EmitterSubscription; /** - * Fires when the contrast level changes. The argument is a number from -1 to 1. + * Fires when the contrast level changes, on Android 14 (API 34) or later. The argument is a + * number from -1 to 1 (see `getContrastLevel`). * * @platform android */ From 55e89876b558abcded802a31e7c9066640c9037f Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:46:17 +0100 Subject: [PATCH 06/11] Test that getContrastLevel rejects without the native module Co-Authored-By: Claude Opus 5.5 --- .../__tests__/AccessibilityInfo-test.js | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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 6c5a88f83480..47b2bbf7f039 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js @@ -795,6 +795,27 @@ describe('AccessibilityInfo', () => { 'NativeAccessibilityInfoAndroid.getContrastLevel 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.getContrastLevel().catch(e => e); + + invariant( + result instanceof Error, + 'Expected getContrastLevel to reject', + ); + expect(result.message).toEqual( + 'NativeAccessibilityInfoAndroid.getContrastLevel is not available', + ); + }); }); describe('iOS', () => { From 381ff42e7b1bccf1104eadffcb90379b08ba0408 Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:46:17 +0100 Subject: [PATCH 07/11] Show loading and errors in the RNTester contrast level row The row shows 'loading' until the starting read finishes, and 'Unavailable: ' if it rejects. A starting read that finishes after an event no longer overwrites the newer value. Co-Authored-By: Claude Opus 5.5 --- .../Accessibility/AccessibilityExample.js | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index a342f98b3634..2c9de402d2a2 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js @@ -1613,25 +1613,42 @@ class DisplayOptionsStatusExample extends React.Component<{}> { } function ContrastLevelExample(): React.Node { - const [contrastLevel, setContrastLevel] = useState(null); + const [contrastLevel, setContrastLevel] = useState('loading'); const [lastEvent, setLastEvent] = useState(null); useEffect(() => { + // Set once an event arrives or the example unmounts, so that a slow starting read can't + // overwrite a newer value. + let ignoreStartingRead = false; const listener = AccessibilityInfo.addEventListener( 'contrastLevelChanged', level => { + ignoreStartingRead = true; setLastEvent(level); - setContrastLevel(level); + setContrastLevel(String(level)); }, ); // $FlowFixMe[unused-promise] - AccessibilityInfo.getContrastLevel().then(setContrastLevel); - return () => listener.remove(); + AccessibilityInfo.getContrastLevel() + .then(level => { + if (!ignoreStartingRead) { + setContrastLevel(String(level)); + } + }) + .catch((error: Error) => { + if (!ignoreStartingRead) { + setContrastLevel(`Unavailable: ${error.message}`); + } + }); + return function cleanup() { + ignoreStartingRead = true; + listener.remove(); + }; }, []); return ( {'Contrast level is '} - {contrastLevel ?? '…'}. + {contrastLevel}. {'Last contrastLevelChanged event: '} From 8f2688c7c1d5b4a8a4ca1646af0aa15ab49d5565 Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:37:03 +0100 Subject: [PATCH 08/11] Keep the last contrast level when Android reports NaN A NaN from the contrast listener or the read on resume no longer sends contrastLevelChanged(0); the module keeps its last level and sends nothing. NaN is still the standard contrast, 0, at construction, where there is no earlier value. NaN and out-of-range values each warn once on their own. invalidate() no longer posts the listener removal when there is no listener, and its warning names the module. Co-Authored-By: Claude Opus 5.5 --- .../AccessibilityInfoModule.kt | 57 ++++++++++++------- .../AccessibilityInfoModuleTest.kt | 37 +++++++++++- 2 files changed, 71 insertions(+), 23 deletions(-) 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 3e72d6ad44d6..2afadf941435 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 @@ -91,7 +91,8 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : private var highTextContrastEnabled = false // Written on the main thread and read on the native modules thread. @Volatile private var contrastLevel = 0f - private val warnedAboutContrast = AtomicBoolean(false) + private val warnedAboutNaNContrast = AtomicBoolean(false) + private val warnedAboutOutOfRangeContrast = AtomicBoolean(false) // Set by invalidate(), so that a resume or a contrast change already queued on the main thread // can't register listeners or send events after teardown. @Volatile private var invalidated = false @@ -113,7 +114,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : ) { ContrastListenerApi34(uiModeManager) { rawLevel -> if (!invalidated && reactApplicationContext.hasActiveReactInstance()) { - updateAndSendContrastLevelChangeEvent(normalizedContrastLevel(rawLevel)) + normalizedContrastLevel(rawLevel)?.let { updateAndSendContrastLevelChangeEvent(it) } } } } else { @@ -123,7 +124,8 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : accessibilityServiceEnabled = accessibilityManager?.isEnabled ?: false reduceMotionEnabled = isReduceMotionEnabledValue highTextContrastEnabled = isHighTextContrastEnabledValue - contrastLevel = contrastLevelValue + // There is no earlier value to keep, so NaN at the start is the standard contrast. + contrastLevel = contrastLevelValue ?: 0f grayscaleModeEnabled = isGrayscaleEnabledValue } @@ -183,7 +185,8 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : ) != 0 } - private val contrastLevelValue: Float + /** The current contrast level, clamped to the range -1 to 1, or null if Android reports NaN. */ + private val contrastLevelValue: Float? get() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { return 0f @@ -192,13 +195,24 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : return normalizedContrastLevel(listener.currentLevel()) } - private fun normalizedContrastLevel(rawLevel: Float): Float { - val level = normalizeContrast(rawLevel) - // `level != rawLevel` is also true for NaN. - if (level != rawLevel && !warnedAboutContrast.getAndSet(true)) { + /** + * Clamps [rawLevel] to the range -1 to 1, or returns null for NaN so the caller can ignore it. + */ + private fun normalizedContrastLevel(rawLevel: Float): Float? { + if (rawLevel.isNaN()) { + if (!warnedAboutNaNContrast.getAndSet(true)) { + FLog.w( + ReactConstants.TAG, + "AccessibilityInfoModule: UiModeManager reported a contrast level that is not a number, ignoring it", + ) + } + return null + } + val level = rawLevel.coerceIn(-1f, 1f) + if (level != rawLevel && !warnedAboutOutOfRangeContrast.getAndSet(true)) { FLog.w( ReactConstants.TAG, - "UiModeManager reported the contrast level $rawLevel, using $level instead", + "AccessibilityInfoModule: UiModeManager reported the contrast level $rawLevel, using $level instead", ) } return level @@ -332,7 +346,8 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : updateAndSendAccessibilityServiceChangeEvent(accessibilityManager?.isEnabled == true) updateAndSendReduceMotionChangeEvent() updateAndSendHighTextContrastChangeEvent() - updateAndSendContrastLevelChangeEvent(contrastLevelValue) + // A NaN keeps the last level, so nothing is sent. + contrastLevelValue?.let { updateAndSendContrastLevelChangeEvent(it) } updateAndSendInvertColorsChangeEvent() updateAndSendGrayscaleModeChangeEvent() } @@ -362,14 +377,16 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : reactApplicationContext.removeLifecycleEventListener(this) invalidated = true // The contrast listener is added and removed on the UI thread, but invalidate() can run on - // another thread. - if (UiThreadUtil.isOnUiThread()) { - unregisterContrastChangeListener() - } else if (!UiThreadUtil.runOnUiThread { unregisterContrastChangeListener() }) { - FLog.w( - ReactConstants.TAG, - "Couldn't post the removal of the contrast listener, so it may leak", - ) + // another thread. There is nothing to remove before Android 14 or without a UiModeManager. + if (contrastListener != null) { + if (UiThreadUtil.isOnUiThread()) { + unregisterContrastChangeListener() + } else if (!UiThreadUtil.runOnUiThread { unregisterContrastChangeListener() }) { + FLog.w( + ReactConstants.TAG, + "AccessibilityInfoModule: Couldn't post the removal of the contrast listener, so it may leak", + ) + } } super.invalidate() } @@ -446,9 +463,5 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : private const val INVERT_COLOR_EVENT_NAME = "invertColorDidChange" private const val GRAYSCALE_MODE_EVENT_NAME = "grayscaleModeDidChange" private const val CONTRAST_LEVEL_EVENT_NAME = "contrastLevelDidChange" - - /** Maps NaN to the standard contrast, 0, and clamps everything else to the range -1 to 1. */ - private fun normalizeContrast(raw: Float): Float = - if (raw.isNaN()) 0f else raw.coerceIn(-1f, 1f) } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt index 17979f1d950d..e0859d5e89ba 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt @@ -131,7 +131,7 @@ class AccessibilityInfoModuleTest { } @Test - fun notANumberIsTreatedAsTheStandardContrast() { + fun notANumberBeforeAnyChangeKeepsTheStandardContrast() { resume() val listener = registeredListener() @@ -145,6 +145,41 @@ class AccessibilityInfoModuleTest { assertThat(currentContrastLevel()).isEqualTo(0.0) } + @Test + fun notANumberFromTheListenerKeepsTheLastLevel() { + resume() + val listener = registeredListener() + + listener.onContrastChanged(0.5f) + listener.onContrastChanged(Float.NaN) + + verify(reactContext, times(1)).emitDeviceEvent(CONTRAST_LEVEL_EVENT, 0.5) + verify(reactContext, never()).emitDeviceEvent(CONTRAST_LEVEL_EVENT, 0.0) + assertThat(currentContrastLevel()).isEqualTo(0.5) + } + + @Test + fun notANumberOnResumeKeepsTheLastLevel() { + resume() + registeredListener().onContrastChanged(0.5f) + pause() + + whenever(uiModeManager.contrast).thenReturn(Float.NaN) + resume() + + verify(reactContext, times(1)).emitDeviceEvent(CONTRAST_LEVEL_EVENT, 0.5) + verify(reactContext, never()).emitDeviceEvent(CONTRAST_LEVEL_EVENT, 0.0) + assertThat(currentContrastLevel()).isEqualTo(0.5) + } + + @Test + fun notANumberAtTheStartIsTheStandardContrast() { + whenever(uiModeManager.contrast).thenReturn(Float.NaN) + startModule() + + assertThat(currentContrastLevel()).isEqualTo(0.0) + } + @Test fun resumingTwiceRegistersOneListener() { resume() From dadc98054daae5ea20f4f5c7dcd95a067acb1f3c Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:42:19 +0100 Subject: [PATCH 09/11] Make the iOS contrastLevelChanged test fail if iOS subscribes The test now emits both contrastLevelDidChange and contrastLevelChanged, so mapping the event to either name on iOS would call the handler. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/AccessibilityInfo-contrastLevelChanged-test.js | 2 ++ 1 file changed, 2 insertions(+) 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 index 17c031e7c79a..a0905ddcb5a2 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-contrastLevelChanged-test.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-contrastLevelChanged-test.js @@ -66,7 +66,9 @@ describe('AccessibilityInfo contrastLevelChanged', () => { 'contrastLevelChanged', handler, ); + // Neither the Android name nor the public name reaches the handler. RCTDeviceEventEmitter.emit('contrastLevelDidChange', 0.5); + RCTDeviceEventEmitter.emit('contrastLevelChanged', 0.5); subscription.remove(); expect(handler).not.toHaveBeenCalled(); From 544a521800481d69623c2495f30d2360926cf0c4 Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:42:23 +0100 Subject: [PATCH 10/11] Handle non-Error rejections in the RNTester contrast level row Co-Authored-By: Claude Opus 5.5 --- .../js/examples/Accessibility/AccessibilityExample.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index 2c9de402d2a2..b3122a1833fd 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js @@ -1634,9 +1634,11 @@ function ContrastLevelExample(): React.Node { setContrastLevel(String(level)); } }) - .catch((error: Error) => { + .catch((error: unknown) => { if (!ignoreStartingRead) { - setContrastLevel(`Unavailable: ${error.message}`); + const message = + error instanceof Error ? error.message : String(error); + setContrastLevel(`Unavailable: ${message}`); } }); return function cleanup() { From d6b00f7c534d7c075bb8389993df6c15c17004a9 Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:26:23 +0100 Subject: [PATCH 11/11] Suppress BridgeReactContext deprecation in AccessibilityInfoModuleTest CI compiles tests with -PenableWarningsAsErrors=true, and BridgeReactContext is deprecated, so the test failed to compile there. Suppress the deprecation for the file, as DeviceInfoModuleTest and the other tests that use BridgeReactContext do. Co-Authored-By: Claude Opus 5.5 --- .../modules/accessibilityinfo/AccessibilityInfoModuleTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt index e0859d5e89ba..9bdf842a83a3 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +@file:Suppress("DEPRECATION") + package com.facebook.react.modules.accessibilityinfo import android.app.UiModeManager