Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/jest-preset/jest/mocks/AccessibilityInfo.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const AccessibilityInfo = {
isAccessibilityServiceEnabled: jest.fn(() =>
Promise.resolve(false),
) as JestMockFn<$FlowFixMe, $FlowFixMe>,
getContrastLevel: jest.fn(() => Promise.resolve(0)) as JestMockFn<
$FlowFixMe,
$FlowFixMe,
>,
isBoldTextEnabled: jest.fn(() => Promise.resolve(false)) as JestMockFn<
$FlowFixMe,
$FlowFixMe,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import NativeAccessibilityManagerIOS from './NativeAccessibilityManager';
// Events that are only supported on Android.
type AccessibilityEventDefinitionsAndroid = {
accessibilityServiceChanged: [boolean],
contrastLevelChanged: [number],
highTextContrastChanged: [boolean],
};

Expand Down Expand Up @@ -52,6 +53,7 @@ const EventNames: Map<keyof AccessibilityEventDefinitions, string> =
['change', 'touchExplorationDidChange'],
['reduceMotionChanged', 'reduceMotionDidChange'],
['highTextContrastChanged', 'highTextContrastDidChange'],
['contrastLevelChanged', 'contrastLevelDidChange'],
['screenReaderChanged', 'touchExplorationDidChange'],
['accessibilityServiceChanged', 'accessibilityServiceDidChange'],
['invertColorsChanged', 'invertColorDidChange'],
Expand Down Expand Up @@ -223,6 +225,35 @@ const AccessibilityInfo = {
}
},

/**
* Query the contrast level chosen in the system settings.
*
* Returns a promise which resolves to a number from -1 to 1, where 0 is
* the standard contrast and higher values mean more contrast. This is the
* Android equivalent of "Increase Contrast" on iOS
* (see `isDarkerSystemColorsEnabled`). Requires Android 14 (API 34) or
* later; earlier versions resolve to 0.
*
* @platform android
*/
getContrastLevel(): Promise<number> {
if (Platform.OS === 'android') {
return new Promise((resolve, reject) => {
if (NativeAccessibilityInfoAndroid?.getContrastLevel != null) {
NativeAccessibilityInfoAndroid.getContrastLevel(resolve);
} else {
reject(
new Error(
'NativeAccessibilityInfoAndroid.getContrastLevel is not available',
),
);
}
});
} else {
return Promise.resolve(0);
}
},

/**
* Query whether darker system colors is currently enabled.
*
Expand Down Expand Up @@ -409,6 +440,9 @@ const AccessibilityInfo = {
*
* These events are only supported on Android:
*
* - `contrastLevelChanged`: Android-only event. Fires when the contrast level
* changes. The argument to the event handler is a number from -1 to 1
* (see `getContrastLevel`). Requires Android 14 (API 34) or later.
* - `highTextContrastChanged`: Android-only event. Fires when the state of the high text contrast
* toggle changes. The argument to the event handler is a boolean. The boolean is `true` when
* high text contrast is enabled and `false` otherwise.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

import typeof RCTDeviceEventEmitterType from '../../../EventEmitter/RCTDeviceEventEmitter';
import typeof AccessibilityInfoType from '../AccessibilityInfo';

const invariant = require('invariant');

jest.unmock('../AccessibilityInfo');

type LoadedModules = {
AccessibilityInfo: AccessibilityInfoType,
RCTDeviceEventEmitter: RCTDeviceEventEmitterType,
};

// `AccessibilityInfo` picks its native event names when it is first loaded,
// so each platform needs a fresh module registry.
function loadForPlatform(os: 'android' | 'ios'): LoadedModules {
let modules: ?LoadedModules = null;
jest.isolateModules(() => {
const Platform = require('../../../Utilities/Platform').default;
/* $FlowFixMe[incompatible-type] */
Platform.OS = os;
modules = {
AccessibilityInfo: require('../AccessibilityInfo').default,
RCTDeviceEventEmitter:
require('../../../EventEmitter/RCTDeviceEventEmitter').default,
};
});
invariant(modules != null, 'Expected modules to load');
return modules;
}

