Skip to content

Commit 17016d3

Browse files
camilopiresclaude
andcommitted
Add AccessibilityInfo.getContrastLevel and contrastLevelChanged on Android
Android 14 (API 34) exposes the system contrast level, from -1 to 1, through UiModeManager.getContrast(). It is the Android equivalent of Increase Contrast on iOS; isHighTextContrastEnabled reads a different setting that only changes how text is drawn. Register a ContrastChangeListener while the app is resumed and read the value again on resume. Earlier versions and iOS resolve to 0. Adds Jest tests for the getter and the numeric event, and an RNTester example that shows the value and the last event. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 27e7721 commit 17016d3

10 files changed

Lines changed: 284 additions & 2 deletions

File tree

‎packages/jest-preset/jest/mocks/AccessibilityInfo.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ const AccessibilityInfo = {
2020
isAccessibilityServiceEnabled: jest.fn(() =>
2121
Promise.resolve(false),
2222
) as JestMockFn<$FlowFixMe, $FlowFixMe>,
23+
getContrastLevel: jest.fn(() => Promise.resolve(0)) as JestMockFn<
24+
$FlowFixMe,
25+
$FlowFixMe,
26+
>,
2327
isBoldTextEnabled: jest.fn(() => Promise.resolve(false)) as JestMockFn<
2428
$FlowFixMe,
2529
$FlowFixMe,

‎packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import NativeAccessibilityManagerIOS from './NativeAccessibilityManager';
2121
// Events that are only supported on Android.
2222
type AccessibilityEventDefinitionsAndroid = {
2323
accessibilityServiceChanged: [boolean],
24+
contrastLevelChanged: [number],
2425
highTextContrastChanged: [boolean],
2526
};
2627

@@ -52,6 +53,7 @@ const EventNames: Map<keyof AccessibilityEventDefinitions, string> =
5253
['change', 'touchExplorationDidChange'],
5354
['reduceMotionChanged', 'reduceMotionDidChange'],
5455
['highTextContrastChanged', 'highTextContrastDidChange'],
56+
['contrastLevelChanged', 'contrastLevelDidChange'],
5557
['screenReaderChanged', 'touchExplorationDidChange'],
5658
['accessibilityServiceChanged', 'accessibilityServiceDidChange'],
5759
['invertColorsChanged', 'invertColorDidChange'],
@@ -223,6 +225,35 @@ const AccessibilityInfo = {
223225
}
224226
},
225227

