From 21ed1c0f878de03f38465663b4396360fe20feb6 Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:40:16 +0100 Subject: [PATCH] Support AccessibilityInfo.isBoldTextEnabled and boldTextChanged on Android Android 12 (API 31) exposes the Bold Text setting as Configuration.fontWeightAdjustment. Read it, listen for configuration changes, and emit boldTextChanged. Earlier versions still resolve to false. If the native module predates this change, isBoldTextEnabled still resolves to false. Adds Kotlin unit tests for the adjustment values, Jest tests for the getter and the event wiring, and shows the Bold Text examples on Android in RNTester. Co-Authored-By: Claude Opus 5.5 --- .../AccessibilityInfo/AccessibilityInfo.js | 21 +++-- .../AccessibilityInfo-events-test.js | 78 +++++++++++++++++++ .../__tests__/AccessibilityInfo-test.js | 21 ++++- .../AccessibilityInfoModule.kt | 62 +++++++++++++++ .../AccessibilityInfoModuleTest.kt | 39 ++++++++++ packages/react-native/ReactNativeApi.d.ts | 6 +- .../modules/NativeAccessibilityInfo.js | 3 + .../AccessibilityInfo/AccessibilityInfo.d.ts | 4 +- .../Accessibility/AccessibilityExample.js | 34 ++++---- 9 files changed, 237 insertions(+), 31 deletions(-) create mode 100644 packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-events-test.js create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModuleTest.kt 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 : ( <> -