Add announce support to the engine (#169685)

Partly of https://github.com/flutter/flutter/issues/165510

⤵️ Child PR: https://github.com/flutter/flutter/pull/168992

Partly re-lands https://github.com/flutter/flutter/pull/165531
The PR was originally reverted due to an issue with an internal Google
test. I split re-land PR into two separate ones so that we can
individually revert in case it fails again.

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
ash2moon 2025-06-02 17:26:11 -07:00 committed by GitHub
parent 5c4edc244f
commit 694600a9f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 120 additions and 11 deletions

View File

@ -931,6 +931,7 @@ class AccessibilityFeatures {
static const int _kReduceMotionIndex = 1 << 4;
static const int _kHighContrastIndex = 1 << 5;
static const int _kOnOffSwitchLabelsIndex = 1 << 6;
static const int _kNoAnnounceIndex = 1 << 7;
// A bitfield which represents each enabled feature.
final int _index;
@ -968,6 +969,20 @@ class AccessibilityFeatures {
/// Only supported on iOS.
bool get onOffSwitchLabels => _kOnOffSwitchLabelsIndex & _index != 0;
/// Whether accessibility announcements (like [SemanticsService.announce])
/// are supported on the current platform.
///
/// Returns `false` on platforms where announcements are deprecated or
/// unsupported by the underlying platform.
///
/// Returns `true` on platforms where such announcements are
/// generally supported without discouragement. (iOS, web etc)
///
/// Use this flag to conditionally avoid making announcements on Android.
// This index check is inverted (== 0 vs != 0); far more platforms support
// "announce" than discourage it.
bool get announce => _kNoAnnounceIndex & _index == 0;
@override
String toString() {
final List<String> features = <String>[];
@ -992,6 +1007,9 @@ class AccessibilityFeatures {
if (onOffSwitchLabels) {
features.add('onOffSwitchLabels');
}
if (announce) {
features.add('announce');
}
return 'AccessibilityFeatures$features';
}

View File

@ -48,6 +48,7 @@ enum class AccessibilityFeatureFlag : int32_t {
kReduceMotion = 1 << 4,
kHighContrast = 1 << 5,
kOnOffSwitchLabels = 1 << 6,
kNoAnnounce = 1 << 7,
};
//--------------------------------------------------------------------------

View File

@ -54,6 +54,7 @@ class EngineAccessibilityFeatures implements ui.AccessibilityFeatures {
static const int _kReduceMotionIndex = 1 << 4;
static const int _kHighContrastIndex = 1 << 5;
static const int _kOnOffSwitchLabelsIndex = 1 << 6;
static const int _kNoAnnounceIndex = 1 << 7;
// A bitfield which represents each enabled feature.
final int _index;
@ -72,6 +73,10 @@ class EngineAccessibilityFeatures implements ui.AccessibilityFeatures {
bool get highContrast => _kHighContrastIndex & _index != 0;
@override
bool get onOffSwitchLabels => _kOnOffSwitchLabelsIndex & _index != 0;
// This index check is inverted (== 0 vs != 0); far more platforms support
// "announce" than discourage it.
@override
bool get announce => _kNoAnnounceIndex & _index == 0;
@override
String toString() {
@ -97,6 +102,9 @@ class EngineAccessibilityFeatures implements ui.AccessibilityFeatures {
if (onOffSwitchLabels) {
features.add('onOffSwitchLabels');
}
if (announce) {
features.add('announce');
}
return 'AccessibilityFeatures$features';
}
@ -119,6 +127,7 @@ class EngineAccessibilityFeatures implements ui.AccessibilityFeatures {
bool? reduceMotion,
bool? highContrast,
bool? onOffSwitchLabels,
bool? announce,
}) {
final EngineAccessibilityFeaturesBuilder builder = EngineAccessibilityFeaturesBuilder(0);
@ -129,6 +138,7 @@ class EngineAccessibilityFeatures implements ui.AccessibilityFeatures {
builder.reduceMotion = reduceMotion ?? this.reduceMotion;
builder.highContrast = highContrast ?? this.highContrast;
builder.onOffSwitchLabels = onOffSwitchLabels ?? this.onOffSwitchLabels;
builder.announce = announce ?? this.announce;
return builder.build();
}
@ -146,6 +156,9 @@ class EngineAccessibilityFeaturesBuilder {
bool get reduceMotion => EngineAccessibilityFeatures._kReduceMotionIndex & _index != 0;
bool get highContrast => EngineAccessibilityFeatures._kHighContrastIndex & _index != 0;
bool get onOffSwitchLabels => EngineAccessibilityFeatures._kOnOffSwitchLabelsIndex & _index != 0;
// This index check is inverted (== 0 vs != 0); far more platforms support
// "announce" than discourage it.
bool get announce => EngineAccessibilityFeatures._kNoAnnounceIndex & _index == 0;
set accessibleNavigation(bool value) {
const int accessibleNavigation = EngineAccessibilityFeatures._kAccessibleNavigation;
@ -182,6 +195,12 @@ class EngineAccessibilityFeaturesBuilder {
_index = value ? _index | onOffSwitchLabels : _index & ~onOffSwitchLabels;
}
set announce(bool value) {
const int noAnnounce = EngineAccessibilityFeatures._kNoAnnounceIndex;
// Since we are using noAnnounce for the embedder, we need to flip the value.
_index = !value ? _index | noAnnounce : _index & ~noAnnounce;
}
/// Creates and returns an instance of EngineAccessibilityFeatures based on the value of _index
EngineAccessibilityFeatures build() {
return EngineAccessibilityFeatures(_index);

View File

@ -115,6 +115,7 @@ abstract class AccessibilityFeatures {
bool get reduceMotion;
bool get highContrast;
bool get onOffSwitchLabels;
bool get announce;
}
enum Brightness { dark, light }

View File

@ -299,6 +299,14 @@ void _testEngineAccessibilityBuilder() {
expect(features.onOffSwitchLabels, isTrue);
});
test('announce', () {
// By default this starts off true, see EngineAccessibilityFeatures.announce
expect(features.announce, isTrue);
builder.announce = false;
features = builder.build();
expect(features.announce, isFalse);
});
test('reduce motion', () {
expect(features.reduceMotion, isFalse);
builder.reduceMotion = true;
@ -391,7 +399,10 @@ void _testEngineSemanticsOwner() {
});
test('accessibilityFeatures copyWith function works', () {
const EngineAccessibilityFeatures original = EngineAccessibilityFeatures(0);
// Announce is an inverted check, see EngineAccessibilityFeatures.announce.
// Therefore, we need to ensure that the original copy starts with false (1 << 7).
const EngineAccessibilityFeatures original = EngineAccessibilityFeatures(0 | 1 << 7);
EngineAccessibilityFeatures copy = original.copyWith(accessibleNavigation: true);
expect(copy.accessibleNavigation, true);
expect(copy.boldText, false);
@ -399,6 +410,7 @@ void _testEngineSemanticsOwner() {
expect(copy.highContrast, false);
expect(copy.invertColors, false);
expect(copy.onOffSwitchLabels, false);
expect(copy.announce, false);
expect(copy.reduceMotion, false);
copy = original.copyWith(boldText: true);
@ -417,6 +429,7 @@ void _testEngineSemanticsOwner() {
expect(copy.highContrast, false);
expect(copy.invertColors, false);
expect(copy.onOffSwitchLabels, false);
expect(copy.announce, false);
expect(copy.reduceMotion, false);
copy = original.copyWith(highContrast: true);
@ -426,6 +439,7 @@ void _testEngineSemanticsOwner() {
expect(copy.highContrast, true);
expect(copy.invertColors, false);
expect(copy.onOffSwitchLabels, false);
expect(copy.announce, false);
expect(copy.reduceMotion, false);
copy = original.copyWith(invertColors: true);
@ -435,6 +449,7 @@ void _testEngineSemanticsOwner() {
expect(copy.highContrast, false);
expect(copy.invertColors, true);
expect(copy.onOffSwitchLabels, false);
expect(copy.announce, false);
expect(copy.reduceMotion, false);
copy = original.copyWith(onOffSwitchLabels: true);
@ -446,6 +461,16 @@ void _testEngineSemanticsOwner() {
expect(copy.onOffSwitchLabels, true);
expect(copy.reduceMotion, false);
copy = original.copyWith(announce: true);
expect(copy.accessibleNavigation, false);
expect(copy.boldText, false);
expect(copy.disableAnimations, false);
expect(copy.highContrast, false);
expect(copy.invertColors, false);
expect(copy.onOffSwitchLabels, false);
expect(copy.announce, true);
expect(copy.reduceMotion, false);
copy = original.copyWith(reduceMotion: true);
expect(copy.accessibleNavigation, false);
expect(copy.boldText, false);
@ -453,6 +478,7 @@ void _testEngineSemanticsOwner() {
expect(copy.highContrast, false);
expect(copy.invertColors, false);
expect(copy.onOffSwitchLabels, false);
expect(copy.announce, false);
expect(copy.reduceMotion, true);
});

View File

@ -490,6 +490,7 @@ public class AccessibilityBridge extends AccessibilityNodeProvider {
this.accessibilityManager.addTouchExplorationStateChangeListener(
touchExplorationStateChangeListener);
accessibilityFeatureFlags |= AccessibilityFeature.NO_ANNOUNCE.value;
// Tell Flutter whether animations should initially be enabled or disabled. Then register a
// listener to be notified of changes in the future.
animationScaleObserver.onChange(false);
@ -2174,7 +2175,8 @@ public class AccessibilityBridge extends AccessibilityNodeProvider {
BOLD_TEXT(1 << 3), // NOT SUPPORTED
REDUCE_MOTION(1 << 4), // NOT SUPPORTED
HIGH_CONTRAST(1 << 5), // NOT SUPPORTED
ON_OFF_SWITCH_LABELS(1 << 6); // NOT SUPPORTED
ON_OFF_SWITCH_LABELS(1 << 6), // NOT SUPPORTED
NO_ANNOUNCE(1 << 7);
final int value;

View File

@ -66,6 +66,11 @@ import org.robolectric.annotation.Config;
@RunWith(AndroidJUnit4.class)
public class AccessibilityBridgeTest {
private static final int ACCESSIBILITY_FEATURE_NAVIGATION = 1 << 0;
private static final int ACCESSIBILITY_FEATURE_DISABLE_ANIMATIONS = 1 << 2;
private static final int ACCESSIBILITY_FEATURE_BOLD_TEXT = 1 << 3;
private static final int ACCESSIBILITY_FEATURE_NO_ANNOUNCE = 1 << 7;
@Test
public void itDescribesNonTextFieldsWithAContentDescription() {
AccessibilityBridge accessibilityBridge = setUpBridge();
@ -135,6 +140,26 @@ public class AccessibilityBridgeTest {
assertEquals(position, outBoundsInScreen.top);
}
@Test
public void itSetsNoAnnounceAccessibleFlagByDefault() {
AccessibilityChannel mockChannel = mock(AccessibilityChannel.class);
AccessibilityViewEmbedder mockViewEmbedder = mock(AccessibilityViewEmbedder.class);
AccessibilityManager mockManager = mock(AccessibilityManager.class);
View mockRootView = mock(View.class);
Context context = mock(Context.class);
when(mockRootView.getContext()).thenReturn(context);
when(context.getPackageName()).thenReturn("test");
when(mockManager.isTouchExplorationEnabled()).thenReturn(false);
setUpBridge(
/*rootAccessibilityView=*/ mockRootView,
/*accessibilityChannel=*/ mockChannel,
/*accessibilityManager=*/ mockManager,
/*contentResolver=*/ null,
/*accessibilityViewEmbedder=*/ mockViewEmbedder,
/*platformViewsAccessibilityDelegate=*/ null);
verify(mockChannel).setAccessibilityFeatures(ACCESSIBILITY_FEATURE_NO_ANNOUNCE);
}
@Test
public void itSetsAccessibleNavigation() {
AccessibilityChannel mockChannel = mock(AccessibilityChannel.class);
@ -158,18 +183,20 @@ public class AccessibilityBridgeTest {
verify(mockManager).addTouchExplorationStateChangeListener(listenerCaptor.capture());
assertEquals(accessibilityBridge.getAccessibleNavigation(), false);
verify(mockChannel).setAccessibilityFeatures(0);
verify(mockChannel).setAccessibilityFeatures(ACCESSIBILITY_FEATURE_NO_ANNOUNCE);
reset(mockChannel);
// Simulate assistive technology accessing accessibility tree.
accessibilityBridge.createAccessibilityNodeInfo(0);
verify(mockChannel).setAccessibilityFeatures(1);
verify(mockChannel)
.setAccessibilityFeatures(
ACCESSIBILITY_FEATURE_NAVIGATION | ACCESSIBILITY_FEATURE_NO_ANNOUNCE);
assertEquals(accessibilityBridge.getAccessibleNavigation(), true);
// Simulate turning off TalkBack.
reset(mockChannel);
listenerCaptor.getValue().onTouchExplorationStateChanged(false);
verify(mockChannel).setAccessibilityFeatures(0);
verify(mockChannel).setAccessibilityFeatures(ACCESSIBILITY_FEATURE_NO_ANNOUNCE);
assertEquals(accessibilityBridge.getAccessibleNavigation(), false);
}
@ -1157,7 +1184,9 @@ public class AccessibilityBridgeTest {
/*accessibilityViewEmbedder=*/ mockViewEmbedder,
/*platformViewsAccessibilityDelegate=*/ null);
verify(mockChannel).setAccessibilityFeatures(1 << 3);
verify(mockChannel)
.setAccessibilityFeatures(
ACCESSIBILITY_FEATURE_BOLD_TEXT | ACCESSIBILITY_FEATURE_NO_ANNOUNCE);
reset(mockChannel);
// Now verify that clearing the BOLD_TEXT flag doesn't touch any of the other flags.
@ -1179,7 +1208,9 @@ public class AccessibilityBridgeTest {
// constructor, verify that the latest argument is correct
ArgumentCaptor<Integer> captor = ArgumentCaptor.forClass(Integer.class);
verify(mockChannel, atLeastOnce()).setAccessibilityFeatures(captor.capture());
assertEquals(1 << 2 /* DISABLE_ANIMATION */, captor.getValue().intValue());
assertEquals(
ACCESSIBILITY_FEATURE_DISABLE_ANIMATIONS | ACCESSIBILITY_FEATURE_NO_ANNOUNCE,
captor.getValue().intValue());
// Set back to default
Settings.Global.putFloat(null, "transition_animation_scale", 1.0f);
@ -1874,19 +1905,21 @@ public class AccessibilityBridgeTest {
ContentObserver observer = observerCaptor.getValue();
// Initial state
verify(mockChannel).setAccessibilityFeatures(0);
verify(mockChannel).setAccessibilityFeatures(ACCESSIBILITY_FEATURE_NO_ANNOUNCE);
reset(mockChannel);
// Animations are disabled
Settings.Global.putFloat(mockContentResolver, "transition_animation_scale", 0.0f);
observer.onChange(false);
verify(mockChannel).setAccessibilityFeatures(1 << 2);
verify(mockChannel)
.setAccessibilityFeatures(
ACCESSIBILITY_FEATURE_DISABLE_ANIMATIONS | ACCESSIBILITY_FEATURE_NO_ANNOUNCE);
reset(mockChannel);
// Animations are enabled
Settings.Global.putFloat(mockContentResolver, "transition_animation_scale", 1.0f);
observer.onChange(false);
verify(mockChannel).setAccessibilityFeatures(0);
verify(mockChannel).setAccessibilityFeatures(ACCESSIBILITY_FEATURE_NO_ANNOUNCE);
}
@Test

View File

@ -105,6 +105,8 @@ typedef enum {
kFlutterAccessibilityFeatureHighContrast = 1 << 5,
/// Request to show on/off labels inside switches.
kFlutterAccessibilityFeatureOnOffSwitchLabels = 1 << 6,
/// Indicate the platform does not support announcements.
kFlutterAccessibilityFeatureNoAnnounce = 1 << 7,
} FlutterAccessibilityFeature;
/// The set of possible actions that can be conveyed to a semantics node.

View File

@ -32,6 +32,7 @@ class FakeAccessibilityFeatures implements AccessibilityFeatures {
this.reduceMotion = false,
this.highContrast = false,
this.onOffSwitchLabels = false,
this.announce = false,
});
/// An instance of [AccessibilityFeatures] where all the features are enabled.
@ -43,6 +44,7 @@ class FakeAccessibilityFeatures implements AccessibilityFeatures {
reduceMotion: true,
highContrast: true,
onOffSwitchLabels: true,
announce: true,
);
@override
@ -66,6 +68,9 @@ class FakeAccessibilityFeatures implements AccessibilityFeatures {
@override
final bool onOffSwitchLabels;
@override
final bool announce;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) {
@ -78,7 +83,8 @@ class FakeAccessibilityFeatures implements AccessibilityFeatures {
other.boldText == boldText &&
other.reduceMotion == reduceMotion &&
other.highContrast == highContrast &&
other.onOffSwitchLabels == onOffSwitchLabels;
other.onOffSwitchLabels == onOffSwitchLabels &&
other.announce == announce;
}
@override
@ -91,6 +97,7 @@ class FakeAccessibilityFeatures implements AccessibilityFeatures {
reduceMotion,
highContrast,
onOffSwitchLabels,
announce,
);
}