228+
/**
229+
* Query the contrast level chosen in the system settings.
230+
*
231+
* Returns a promise which resolves to a number from -1 to 1, where 0 is
232+
* the standard contrast and higher values mean more contrast. This is the
233+
* Android equivalent of "Increase Contrast" on iOS
234+
* (see `isDarkerSystemColorsEnabled`). Requires Android 14 (API 34) or
235+
* later; earlier versions resolve to 0.
236+
*
237+
* @platform android
238+
*/
239+
getContrastLevel(): Promise<number> {
240+
if (Platform.OS === 'android') {
241+
return new Promise((resolve, reject) => {
242+
if (NativeAccessibilityInfoAndroid?.getContrastLevel != null) {
243+
NativeAccessibilityInfoAndroid.getContrastLevel(resolve);
244+
} else {
245+
reject(
246+
new Error(
247+
'NativeAccessibilityInfoAndroid.getContrastLevel is not available',
248+
),
249+
);
250+
}
251+
});
252+
} else {
253+
return Promise.resolve(0);
254+
}
255+
},
256+
226257
/**
227258
* Query whether darker system colors is currently enabled.
228259
*
@@ -409,6 +440,9 @@ const AccessibilityInfo = {
409440
*
410441
* These events are only supported on Android:
411442
*
443+
* - `contrastLevelChanged`: Android-only event. Fires when the contrast level
444+
* changes. The argument to the event handler is a number from -1 to 1
445+
* (see `getContrastLevel`). Requires Android 14 (API 34) or later.
412446
* - `highTextContrastChanged`: Android-only event. Fires when the state of the high text contrast
413447
* toggle changes. The argument to the event handler is a boolean. The boolean is `true` when
414448
* high text contrast is enabled and `false` otherwise.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import typeof RCTDeviceEventEmitterType from '../../../EventEmitter/RCTDeviceEventEmitter';
12+
import typeof AccessibilityInfoType from '../AccessibilityInfo';
13+
14+
const invariant = require('invariant');
15+
16+
jest.unmock('../AccessibilityInfo');
17+
18+
type LoadedModules = {
19+
AccessibilityInfo: AccessibilityInfoType,
20+
RCTDeviceEventEmitter: RCTDeviceEventEmitterType,
21+
};
22+
23+
// `AccessibilityInfo` picks its native event names when it is first loaded,
24+
// so each platform needs a fresh module registry.
25+
function loadForPlatform(os: 'android' | 'ios'): LoadedModules {
26+
let modules: ?LoadedModules = null;
27+
jest.isolateModules(() => {
28+
const Platform = require('../../../Utilities/Platform').default;
29+
/* $FlowFixMe[incompatible-type] */
30+
Platform.OS = os;
31+
modules = {
32+
AccessibilityInfo: require('../AccessibilityInfo').default,
33+
RCTDeviceEventEmitter:
34+
require('../../../EventEmitter/RCTDeviceEventEmitter').default,
35+
};
36+
});
37+
invariant(modules != null, 'Expected modules to load');
38+
return modules;
39+
}
40+
41+
describe('AccessibilityInfo contrastLevelChanged', () => {
42+
it('delivers contrastLevelDidChange as contrastLevelChanged on Android', () => {
43+
const {AccessibilityInfo, RCTDeviceEventEmitter} =
44+
loadForPlatform('android');
45+
const handler = jest.fn();
46+
47+
const subscription = AccessibilityInfo.addEventListener(
48+
'contrastLevelChanged',
49+
handler,
50+
);
51+
RCTDeviceEventEmitter.emit('contrastLevelDidChange', 0.5);
52+
RCTDeviceEventEmitter.emit('contrastLevelDidChange', -0.25);
53+
subscription.remove();
54+
RCTDeviceEventEmitter.emit('contrastLevelDidChange', 1);
55+
56+
expect(handler).toHaveBeenCalledTimes(2);
57+
expect(handler).toHaveBeenNthCalledWith(1, 0.5);
58+
expect(handler).toHaveBeenNthCalledWith(2, -0.25);
59+
});
60+
61+
it('does not subscribe on iOS', () => {
62+
const {AccessibilityInfo, RCTDeviceEventEmitter} = loadForPlatform('ios');
63+
const handler = jest.fn();
64+
65+
const subscription = AccessibilityInfo.addEventListener(
66+
'contrastLevelChanged',
67+
handler,
68+
);
69+
RCTDeviceEventEmitter.emit('contrastLevelDidChange', 0.5);
70+
subscription.remove();
71+
72+
expect(handler).not.toHaveBeenCalled();
73+
});
74+
});

