diff --git a/engine/src/flutter/lib/ui/window.dart b/engine/src/flutter/lib/ui/window.dart index 48a18692cf8..3db2091b15d 100644 --- a/engine/src/flutter/lib/ui/window.dart +++ b/engine/src/flutter/lib/ui/window.dart @@ -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 features = []; @@ -992,6 +1007,9 @@ class AccessibilityFeatures { if (onOffSwitchLabels) { features.add('onOffSwitchLabels'); } + if (announce) { + features.add('announce'); + } return 'AccessibilityFeatures$features'; } diff --git a/engine/src/flutter/lib/ui/window/platform_configuration.h b/engine/src/flutter/lib/ui/window/platform_configuration.h index 29ee8ca4fff..36e5ddac1ff 100644 --- a/engine/src/flutter/lib/ui/window/platform_configuration.h +++ b/engine/src/flutter/lib/ui/window/platform_configuration.h @@ -48,6 +48,7 @@ enum class AccessibilityFeatureFlag : int32_t { kReduceMotion = 1 << 4, kHighContrast = 1 << 5, kOnOffSwitchLabels = 1 << 6, + kNoAnnounce = 1 << 7, }; //-------------------------------------------------------------------------- diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart index 014fffbbd96..c6629275fac 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -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); diff --git a/engine/src/flutter/lib/web_ui/lib/window.dart b/engine/src/flutter/lib/web_ui/lib/window.dart index 6abd944be64..251b719678e 100644 --- a/engine/src/flutter/lib/web_ui/lib/window.dart +++ b/engine/src/flutter/lib/web_ui/lib/window.dart @@ -115,6 +115,7 @@ abstract class AccessibilityFeatures { bool get reduceMotion; bool get highContrast; bool get onOffSwitchLabels; + bool get announce; } enum Brightness { dark, light } diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart index a6671c15d4a..075e9d16ab6 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -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); }); diff --git a/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java index 99dc1cdaa8b..71a0fddd7c1 100644 --- a/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -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; diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java index b2ed50a3b4d..552f217fffe 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java @@ -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 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 diff --git a/engine/src/flutter/shell/platform/embedder/embedder.h b/engine/src/flutter/shell/platform/embedder/embedder.h index 07f48914d22..a0978917561 100644 --- a/engine/src/flutter/shell/platform/embedder/embedder.h +++ b/engine/src/flutter/shell/platform/embedder/embedder.h @@ -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. diff --git a/packages/flutter_test/lib/src/window.dart b/packages/flutter_test/lib/src/window.dart index 562cd1bcbe0..f2eb6f8a046 100644 --- a/packages/flutter_test/lib/src/window.dart +++ b/packages/flutter_test/lib/src/window.dart @@ -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, ); }