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..fcd5470861a6 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,45 @@ 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 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 + */ + getContrastLevel(): Promise { + if (Platform.OS === 'android') { + return new Promise((resolve, reject) => { + 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', + ), + ); + } + }); + } else { + // iOS has no contrast level, only the on/off "Increase Contrast" setting, which + // `isDarkerSystemColorsEnabled` reports. + return Promise.resolve(0); + } + }, + /** * Query whether darker system colors is currently enabled. * @@ -445,6 +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. * - `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..a0905ddcb5a2 --- /dev/null +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-contrastLevelChanged-test.js @@ -0,0 +1,76 @@ +/** + * 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, + ); + // 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(); + }); +}); 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..47b2bbf7f039 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,70 @@ 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', + ); + }); + + 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', () => { + 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 +930,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..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 @@ -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 @@ -15,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 @@ -72,6 +78,9 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : } } } + // 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() @@ -80,6 +89,13 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : private val contentResolver: ContentResolver private var reduceMotionEnabled = false private var highTextContrastEnabled = false + // Written on the main thread and read on the native modules thread. + @Volatile private var contrastLevel = 0f + 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 private var touchExplorationEnabled = false private var accessibilityServiceEnabled = false private var recommendedTimeout = 0 @@ -91,10 +107,25 @@ 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()) { + normalizedContrastLevel(rawLevel)?.let { updateAndSendContrastLevelChangeEvent(it) } + } + } + } else { + null + } touchExplorationEnabled = accessibilityManager?.isTouchExplorationEnabled ?: false accessibilityServiceEnabled = accessibilityManager?.isEnabled ?: false reduceMotionEnabled = isReduceMotionEnabledValue highTextContrastEnabled = isHighTextContrastEnabledValue + // There is no earlier value to keep, so NaN at the start is the standard contrast. + contrastLevel = contrastLevelValue ?: 0f grayscaleModeEnabled = isGrayscaleEnabledValue } @@ -154,6 +185,39 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : ) != 0 } + /** 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 + } + val listener = contrastListener ?: return 0f + return normalizedContrastLevel(listener.currentLevel()) + } + + /** + * 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, + "AccessibilityInfoModule: UiModeManager reported the contrast level $rawLevel, using $level instead", + ) + } + return level + } + override fun isReduceMotionEnabled(successCallback: Callback) { successCallback.invoke(reduceMotionEnabled) } @@ -170,6 +234,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 +276,29 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : } } + /** [level] must already be normalized. */ + private fun updateAndSendContrastLevelChangeEvent(level: Float) { + if (contrastLevel != level) { + contrastLevel = level + getReactApplicationContextIfActiveOrWarn() + ?.emitDeviceEvent(CONTRAST_LEVEL_EVENT_NAME, level.toDouble()) + } + } + + private fun registerContrastChangeListener() { + if (invalidated || Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return + } + contrastListener?.register(reactApplicationContext.mainExecutor) + } + + private fun unregisterContrastChangeListener() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return + } + contrastListener?.unregister() + } + private fun updateAndSendTouchExplorationChangeEvent(enabled: Boolean) { if (touchExplorationEnabled != enabled) { touchExplorationEnabled = enabled @@ -234,6 +325,9 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : } override fun onHostResume() { + if (invalidated) { + return + } accessibilityManager?.addTouchExplorationStateChangeListener( touchExplorationStateChangeListener, ) @@ -243,12 +337,17 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : val highTextContrastUri = Settings.Secure.getUriFor(ACCESSIBILITY_HIGH_TEXT_CONTRAST_ENABLED_CONSTANT) contentResolver.registerContentObserver(highTextContrastUri, false, highTextContrastObserver) + // 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, ) updateAndSendAccessibilityServiceChangeEvent(accessibilityManager?.isEnabled == true) updateAndSendReduceMotionChangeEvent() updateAndSendHighTextContrastChangeEvent() + // A NaN keeps the last level, so nothing is sent. + contrastLevelValue?.let { updateAndSendContrastLevelChangeEvent(it) } updateAndSendInvertColorsChangeEvent() updateAndSendGrayscaleModeChangeEvent() } @@ -260,6 +359,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : accessibilityManager?.removeAccessibilityStateChangeListener(accessibilityServiceChangeListener) contentResolver.unregisterContentObserver(animationScaleObserver) contentResolver.unregisterContentObserver(highTextContrastObserver) + unregisterContrastChangeListener() } override fun initialize() { @@ -273,7 +373,21 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : } override fun invalidate() { + // Removing the lifecycle listener first drops an onHostResume that ReactContext has queued. reactApplicationContext.removeLifecycleEventListener(this) + invalidated = true + // The contrast listener is added and removed on the UI thread, but invalidate() can run on + // 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() } @@ -308,6 +422,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" @@ -318,5 +462,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/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..9bdf842a83a3 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt @@ -0,0 +1,365 @@ +/* + * 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. + */ + +@file:Suppress("DEPRECATION") + +package com.facebook.react.modules.accessibilityinfo + +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 +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 +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.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 { + + private lateinit var uiModeManager: UiModeManager + private lateinit var reactContext: BridgeReactContext + private lateinit var module: AccessibilityInfoModule + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + uiModeManager = mock() + whenever(uiModeManager.contrast).thenReturn(0f) + } + startModule() + } + + @Test + fun aContrastChangeSendsContrastLevelDidChangeOnce() { + resume() + val listener = registeredListener() + + 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 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() { + resume() + + 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 notANumberBeforeAnyChangeKeepsTheStandardContrast() { + 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 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() + resume() + + verify(uiModeManager, times(1)).addContrastChangeListener(any(), any()) + } + + @Test + fun pausingRemovesTheListener() { + resume() + val listener = registeredListener() + + pause() + + verify(uiModeManager).removeContrastChangeListener(listener) + } + + @Test + fun invalidatingOnTheUiThreadRemovesTheListenerAtOnce() { + resume() + val listener = registeredListener() + + module.invalidate() + + verify(uiModeManager).removeContrastChangeListener(listener) + } + + @Test + 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() + + verify(uiModeManager, never()).addContrastChangeListener(any(), any()) + } + + @Test + fun aChangeWhilePausedIsSentOnResume() { + resume() + pause() + + whenever(uiModeManager.contrast).thenReturn(1f) + 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() { + resume() + pause() + module.invalidate() + + assertThat(currentContrastLevel()).isEqualTo(0.0) + 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 { + 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) + } + + 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 + + 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" + } +} 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..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 @@ -95,6 +95,21 @@ export interface AccessibilityInfoStatic { */ isShowBordersEnabled: () => Promise; + /** + * 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 + */ + getContrastLevel: () => Promise; + /** * Query whether darker system colors is currently enabled. * @@ -151,6 +166,16 @@ export interface AccessibilityInfoStatic { eventName: AccessibilityAnnouncementEventName, handler: AccessibilityAnnouncementFinishedEventHandler, ): EmitterSubscription; + /** + * 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 + */ + 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..b3122a1833fd 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('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(String(level)); + }, + ); + // $FlowFixMe[unused-promise] + AccessibilityInfo.getContrastLevel() + .then(level => { + if (!ignoreStartingRead) { + setContrastLevel(String(level)); + } + }) + .catch((error: unknown) => { + if (!ignoreStartingRead) { + const message = + error instanceof Error ? error.message : String(error); + setContrastLevel(`Unavailable: ${message}`); + } + }); + return function cleanup() { + ignoreStartingRead = true; + listener.remove(); + }; + }, []); + return ( + + + {'Contrast level is '} + {contrastLevel}. + + + {'Last contrastLevelChanged event: '} + {lastEvent ?? 'none'}. + + + ); +} + function DisplayOptionStatusExample({ optionName, optionChecker, 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