‎packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ let mockNativeAccessibilityManagerIOS: {
105105

106106
const ANDROID_RECOMMENDED_TIMEOUT = 6000;
107107
const mockIsHighTextContrastEnabled = jest.fn(onSuccess => onSuccess(true));
108+
const mockGetContrastLevel = jest.fn(onSuccess => onSuccess(0.5));
108109
const mockIsGrayscaleEnabled = jest.fn(onSuccess => onSuccess(true));
109110
const mockIsInvertColorsEnabled = jest.fn(onSuccess => onSuccess(true));
110111
const mockIsReduceMotionEnabled = jest.fn(onSuccess => onSuccess(true));
@@ -114,6 +115,10 @@ const mockGetRecommendedTimeoutMillis = jest.fn((originalTimeout, onSuccess) =>
114115
onSuccess(ANDROID_RECOMMENDED_TIMEOUT),
115116
);
116117
let mockNativeAccessibilityInfoAndroid: {
118+
getContrastLevel: JestMockFn<
119+
[onSuccess: (contrastLevel: number) => void],
120+
void,
121+
> | null,
117122
isHighTextContrastEnabled: JestMockFn<
118123
[onSuccess: (isHighTextContrastEnabled: boolean) => void],
119124
void,
@@ -143,6 +148,7 @@ let mockNativeAccessibilityInfoAndroid: {
143148
void,
144149
> | null,
145150
} = {
151+
getContrastLevel: mockGetContrastLevel,
146152
isHighTextContrastEnabled: mockIsHighTextContrastEnabled,
147153
isGrayscaleEnabled: mockIsGrayscaleEnabled,
148154
isInvertColorsEnabled: mockIsInvertColorsEnabled,
@@ -706,6 +712,49 @@ describe('AccessibilityInfo', () => {
706712
});
707713
});
708714

715+
describe('getContrastLevel', () => {
716+
describe('Android', () => {
717+
it('should call getContrastLevel if available', async () => {
718+
/* $FlowFixMe[incompatible-type] */
719+
Platform.OS = 'android';
720+
721+
const contrastLevel = await AccessibilityInfo.getContrastLevel();
722+
723+
expect(mockGetContrastLevel).toHaveBeenCalled();
724+
expect(contrastLevel).toBe(0.5);
725+
});
726+
727+
it('should throw error if getContrastLevel is not available', async () => {
728+
/* $FlowFixMe[incompatible-type] */
729+
Platform.OS = 'android';
730+
731+
mockNativeAccessibilityInfoAndroid.getContrastLevel = null;
732+
733+
const result: unknown =
734+
await AccessibilityInfo.getContrastLevel().catch(e => e);
735+
736+
invariant(
737+
result instanceof Error,
738+
'Expected getContrastLevel to reject',
739+
);
740+
expect(result.message).toEqual(
741+
'NativeAccessibilityInfoAndroid.getContrastLevel is not available',
742+
);
743+
});
744+
});
745+
746+
describe('iOS', () => {
747+
it('should return 0', async () => {
748+
/* $FlowFixMe[incompatible-type] */
749+
Platform.OS = 'ios';
750+
751+
const contrastLevel = await AccessibilityInfo.getContrastLevel();
752+
753+
expect(contrastLevel).toBe(0);
754+
});
755+
});
756+
});
757+
709758
describe('isHighTextContrastEnabled', () => {
710759
describe('Android', () => {
711760
it('should call isHighTextContrastEnabled if available', async () => {
@@ -805,6 +854,7 @@ describe('AccessibilityInfo', () => {
805854
mockNativeAccessibilityManagerIOS;
806855

807856
mockNativeAccessibilityInfoAndroid = {
857+
getContrastLevel: mockGetContrastLevel,
808858
isHighTextContrastEnabled: mockIsHighTextContrastEnabled,
809859
isGrayscaleEnabled: mockIsGrayscaleEnabled,
810860
isInvertColorsEnabled: mockIsInvertColorsEnabled,

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModule.kt‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
package com.facebook.react.modules.accessibilityinfo
99

10+
import android.app.UiModeManager
1011
import android.content.ContentResolver
1112
import android.content.Context
1213
import android.database.ContentObserver
@@ -72,6 +73,11 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
7273
}
7374
}
7475
}
76+
private val uiModeManager: UiModeManager? =
77+
context.applicationContext.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager?
78+
// Listener that is notified when the contrast level changes (API 34+). Created lazily so that
79+
// older versions never load the ContrastChangeListener interface.
80+
private var contrastChangeListener: Any? = null
7581
private val accessibilityManager: AccessibilityManager?
7682
private val touchExplorationStateChangeListener: ReactTouchExplorationStateChangeListener =
7783
ReactTouchExplorationStateChangeListener()
@@ -80,6 +86,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
8086
private val contentResolver: ContentResolver
8187
private var reduceMotionEnabled = false
8288
private var highTextContrastEnabled = false
89+
private var contrastLevel = 0f
8390
private var touchExplorationEnabled = false
8491
private var accessibilityServiceEnabled = false
8592
private var recommendedTimeout = 0
@@ -95,6 +102,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
95102
accessibilityServiceEnabled = accessibilityManager?.isEnabled ?: false
96103
reduceMotionEnabled = isReduceMotionEnabledValue
97104
highTextContrastEnabled = isHighTextContrastEnabledValue
105+
contrastLevel = contrastLevelValue
98106
grayscaleModeEnabled = isGrayscaleEnabledValue
99107
}
100108

@@ -154,6 +162,14 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
154162
) != 0
155163
}
156164

165+
private val contrastLevelValue: Float
166+
get() =
167+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
168+
uiModeManager?.contrast ?: 0f
169+
} else {
170+
0f
171+
}
172+
157173
override fun isReduceMotionEnabled(successCallback: Callback) {
158174
successCallback.invoke(reduceMotionEnabled)
159175
}
@@ -170,6 +186,10 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
170186
successCallback.invoke(highTextContrastEnabled)
171187
}
172188

189+
override fun getContrastLevel(successCallback: Callback) {
190+
successCallback.invoke(contrastLevel.toDouble())
191+
}
192+
173193
override fun isTouchExplorationEnabled(successCallback: Callback) {
174194
successCallback.invoke(touchExplorationEnabled)
175195
}
@@ -208,6 +228,42 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
208228
}
209229
}
210230

