diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js index b922c77f0626..298fd25e97a5 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], }; @@ -35,6 +34,7 @@ type AccessibilityEventDefinitionsIOS = { type AccessibilityEventDefinitions = { ...AccessibilityEventDefinitionsAndroid, ...AccessibilityEventDefinitionsIOS, + boldTextChanged: [boolean], change: [boolean], // screenReaderChanged grayscaleChanged: [boolean], invertColorsChanged: [boolean], @@ -52,6 +52,7 @@ const EventNames: Map = ['change', 'touchExplorationDidChange'], ['reduceMotionChanged', 'reduceMotionDidChange'], ['highTextContrastChanged', 'highTextContrastDidChange'], + ['boldTextChanged', 'boldTextDidChange'], ['screenReaderChanged', 'touchExplorationDidChange'], ['accessibilityServiceChanged', 'accessibilityServiceDidChange'], ['invertColorsChanged', 'invertColorDidChange'], @@ -81,11 +82,19 @@ 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 { + // Native code that predates Android support keeps the old behavior. + resolve(false); + } + }); } else { return new Promise((resolve, reject) => { if (NativeAccessibilityManagerIOS != null) { @@ -388,12 +397,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 a 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 9e3d63ca330d..1a2bf476d5cd 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js @@ -104,6 +104,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)); @@ -114,6 +115,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, @@ -143,6 +148,7 @@ let mockNativeAccessibilityInfoAndroid: { void, > | null, } = { + isBoldTextEnabled: mockIsBoldTextEnabledAndroid, isHighTextContrastEnabled: mockIsHighTextContrastEnabled, isGrayscaleEnabled: mockIsGrayscaleEnabled, isInvertColorsEnabled: mockIsInvertColorsEnabled, @@ -176,10 +182,22 @@ 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); @@ -805,6 +823,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..680e1563c8ea 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,8 +7,10 @@ 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 @@ -72,6 +74,22 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : } } } + // Listener that is notified when the bold text setting (font weight adjustment) changes. + private val boldTextCallbacks: ComponentCallbacks = + object : ComponentCallbacks { + override fun onConfigurationChanged(newConfig: Configuration) { + if (reactApplicationContext.hasActiveReactInstance()) { + updateAndSendBoldTextChangeEvent() + } + } + + @Deprecated( + "onLowMemory is deprecated, use onTrimMemory instead.", + ReplaceWith("onTrimMemory(level)"), + ) + override fun onLowMemory(): Unit = Unit + } + private var boldTextCallbacksRegistered = false private val accessibilityManager: AccessibilityManager? private val touchExplorationStateChangeListener: ReactTouchExplorationStateChangeListener = ReactTouchExplorationStateChangeListener() @@ -80,6 +98,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : private val contentResolver: ContentResolver private var reduceMotionEnabled = false private var highTextContrastEnabled = false + private var boldTextEnabled = false private var touchExplorationEnabled = false private var accessibilityServiceEnabled = false private var recommendedTimeout = 0 @@ -95,6 +114,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : accessibilityServiceEnabled = accessibilityManager?.isEnabled ?: false reduceMotionEnabled = isReduceMotionEnabledValue highTextContrastEnabled = isHighTextContrastEnabledValue + boldTextEnabled = isBoldTextEnabledValue grayscaleModeEnabled = isGrayscaleEnabledValue } @@ -154,6 +174,11 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : ) != 0 } + private val isBoldTextEnabledValue: Boolean + get() = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + isBoldTextEnabled(reactApplicationContext.resources.configuration.fontWeightAdjustment) + override fun isReduceMotionEnabled(successCallback: Callback) { successCallback.invoke(reduceMotionEnabled) } @@ -170,6 +195,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 +237,15 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : } } + private fun updateAndSendBoldTextChangeEvent() { + val isBoldTextEnabled = isBoldTextEnabledValue + if (boldTextEnabled != isBoldTextEnabled) { + boldTextEnabled = isBoldTextEnabled + getReactApplicationContextIfActiveOrWarn() + ?.emitDeviceEvent(BOLD_TEXT_EVENT_NAME, boldTextEnabled) + } + } + private fun updateAndSendTouchExplorationChangeEvent(enabled: Boolean) { if (touchExplorationEnabled != enabled) { touchExplorationEnabled = enabled @@ -243,12 +281,17 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : val highTextContrastUri = Settings.Secure.getUriFor(ACCESSIBILITY_HIGH_TEXT_CONTRAST_ENABLED_CONSTANT) contentResolver.registerContentObserver(highTextContrastUri, false, highTextContrastObserver) + if (!boldTextCallbacksRegistered) { + reactApplicationContext.applicationContext.registerComponentCallbacks(boldTextCallbacks) + boldTextCallbacksRegistered = true + } updateAndSendTouchExplorationChangeEvent( accessibilityManager?.isTouchExplorationEnabled == true, ) updateAndSendAccessibilityServiceChangeEvent(accessibilityManager?.isEnabled == true) updateAndSendReduceMotionChangeEvent() updateAndSendHighTextContrastChangeEvent() + updateAndSendBoldTextChangeEvent() updateAndSendInvertColorsChangeEvent() updateAndSendGrayscaleModeChangeEvent() } @@ -260,6 +303,14 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : accessibilityManager?.removeAccessibilityStateChangeListener(accessibilityServiceChangeListener) contentResolver.unregisterContentObserver(animationScaleObserver) contentResolver.unregisterContentObserver(highTextContrastObserver) + unregisterBoldTextCallbacks() + } + + private fun unregisterBoldTextCallbacks() { + if (boldTextCallbacksRegistered) { + reactApplicationContext.applicationContext.unregisterComponentCallbacks(boldTextCallbacks) + boldTextCallbacksRegistered = false + } } override fun initialize() { @@ -270,10 +321,12 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) : updateAndSendAccessibilityServiceChangeEvent(accessibilityManager?.isEnabled == true) updateAndSendReduceMotionChangeEvent() updateAndSendHighTextContrastChangeEvent() + updateAndSendBoldTextChangeEvent() } override fun invalidate() { reactApplicationContext.removeLifecycleEventListener(this) + unregisterBoldTextCallbacks() super.invalidate() } @@ -318,5 +371,14 @@ 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. A negative adjustment means lighter text, and + * [Configuration.FONT_WEIGHT_ADJUSTMENT_UNDEFINED] means the value is not known. + */ + internal fun isBoldTextEnabled(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..f9be0c7f6dac --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt @@ -0,0 +1,39 @@ +/* + * 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.content.res.Configuration +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class AccessibilityInfoModuleTest { + @Test + fun boldTextIsEnabledForAPositiveFontWeightAdjustment() { + assertThat(AccessibilityInfoModule.isBoldTextEnabled(300)).isTrue() + } + + @Test + fun boldTextIsDisabledWithoutAFontWeightAdjustment() { + assertThat(AccessibilityInfoModule.isBoldTextEnabled(0)).isFalse() + } + + @Test + fun boldTextIsDisabledForANegativeFontWeightAdjustment() { + assertThat(AccessibilityInfoModule.isBoldTextEnabled(-100)).isFalse() + } + + @Test + fun boldTextIsDisabledWhenTheFontWeightAdjustmentIsUndefined() { + assertThat( + AccessibilityInfoModule.isBoldTextEnabled( + Configuration.FONT_WEIGHT_ADJUSTMENT_UNDEFINED + ) + ) + .isFalse() + } +} diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 74c407136ff1..7377f88072f0 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<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -954,6 +954,7 @@ declare type AccessibilityActionName = declare type AccessibilityEventDefinitions = AccessibilityEventDefinitionsAndroid & AccessibilityEventDefinitionsIOS & { + boldTextChanged: [boolean] change: [boolean] grayscaleChanged: [boolean] invertColorsChanged: [boolean] @@ -971,7 +972,6 @@ declare type AccessibilityEventDefinitionsIOS = { success: boolean }, ] - boldTextChanged: [boolean] darkerSystemColorsChanged: [boolean] reduceTransparencyChanged: [boolean] } @@ -5770,7 +5770,7 @@ declare type WrapperComponentProvider = ( export { AccessibilityActionEvent, // a276ec6e AccessibilityActionInfo, // c94545a7 - AccessibilityInfo, // d92c5eae + AccessibilityInfo, // 792529c4 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 80e5fe6a3295..e134b20357f4 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 */ + /** On Android, requires Android 12 (API 31) or later */ | 'boldTextChanged' /** @deprecated Maps to 'screenReaderChanged' */ | 'change' @@ -55,7 +55,7 @@ export interface AccessibilityInfoStatic { /** * Query whether bold text is currently enabled. * - * @platform ios + * On Android, requires Android 12 (API 31) or later. */ isBoldTextEnabled: () => Promise; diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index 2ac5879c9458..90752f142221 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js @@ -1392,21 +1392,17 @@ class EnabledExamples extends React.Component<{}> { render(): React.Node { return ( + + + + {Platform.OS === 'ios' ? ( - <> - - - - - - - + + + ) : null} {Platform.OS === 'android' ? ( @@ -1577,13 +1573,13 @@ class DisplayOptionsStatusExample extends React.Component<{}> { optionChecker={AccessibilityInfo.isScreenReaderEnabled} notification={'screenReaderChanged'} /> + {isAndroid ? null : ( <> -