describe('AccessibilityInfo contrastLevelChanged', () => {
it('delivers contrastLevelDidChange as contrastLevelChanged on Android', () => {
const {AccessibilityInfo, RCTDeviceEventEmitter} =
loadForPlatform('android');
const handler = jest.fn();

const subscription = AccessibilityInfo.addEventListener(
'contrastLevelChanged',
handler,
);
RCTDeviceEventEmitter.emit('contrastLevelDidChange', 0.5);
RCTDeviceEventEmitter.emit('contrastLevelDidChange', -0.25);
subscription.remove();
RCTDeviceEventEmitter.emit('contrastLevelDidChange', 1);

expect(handler).toHaveBeenCalledTimes(2);
expect(handler).toHaveBeenNthCalledWith(1, 0.5);
expect(handler).toHaveBeenNthCalledWith(2, -0.25);
});

it('does not subscribe on iOS', () => {
const {AccessibilityInfo, RCTDeviceEventEmitter} = loadForPlatform('ios');
const handler = jest.fn();

const subscription = AccessibilityInfo.addEventListener(
'contrastLevelChanged',
handler,
);
RCTDeviceEventEmitter.emit('contrastLevelDidChange', 0.5);
subscription.remove();

expect(handler).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ let mockNativeAccessibilityManagerIOS: {

const ANDROID_RECOMMENDED_TIMEOUT = 6000;
const mockIsHighTextContrastEnabled = jest.fn(onSuccess => onSuccess(true));
const mockGetContrastLevel = jest.fn(onSuccess => onSuccess(0.5));
const mockIsGrayscaleEnabled = jest.fn(onSuccess => onSuccess(true));
const mockIsInvertColorsEnabled = jest.fn(onSuccess => onSuccess(true));
const mockIsReduceMotionEnabled = jest.fn(onSuccess => onSuccess(true));
Expand All @@ -114,6 +115,10 @@ const mockGetRecommendedTimeoutMillis = jest.fn((originalTimeout, onSuccess) =>
onSuccess(ANDROID_RECOMMENDED_TIMEOUT),
);
let mockNativeAccessibilityInfoAndroid: {
getContrastLevel: JestMockFn<
[onSuccess: (contrastLevel: number) => void],
void,
> | null,
isHighTextContrastEnabled: JestMockFn<
[onSuccess: (isHighTextContrastEnabled: boolean) => void],
void,
Expand Down Expand Up @@ -143,6 +148,7 @@ let mockNativeAccessibilityInfoAndroid: {
void,
> | null,
} = {
getContrastLevel: mockGetContrastLevel,
isHighTextContrastEnabled: mockIsHighTextContrastEnabled,
isGrayscaleEnabled: mockIsGrayscaleEnabled,
isInvertColorsEnabled: mockIsInvertColorsEnabled,
Expand Down Expand Up @@ -706,6 +712,49 @@ describe('AccessibilityInfo', () => {
});
});

describe('getContrastLevel', () => {
describe('Android', () => {
it('should call getContrastLevel if available', async () => {
/* $FlowFixMe[incompatible-type] */
Platform.OS = 'android';

const contrastLevel = await AccessibilityInfo.getContrastLevel();

expect(mockGetContrastLevel).toHaveBeenCalled();
expect(contrastLevel).toBe(0.5);
});

it('should throw error if getContrastLevel is not available', async () => {
/* $FlowFixMe[incompatible-type] */
Platform.OS = 'android';

mockNativeAccessibilityInfoAndroid.getContrastLevel = null;

const result: unknown =
await AccessibilityInfo.getContrastLevel().catch(e => e);

invariant(
result instanceof Error,
'Expected getContrastLevel to reject',
);
expect(result.message).toEqual(
'NativeAccessibilityInfoAndroid.getContrastLevel is not available',
);
});
});

describe('iOS', () => {
it('should return 0', async () => {
/* $FlowFixMe[incompatible-type] */
Platform.OS = 'ios';

const contrastLevel = await AccessibilityInfo.getContrastLevel();

expect(contrastLevel).toBe(0);
});
});
});

describe('isHighTextContrastEnabled', () => {
describe('Android', () => {
it('should call isHighTextContrastEnabled if available', async () => {
Expand Down Expand Up @@ -805,6 +854,7 @@ describe('AccessibilityInfo', () => {
mockNativeAccessibilityManagerIOS;

mockNativeAccessibilityInfoAndroid = {
getContrastLevel: mockGetContrastLevel,
isHighTextContrastEnabled: mockIsHighTextContrastEnabled,
isGrayscaleEnabled: mockIsGrayscaleEnabled,
isInvertColorsEnabled: mockIsInvertColorsEnabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

package com.facebook.react.modules.accessibilityinfo

import android.app.UiModeManager
import android.content.ContentResolver
import android.content.Context
import android.database.ContentObserver
Expand Down Expand Up @@ -72,6 +73,11 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
}
}
}
private val uiModeManager: UiModeManager? =
context.applicationContext.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager?
// Listener that is notified when the contrast level changes (API 34+). Created lazily so that
// older versions never load the ContrastChangeListener interface.
private var contrastChangeListener: Any? = null
private val accessibilityManager: AccessibilityManager?
private val touchExplorationStateChangeListener: ReactTouchExplorationStateChangeListener =
ReactTouchExplorationStateChangeListener()
Expand All @@ -80,6 +86,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
private val contentResolver: ContentResolver
private var reduceMotionEnabled = false
private var highTextContrastEnabled = false
private var contrastLevel = 0f
private var touchExplorationEnabled = false
private var accessibilityServiceEnabled = false
private var recommendedTimeout = 0
Expand All @@ -95,6 +102,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
accessibilityServiceEnabled = accessibilityManager?.isEnabled ?: false
reduceMotionEnabled = isReduceMotionEnabledValue
highTextContrastEnabled = isHighTextContrastEnabledValue
contrastLevel = contrastLevelValue
grayscaleModeEnabled = isGrayscaleEnabledValue
}

Expand Down Expand Up @@ -154,6 +162,14 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
) != 0
}

private val contrastLevelValue: Float
get() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
uiModeManager?.contrast ?: 0f
} else {
0f
}

override fun isReduceMotionEnabled(successCallback: Callback) {
successCallback.invoke(reduceMotionEnabled)
}
Expand All @@ -170,6 +186,10 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
successCallback.invoke(highTextContrastEnabled)
}

override fun getContrastLevel(successCallback: Callback) {
successCallback.invoke(contrastLevel.toDouble())
}

override fun isTouchExplorationEnabled(successCallback: Callback) {
successCallback.invoke(touchExplorationEnabled)
}
Expand Down Expand Up @@ -208,6 +228,42 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
}
}

private fun updateAndSendContrastLevelChangeEvent() {
val newContrastLevel = contrastLevelValue
if (contrastLevel != newContrastLevel) {
contrastLevel = newContrastLevel
getReactApplicationContextIfActiveOrWarn()
?.emitDeviceEvent(CONTRAST_LEVEL_EVENT_NAME, contrastLevel.toDouble())
}
}

private fun registerContrastChangeListener() {
if (
Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE ||
contrastChangeListener != null
) {
return
}
val manager = uiModeManager ?: return
val listener =
UiModeManager.ContrastChangeListener {
if (reactApplicationContext.hasActiveReactInstance()) {
updateAndSendContrastLevelChangeEvent()
}
}
manager.addContrastChangeListener(reactApplicationContext.mainExecutor, listener)
contrastChangeListener = listener
}

private fun unregisterContrastChangeListener() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
return
}
val listener = contrastChangeListener as? UiModeManager.ContrastChangeListener ?: return
uiModeManager?.removeContrastChangeListener(listener)
contrastChangeListener = null
}

private fun updateAndSendTouchExplorationChangeEvent(enabled: Boolean) {
if (touchExplorationEnabled != enabled) {
touchExplorationEnabled = enabled
Expand Down Expand Up @@ -243,12 +299,15 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
val highTextContrastUri =
Settings.Secure.getUriFor(ACCESSIBILITY_HIGH_TEXT_CONTRAST_ENABLED_CONSTANT)
contentResolver.registerContentObserver(highTextContrastUri, false, highTextContrastObserver)
// Registering does not report the current value, so read it again below.
registerContrastChangeListener()
updateAndSendTouchExplorationChangeEvent(
accessibilityManager?.isTouchExplorationEnabled == true,
)
updateAndSendAccessibilityServiceChangeEvent(accessibilityManager?.isEnabled == true)
updateAndSendReduceMotionChangeEvent()
updateAndSendHighTextContrastChangeEvent()
updateAndSendContrastLevelChangeEvent()
updateAndSendInvertColorsChangeEvent()
updateAndSendGrayscaleModeChangeEvent()
}
Expand All @@ -260,6 +319,7 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
accessibilityManager?.removeAccessibilityStateChangeListener(accessibilityServiceChangeListener)
contentResolver.unregisterContentObserver(animationScaleObserver)
contentResolver.unregisterContentObserver(highTextContrastObserver)
unregisterContrastChangeListener()
}

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

override fun invalidate() {
reactApplicationContext.removeLifecycleEventListener(this)
unregisterContrastChangeListener()
super.invalidate()
}

Expand Down Expand Up @@ -318,5 +379,6 @@ internal class AccessibilityInfoModule(context: ReactApplicationContext) :
"high_text_contrast_enabled" // constant is marked with @hide
private const val INVERT_COLOR_EVENT_NAME = "invertColorDidChange"
private const val GRAYSCALE_MODE_EVENT_NAME = "grayscaleModeDidChange"
private const val CONTRAST_LEVEL_EVENT_NAME = "contrastLevelDidChange"
}
}
Loading
Loading