231+
private fun updateAndSendContrastLevelChangeEvent() {
232+
val newContrastLevel = contrastLevelValue
233+
if (contrastLevel != newContrastLevel) {
234+
contrastLevel = newContrastLevel
235+
getReactApplicationContextIfActiveOrWarn()
236+
?.emitDeviceEvent(CONTRAST_LEVEL_EVENT_NAME, contrastLevel.toDouble())
237+
}
238+
}
239+
240+
private fun registerContrastChangeListener() {
241+
if (
242+
Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE ||
243+
contrastChangeListener != null
244+
) {
245+
return
246+
}
247+
val manager = uiModeManager ?: return
248+
val listener =
249+
UiModeManager.ContrastChangeListener {
250+
if (reactApplicationContext.hasActiveReactInstance()) {
251+
updateAndSendContrastLevelChangeEvent()
252+
}
253+
}
254+
manager.addContrastChangeListener(reactApplicationContext.mainExecutor, listener)
255+
contrastChangeListener = listener
256+
}
257+
258+
private fun unregisterContrastChangeListener() {
259+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
260+
return
261+
}
262+
val listener = contrastChangeListener as? UiModeManager.ContrastChangeListener ?: return
263+
uiModeManager?.removeContrastChangeListener(listener)
264+
contrastChangeListener = null
265+
}
266+
211267
private fun updateAndSendTouchExplorationChangeEvent(enabled: Boolean) {
212268
if (touchExplorationEnabled != enabled) {
213269
touchExplorationEnabled = enabled
@@ -243,12 +299,15 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
243299
val highTextContrastUri =
244300
Settings.Secure.getUriFor(ACCESSIBILITY_HIGH_TEXT_CONTRAST_ENABLED_CONSTANT)
245301
contentResolver.registerContentObserver(highTextContrastUri, false, highTextContrastObserver)
302+
// Registering does not report the current value, so read it again below.
303+
registerContrastChangeListener()
246304
updateAndSendTouchExplorationChangeEvent(
247305
accessibilityManager?.isTouchExplorationEnabled == true,
248306
)
249307
updateAndSendAccessibilityServiceChangeEvent(accessibilityManager?.isEnabled == true)
250308
updateAndSendReduceMotionChangeEvent()
251309
updateAndSendHighTextContrastChangeEvent()
310+
updateAndSendContrastLevelChangeEvent()
252311
updateAndSendInvertColorsChangeEvent()
253312
updateAndSendGrayscaleModeChangeEvent()
254313
}
@@ -260,6 +319,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
260319
accessibilityManager?.removeAccessibilityStateChangeListener(accessibilityServiceChangeListener)
261320
contentResolver.unregisterContentObserver(animationScaleObserver)
262321
contentResolver.unregisterContentObserver(highTextContrastObserver)
322+
unregisterContrastChangeListener()
263323
}
264324

265325
override fun initialize() {
@@ -274,6 +334,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
274334

275335
override fun invalidate() {
276336
reactApplicationContext.removeLifecycleEventListener(this)
337+
unregisterContrastChangeListener()
277338
super.invalidate()
278339
}
279340

@@ -318,5 +379,6 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
318379
"high_text_contrast_enabled" // constant is marked with @hide
319380
private const val INVERT_COLOR_EVENT_NAME = "invertColorDidChange"
320381
private const val GRAYSCALE_MODE_EVENT_NAME = "grayscaleModeDidChange"
382+
private const val CONTRAST_LEVEL_EVENT_NAME = "contrastLevelDidChange"
321383
}
322384
}

0 commit comments

Comments
 (0)