diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js index 017c1d3c842e..0eca6984db62 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -27,7 +27,6 @@ type AccessibilityEventDefinitionsAndroid = { // Events that are only supported on iOS. type AccessibilityEventDefinitionsIOS = { announcementFinished: [{announcement: string, success: boolean}], - boldTextChanged: [boolean], darkerSystemColorsChanged: [boolean], reduceTransparencyChanged: [boolean], showBordersChanged: [boolean], @@ -36,6 +35,7 @@ type AccessibilityEventDefinitionsIOS = { type AccessibilityEventDefinitions = { ...AccessibilityEventDefinitionsAndroid, ...AccessibilityEventDefinitionsIOS, + boldTextChanged: [boolean], change: [boolean], // screenReaderChanged grayscaleChanged: [boolean], invertColorsChanged: [boolean], @@ -53,6 +53,7 @@ const EventNames: Map = ['change', 'touchExplorationDidChange'], ['reduceMotionChanged', 'reduceMotionDidChange'], ['highTextContrastChanged', 'highTextContrastDidChange'], + ['boldTextChanged', 'boldTextDidChange'], ['screenReaderChanged', 'touchExplorationDidChange'], ['accessibilityServiceChanged', 'accessibilityServiceDidChange'], ['invertColorsChanged', 'invertColorDidChange'], @@ -83,11 +84,22 @@ const AccessibilityInfo = { * Returns a promise which resolves to a boolean. * The result is `true` when bold text is enabled and `false` otherwise. * - * @platform ios + * On Android, bold text is supported on Android 12 (API 31) and later; + * earlier versions always resolve to `false`. */ isBoldTextEnabled(): Promise { if (Platform.OS === 'android') { - return Promise.resolve(false); + return new Promise(resolve => { + if (NativeAccessibilityInfoAndroid?.isBoldTextEnabled != null) { + NativeAccessibilityInfoAndroid.isBoldTextEnabled(resolve); + } else { + // `isBoldTextEnabled` is an existing API that resolved `false` on + // Android before native support was added, so it keeps resolving + // `false` when the native module or method is missing. APIs that are + // new reject instead, like `isShowBordersEnabled`. + resolve(false); + } + }); } else { return new Promise((resolve, reject) => { if (NativeAccessibilityManagerIOS != null) { @@ -420,12 +432,12 @@ const AccessibilityInfo = { * - `invertColorsChanged`: Fires when the state of the invert colors toggle * changes. The argument to the event handler is a boolean. The boolean is `true` when invert * colors is enabled and `false` otherwise. + * - `boldTextChanged`: Fires when the state of the bold text toggle changes. + * The argument to the event handler is a boolean. The boolean is `true` when bold text + * is enabled and `false` otherwise. On Android, requires Android 12 (API 31) or later. * * These events are only supported on iOS: * - * - `boldTextChanged`: iOS-only event. Fires when the state of the bold text toggle changes. - * The argument to the event handler is a boolean. The boolean is `true` when a bold text - * is enabled and `false` otherwise. * - `reduceTransparencyChanged`: iOS-only event. Fires when the state of the reduce transparency * toggle changes. The argument to the event handler is a boolean. The boolean is `true` * when a reduce transparency is enabled and `false` otherwise. diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-events-test.js b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-events-test.js new file mode 100644 index 000000000000..36687a0ae6ec --- /dev/null +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-events-test.js @@ -0,0 +1,78 @@ +/** + * 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 events', () => { + describe('Android', () => { + it('delivers boldTextDidChange as boldTextChanged', () => { + const {AccessibilityInfo, RCTDeviceEventEmitter} = + loadForPlatform('android'); + const handler = jest.fn(); + + const subscription = AccessibilityInfo.addEventListener( + 'boldTextChanged', + handler, + ); + RCTDeviceEventEmitter.emit('boldTextDidChange', true); + subscription.remove(); + RCTDeviceEventEmitter.emit('boldTextDidChange', false); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(true); + }); + }); + + describe('iOS', () => { + it('delivers boldTextChanged as boldTextChanged', () => { + const {AccessibilityInfo, RCTDeviceEventEmitter} = loadForPlatform('ios'); + const handler = jest.fn(); + + const subscription = AccessibilityInfo.addEventListener( + 'boldTextChanged', + handler, + ); + RCTDeviceEventEmitter.emit('boldTextChanged', true); + subscription.remove(); + RCTDeviceEventEmitter.emit('boldTextChanged', false); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(true); + }); + }); +}); 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..e0aad54026c6 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js @@ -115,6 +115,7 @@ let mockNativeAccessibilityManagerIOS: { }; const ANDROID_RECOMMENDED_TIMEOUT = 6000; +const mockIsBoldTextEnabledAndroid = jest.fn(onSuccess => onSuccess(true)); const mockIsHighTextContrastEnabled = jest.fn(onSuccess => onSuccess(true)); const mockIsGrayscaleEnabled = jest.fn(onSuccess => onSuccess(true)); const mockIsInvertColorsEnabled = jest.fn(onSuccess => onSuccess(true)); @@ -125,6 +126,10 @@ const mockGetRecommendedTimeoutMillis = jest.fn((originalTimeout, onSuccess) => onSuccess(ANDROID_RECOMMENDED_TIMEOUT), ); let mockNativeAccessibilityInfoAndroid: { + isBoldTextEnabled: JestMockFn< + [onSuccess: (isBoldTextEnabled: boolean) => void], + void, + > | null, isHighTextContrastEnabled: JestMockFn< [onSuccess: (isHighTextContrastEnabled: boolean) => void], void, @@ -154,6 +159,7 @@ let mockNativeAccessibilityInfoAndroid: { void, > | null, } = { + isBoldTextEnabled: mockIsBoldTextEnabledAndroid, isHighTextContrastEnabled: mockIsHighTextContrastEnabled, isGrayscaleEnabled: mockIsGrayscaleEnabled, isInvertColorsEnabled: mockIsInvertColorsEnabled, @@ -187,12 +193,39 @@ describe('AccessibilityInfo', () => { describe('isBoldTextEnabled', () => { describe('Android', () => { - it('should return immediately', async () => { + it('should call isBoldTextEnabled if available', async () => { /* $FlowFixMe[incompatible-type] */ Platform.OS = 'android'; const isBoldTextEnabled = await AccessibilityInfo.isBoldTextEnabled(); + expect(mockIsBoldTextEnabledAndroid).toHaveBeenCalled(); + expect(isBoldTextEnabled).toBe(true); + }); + + it('should return false if isBoldTextEnabled is not available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'android'; + + mockNativeAccessibilityInfoAndroid.isBoldTextEnabled = null; + + const isBoldTextEnabled = await AccessibilityInfo.isBoldTextEnabled(); + + expect(isBoldTextEnabled).toBe(false); + }); + + it('should return false if NativeAccessibilityInfoAndroid is not available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'android'; + + const nativeAccessibilityInfoModule = jest.requireMock( + '../NativeAccessibilityInfo', + ); + nativeAccessibilityInfoModule.default = null; + + const isBoldTextEnabled = await AccessibilityInfo.isBoldTextEnabled(); + + expect(mockIsBoldTextEnabledAndroid).not.toHaveBeenCalled(); expect(isBoldTextEnabled).toBe(false); }); }); @@ -860,6 +893,7 @@ describe('AccessibilityInfo', () => { mockNativeAccessibilityManagerIOS; mockNativeAccessibilityInfoAndroid = { + isBoldTextEnabled: mockIsBoldTextEnabledAndroid, 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..2a1157794cb3 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,19 +7,23 @@ package com.facebook.react.modules.accessibilityinfo +import android.content.ComponentCallbacks import android.content.ContentResolver import android.content.Context +import android.content.res.Configuration import android.database.ContentObserver import android.net.Uri import android.os.Build import android.provider.Settings import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityManager +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 /** @@ -72,6 +76,29 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : } } } + // Listener that is notified on every configuration change. Only a change to the font weight + // adjustment, which the bold text setting controls, sends an event. Registered on Android 12 + // (API 31) and later, where the adjustment exists. + private val boldTextCallbacks: ComponentCallbacks = + object : ComponentCallbacks { + override fun onConfigurationChanged(newConfig: Configuration) { + // A change already queued on the main thread can arrive after `invalidate()`. + if (!invalidated && reactApplicationContext.hasActiveReactInstance()) { + updateAndSendBoldTextChangeEvent( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + isBoldFontWeightAdjustment(newConfig.fontWeightAdjustment) + ) + } + } + + @Deprecated("Overrides deprecated ComponentCallbacks.onLowMemory()") + override fun onLowMemory(): Unit = Unit + } + // Only read and written on the main thread. + private var boldTextCallbacksRegistered = false + // Set by `invalidate()`, on any thread, and read on the main thread. Once set, nothing registers + // again and queued callbacks send nothing. + @Volatile private var invalidated = false private val accessibilityManager: AccessibilityManager? private val touchExplorationStateChangeListener: ReactTouchExplorationStateChangeListener = ReactTouchExplorationStateChangeListener() @@ -80,6 +107,8 @@ 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 boldTextEnabled = false private var touchExplorationEnabled = false private var accessibilityServiceEnabled = false private var recommendedTimeout = 0 @@ -95,6 +124,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : accessibilityServiceEnabled = accessibilityManager?.isEnabled ?: false reduceMotionEnabled = isReduceMotionEnabledValue highTextContrastEnabled = isHighTextContrastEnabledValue + boldTextEnabled = isBoldTextEnabledValue grayscaleModeEnabled = isGrayscaleEnabledValue } @@ -154,6 +184,13 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : ) != 0 } + private val isBoldTextEnabledValue: Boolean + get() = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + isBoldFontWeightAdjustment( + reactApplicationContext.resources.configuration.fontWeightAdjustment + ) + override fun isReduceMotionEnabled(successCallback: Callback) { successCallback.invoke(reduceMotionEnabled) } @@ -170,6 +207,10 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : successCallback.invoke(highTextContrastEnabled) } + override fun isBoldTextEnabled(successCallback: Callback) { + successCallback.invoke(boldTextEnabled) + } + override fun isTouchExplorationEnabled(successCallback: Callback) { successCallback.invoke(touchExplorationEnabled) } @@ -208,6 +249,14 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : } } + private fun updateAndSendBoldTextChangeEvent(isBoldTextEnabled: Boolean) { + if (boldTextEnabled != isBoldTextEnabled) { + boldTextEnabled = isBoldTextEnabled + getReactApplicationContextIfActiveOrWarn() + ?.emitDeviceEvent(BOLD_TEXT_EVENT_NAME, boldTextEnabled) + } + } + private fun updateAndSendTouchExplorationChangeEvent(enabled: Boolean) { if (touchExplorationEnabled != enabled) { touchExplorationEnabled = enabled @@ -234,6 +283,9 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : } override fun onHostResume() { + if (invalidated) { + return + } accessibilityManager?.addTouchExplorationStateChangeListener( touchExplorationStateChangeListener, ) @@ -243,12 +295,15 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : val highTextContrastUri = Settings.Secure.getUriFor(ACCESSIBILITY_HIGH_TEXT_CONTRAST_ENABLED_CONSTANT) contentResolver.registerContentObserver(highTextContrastUri, false, highTextContrastObserver) + registerBoldTextCallbacks() updateAndSendTouchExplorationChangeEvent( accessibilityManager?.isTouchExplorationEnabled == true, ) updateAndSendAccessibilityServiceChangeEvent(accessibilityManager?.isEnabled == true) updateAndSendReduceMotionChangeEvent() updateAndSendHighTextContrastChangeEvent() + // The listener is removed while paused, so read the current value again. + updateAndSendBoldTextChangeEvent(isBoldTextEnabledValue) updateAndSendInvertColorsChangeEvent() updateAndSendGrayscaleModeChangeEvent() } @@ -260,6 +315,25 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : accessibilityManager?.removeAccessibilityStateChangeListener(accessibilityServiceChangeListener) contentResolver.unregisterContentObserver(animationScaleObserver) contentResolver.unregisterContentObserver(highTextContrastObserver) + unregisterBoldTextCallbacks() + } + + private fun registerBoldTextCallbacks() { + if ( + !invalidated && + !boldTextCallbacksRegistered && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + ) { + reactApplicationContext.applicationContext.registerComponentCallbacks(boldTextCallbacks) + boldTextCallbacksRegistered = true + } + } + + private fun unregisterBoldTextCallbacks() { + if (boldTextCallbacksRegistered) { + reactApplicationContext.applicationContext.unregisterComponentCallbacks(boldTextCallbacks) + boldTextCallbacksRegistered = false + } } override fun initialize() { @@ -270,10 +344,23 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : updateAndSendAccessibilityServiceChangeEvent(accessibilityManager?.isEnabled == true) updateAndSendReduceMotionChangeEvent() updateAndSendHighTextContrastChangeEvent() + updateAndSendBoldTextChangeEvent(isBoldTextEnabledValue) } override fun invalidate() { + // Removing the listener first drops an `onHostResume` that `ReactContext` has already queued. reactApplicationContext.removeLifecycleEventListener(this) + invalidated = true + // `invalidate()` can run off the main thread, but the callbacks are registered on it. + if (UiThreadUtil.isOnUiThread()) { + unregisterBoldTextCallbacks() + } else if (!UiThreadUtil.runOnUiThread { unregisterBoldTextCallbacks() }) { + FLog.w( + ReactConstants.TAG, + "AccessibilityInfoModule: could not post to the main thread, so the bold text " + + "listener may leak.", + ) + } super.invalidate() } @@ -318,5 +405,15 @@ 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 BOLD_TEXT_EVENT_NAME = "boldTextDidChange" + + /** + * Bold text raises the font weight adjustment, and a negative adjustment means lighter text. + * [Configuration.FONT_WEIGHT_ADJUSTMENT_UNDEFINED] means the value is not known. It is + * [Int.MAX_VALUE], so the `> 0` check alone would treat it as bold; it is excluded first. + */ + internal fun isBoldFontWeightAdjustment(fontWeightAdjustment: Int): Boolean = + fontWeightAdjustment != Configuration.FONT_WEIGHT_ADJUSTMENT_UNDEFINED && + fontWeightAdjustment > 0 } } 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..8eb5fb561b75 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt @@ -0,0 +1,314 @@ +/* + * 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.Application +import android.content.ComponentCallbacks +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Configuration +import android.os.Handler +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 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.never +import org.mockito.kotlin.spy +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +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) +@Config(shadows = [ShadowSoLoader::class, ShadowNativeLoader::class]) +class AccessibilityInfoModuleTest { + + private lateinit var recordingContext: RecordingContext + private lateinit var reactContext: BridgeReactContext + private lateinit var module: AccessibilityInfoModule + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + recordingContext = RecordingContext(RuntimeEnvironment.getApplication()) + reactContext = spy(BridgeReactContext(recordingContext)) + reactContext.initializeWithInstance(ReactTestHelper.createMockCatalystInstance()) + module = AccessibilityInfoModule(reactContext) + } + + @Test + fun aPositiveFontWeightAdjustmentIsBold() { + assertThat(AccessibilityInfoModule.isBoldFontWeightAdjustment(300)).isTrue() + } + + @Test + fun noFontWeightAdjustmentIsNotBold() { + assertThat(AccessibilityInfoModule.isBoldFontWeightAdjustment(0)).isFalse() + } + + @Test + fun aNegativeFontWeightAdjustmentIsNotBold() { + assertThat(AccessibilityInfoModule.isBoldFontWeightAdjustment(-100)).isFalse() + } + + @Test + fun anUndefinedFontWeightAdjustmentIsNotBold() { + assertThat( + AccessibilityInfoModule.isBoldFontWeightAdjustment( + Configuration.FONT_WEIGHT_ADJUSTMENT_UNDEFINED + ) + ) + .isFalse() + } + + @Test + fun aConfigurationChangeSendsBoldTextDidChangeOnce() { + module.onHostResume() + + dispatchConfigurationChange(setFontWeightAdjustment(300)) + dispatchConfigurationChange(setFontWeightAdjustment(300)) + + verify(reactContext, times(1)).emitDeviceEvent(BOLD_TEXT_EVENT, true) + assertThat(currentBoldText()).isTrue() + } + + @Test + fun turningBoldTextOffSendsFalse() { + setFontWeightAdjustment(300) + module = AccessibilityInfoModule(reactContext) + module.onHostResume() + + dispatchConfigurationChange(setFontWeightAdjustment(0)) + + verify(reactContext, times(1)).emitDeviceEvent(BOLD_TEXT_EVENT, false) + assertThat(currentBoldText()).isFalse() + } + + @Test + fun noEventIsSentWhilePaused() { + module.onHostResume() + module.onHostPause() + + dispatchConfigurationChange(setFontWeightAdjustment(300)) + + verifyNoBoldTextEvent() + } + + @Test + fun resumingTwiceAndPausingOnceStopsTheEvents() { + module.onHostResume() + module.onHostResume() + module.onHostPause() + + dispatchConfigurationChange(setFontWeightAdjustment(300)) + + verifyNoBoldTextEvent() + } + + @Test + fun noEventIsSentAfterInvalidate() { + module.onHostResume() + module.invalidate() + + dispatchConfigurationChange(setFontWeightAdjustment(300)) + + verifyNoBoldTextEvent() + } + + @Test + fun aChangeWhilePausedIsSentOnResume() { + module.onHostResume() + module.onHostPause() + + setFontWeightAdjustment(300) + module.onHostResume() + + verify(reactContext, times(1)).emitDeviceEvent(BOLD_TEXT_EVENT, true) + } + + @Test + fun theConfigurationAndroidPassesInIsUsed() { + module.onHostResume() + + // The Application's resources are not updated, so only `newConfig` says bold text is on. + val config = + Configuration(RuntimeEnvironment.getApplication().resources.configuration).apply { + fontWeightAdjustment = 300 + } + dispatchConfigurationChange(config) + + verify(reactContext, times(1)).emitDeviceEvent(BOLD_TEXT_EVENT, true) + assertThat(currentBoldText()).isTrue() + } + + @Test + fun resumingThroughTheReactContextRegistersOnce() { + module.initialize() + reactContext.onHostResume(null) + reactContext.onHostResume(null) + + assertThat(recordingContext.registrations).hasSize(1) + + dispatchConfigurationChange(setFontWeightAdjustment(300)) + + verify(reactContext, times(1)).emitDeviceEvent(BOLD_TEXT_EVENT, true) + } + + @Test + fun invalidateOnTheMainThreadUnregistersInline() { + module.initialize() + reactContext.onHostResume(null) + + module.invalidate() + + assertThat(recordingContext.unregistrations).containsExactly(Looper.getMainLooper().thread) + } + + @Test + fun invalidateOnABackgroundThreadUnregistersOnTheMainThread() { + module.initialize() + reactContext.onHostResume(null) + + val background = Thread { module.invalidate() } + background.start() + background.join() + + assertThat(recordingContext.unregistrations).isEmpty() + + shadowOf(Looper.getMainLooper()).idle() + + assertThat(recordingContext.unregistrations).containsExactly(Looper.getMainLooper().thread) + } + + @Test + @LooperMode(LooperMode.Mode.PAUSED) + fun aConfigurationChangeQueuedBeforeInvalidateSendsNothing() { + module.initialize() + reactContext.onHostResume(null) + val config = setFontWeightAdjustment(300) + Handler(Looper.getMainLooper()).post { dispatchConfigurationChange(config) } + + val background = Thread { module.invalidate() } + background.start() + background.join() + shadowOf(Looper.getMainLooper()).idle() + + verifyNoBoldTextEvent() + assertThat(recordingContext.unregistrations).containsExactly(Looper.getMainLooper().thread) + } + + @Test + fun resumingAfterInvalidateDoesNotRegister() { + module.initialize() + module.invalidate() + + reactContext.onHostResume(null) + // A resume that reaches the module directly, such as one queued before `invalidate()`. + module.onHostResume() + + assertThat(recordingContext.registrations).isEmpty() + dispatchConfigurationChange(setFontWeightAdjustment(300)) + verifyNoBoldTextEvent() + } + + @Test + @Config(sdk = [30]) + fun nothingIsRegisteredBeforeAndroid12() { + module = AccessibilityInfoModule(reactContext) + module.initialize() + reactContext.onHostResume(null) + + assertThat(recordingContext.registrations).isEmpty() + + val config = + Configuration(RuntimeEnvironment.getApplication().resources.configuration).apply { + orientation = Configuration.ORIENTATION_LANDSCAPE + } + dispatchConfigurationChange(config) + + verifyNoBoldTextEvent() + } + + @Test + @Config(sdk = [30]) + fun boldTextIsAlwaysFalseBeforeAndroid12() { + module = AccessibilityInfoModule(reactContext) + module.onHostResume() + module.onHostPause() + module.invalidate() + + assertThat(currentBoldText()).isFalse() + verifyNoBoldTextEvent() + } + + private fun currentBoldText(): Boolean { + var result: Boolean? = null + module.isBoldTextEnabled(Callback { args -> result = args[0] as Boolean }) + return checkNotNull(result) + } + + private fun verifyNoBoldTextEvent() { + verify(reactContext, never()) + .emitDeviceEvent( + ArgumentMatchers.eq(BOLD_TEXT_EVENT), + ArgumentMatchers.any(), + ) + } + + private fun setFontWeightAdjustment(adjustment: Int): Configuration { + val resources = RuntimeEnvironment.getApplication().resources + val config = Configuration(resources.configuration).apply { fontWeightAdjustment = adjustment } + resources.updateConfiguration(config, resources.displayMetrics) + return config + } + + private fun dispatchConfigurationChange(config: Configuration) { + RuntimeEnvironment.getApplication().onConfigurationChanged(config) + } + + /** + * The module registers its callbacks on the application context. Returning itself from + * [getApplicationContext] makes this wrapper that context, so it can record each call and the + * thread it ran on before passing it to the real [Application]. It calls [getBaseContext] rather + * than `super`: before Android 12, [ContextWrapper] doesn't override these methods, and + * [Context]'s versions forward to [getApplicationContext], which is this wrapper. + */ + private class RecordingContext(base: Application) : ContextWrapper(base) { + val registrations = mutableListOf() + val unregistrations = mutableListOf() + + override fun getApplicationContext(): Context = this + + override fun registerComponentCallbacks(callback: ComponentCallbacks) { + registrations.add(Thread.currentThread()) + baseContext.registerComponentCallbacks(callback) + } + + override fun unregisterComponentCallbacks(callback: ComponentCallbacks) { + unregistrations.add(Thread.currentThread()) + baseContext.unregisterComponentCallbacks(callback) + } + } + + private companion object { + const val BOLD_TEXT_EVENT = "boldTextDidChange" + } +} diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index c0f8309f0ac5..ee2292fc1a5d 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<<02ba9498145f69e034d52dffd5824a89>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -955,6 +955,7 @@ declare type AccessibilityActionName = declare type AccessibilityEventDefinitions = AccessibilityEventDefinitionsAndroid & AccessibilityEventDefinitionsIOS & { + boldTextChanged: [boolean] change: [boolean] grayscaleChanged: [boolean] invertColorsChanged: [boolean] @@ -972,7 +973,6 @@ declare type AccessibilityEventDefinitionsIOS = { success: boolean }, ] - boldTextChanged: [boolean] darkerSystemColorsChanged: [boolean] reduceTransparencyChanged: [boolean] showBordersChanged: [boolean] @@ -5775,7 +5775,7 @@ declare type WrapperComponentProvider = ( export { AccessibilityActionEvent, // a276ec6e AccessibilityActionInfo, // c94545a7 - AccessibilityInfo, // 7d8718ed + AccessibilityInfo, // e31e919b AccessibilityProps, // 95dbb1d8 AccessibilityRole, // f2f2e066 AccessibilityState, // b0c2b3f7 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..ac3144211969 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 isBoldTextEnabled?: ( + onSuccess: (isBoldTextEnabled: boolean) => 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..4c46aa3a10fe 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 @@ -13,7 +13,7 @@ import {EmitterSubscription} from '../../vendor/emitter/EventEmitter'; type AccessibilityChangeEventName = /** Android only */ | 'accessibilityServiceChanged' - /** iOS only */ + /** iOS, and Android 12 (API 31) or later */ | 'boldTextChanged' /** @deprecated Maps to 'screenReaderChanged' */ | 'change' @@ -57,7 +57,8 @@ export interface AccessibilityInfoStatic { /** * Query whether bold text is currently enabled. * - * @platform ios + * iOS, and Android 12 (API 31) or later. Earlier Android versions resolve + * `false`. */ isBoldTextEnabled: () => Promise; diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index ff2891104139..2d310aa521c3 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js @@ -11,7 +11,7 @@ 'use strict'; import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; -import type {EventSubscription, GestureResponderEvent} from 'react-native'; +import type {EventSubscription} from 'react-native'; import RNTesterBlock from '../../components/RNTesterBlock'; import RNTesterText from '../../components/RNTesterText'; @@ -1392,21 +1392,17 @@ class EnabledExamples extends React.Component<{}> { render(): React.Node { return ( + + + + {Platform.OS === 'ios' ? ( - <> - - - - - - - + + + ) : null} {Platform.OS === 'android' ? ( @@ -1503,42 +1499,56 @@ class EnabledExample extends React.Component< }, { isEnabled: boolean, + error: ?string, }, > { - state: {isEnabled: boolean} = { + state: {isEnabled: boolean, error: ?string} = { isEnabled: false, + error: null, }; _subscription: EventSubscription; componentDidMount(): null | Promise { this._subscription = AccessibilityInfo.addEventListener( this.props.eventListener, - this._handleToggled, + this._handleChanged, ); + let initialValue: ?Promise = null; switch (this.props.eventListener) { case 'reduceMotionChanged': - return AccessibilityInfo.isReduceMotionEnabled().then(state => { - this.setState({isEnabled: state}); - }); + initialValue = AccessibilityInfo.isReduceMotionEnabled(); + break; case 'accessibilityServiceChanged': - return AccessibilityInfo.isAccessibilityServiceEnabled().then(state => { - this.setState({isEnabled: state}); - }); + initialValue = AccessibilityInfo.isAccessibilityServiceEnabled(); + break; + case 'boldTextChanged': + initialValue = AccessibilityInfo.isBoldTextEnabled(); + break; default: return null; } + return initialValue.then( + state => { + this.setState({isEnabled: state, error: null}); + }, + (error: unknown) => { + this.setState({ + error: error instanceof Error ? error.message : String(error), + }); + }, + ); } componentWillUnmount() { this._subscription?.remove(); } - _handleToggled = (isEnabled: void | GestureResponderEvent | boolean) => { - if (!this.state.isEnabled) { - this.setState({isEnabled: true}); - } else { - this.setState({isEnabled: false}); - } + _handleChanged = (isEnabled: boolean) => { + this.setState({isEnabled, error: null}); + }; + + _handleToggled = () => { + this.setState(state => ({isEnabled: !state.isEnabled})); }; render(): React.Node { @@ -1546,7 +1556,11 @@ class EnabledExample extends React.Component< The {this.props.test} is{' '} - {this.state.isEnabled ? 'enabled' : 'disabled'} + {this.state.error != null + ? `Unavailable: ${this.state.error}` + : this.state.isEnabled + ? 'enabled' + : 'disabled'}