Add AccessibilityFeatures to media query and fix Snackbar a11y behavior (#19336)

diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart
index 1364be9..a045147 100644
--- a/packages/flutter/lib/src/material/scaffold.dart
+++ b/packages/flutter/lib/src/material/scaffold.dart
@@ -1162,12 +1162,18 @@
     assert(reason != null);
     if (_snackBars.isEmpty || _snackBarController.status == AnimationStatus.dismissed)
       return;
+    final MediaQueryData mediaQuery = MediaQuery.of(context);
     final Completer<SnackBarClosedReason> completer = _snackBars.first._completer;
-    _snackBarController.reverse().then<void>((Null _) {
-      assert(mounted);
-      if (!completer.isCompleted)
-        completer.complete(reason);
-    });
+    if (mediaQuery.accessibleNavigation) {
+      _snackBarController.value = 0.0;
+      completer.complete(reason);
+    } else {
+      _snackBarController.reverse().then<void>((Null _) {
+        assert(mounted);
+        if (!completer.isCompleted)
+          completer.complete(reason);
+      });
+    }
     _snackBarTimer?.cancel();
     _snackBarTimer = null;
   }
@@ -1480,12 +1486,19 @@
     if (_snackBars.isNotEmpty) {
       final ModalRoute<dynamic> route = ModalRoute.of(context);
       if (route == null || route.isCurrent) {
-        if (_snackBarController.isCompleted && _snackBarTimer == null)
-          _snackBarTimer = new Timer(_snackBars.first._widget.duration, () {
+        if (_snackBarController.isCompleted && _snackBarTimer == null) {
+          final SnackBar snackBar = _snackBars.first._widget;
+          _snackBarTimer = new Timer.periodic(snackBar.duration, (Timer timer) {
             assert(_snackBarController.status == AnimationStatus.forward ||
-              _snackBarController.status == AnimationStatus.completed);
+                   _snackBarController.status == AnimationStatus.completed);
+            // Look up MediaQuery again in case the setting changed.
+            final MediaQueryData mediaQuery = MediaQuery.of(context);
+            if (mediaQuery.accessibleNavigation && snackBar.action != null)
+              return;
+            timer.cancel();
             hideCurrentSnackBar(reason: SnackBarClosedReason.timeout);
           });
+        }
       } else {
         _snackBarTimer?.cancel();
         _snackBarTimer = null;
diff --git a/packages/flutter/lib/src/material/snack_bar.dart b/packages/flutter/lib/src/material/snack_bar.dart
index 5a8563c..406a883 100644
--- a/packages/flutter/lib/src/material/snack_bar.dart
+++ b/packages/flutter/lib/src/material/snack_bar.dart
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+import 'package:flutter/rendering.dart';
 import 'package:flutter/widgets.dart';
 
 import 'button_theme.dart';
@@ -46,6 +47,9 @@
   /// The snack bar was closed after the user tapped a [SnackBarAction].
   action,
 
+  /// The snack bar was closed through a [SemanticAction.dismiss].
+  dismiss,
+
   /// The snack bar was closed by a user's swipe.
   swipe,
 
@@ -126,6 +130,9 @@
 ///
 /// To control how long the [SnackBar] remains visible, specify a [duration].
 ///
+/// A SnackBar with an action will not time out when TalkBack or VoiceOver are
+/// enabled. This is controlled by [AccessibilityFeatures.accessibleNavigation].
+///
 /// See also:
 ///
 ///  * [Scaffold.of], to obtain the current [ScaffoldState], which manages the
@@ -183,6 +190,7 @@
 
   @override
   Widget build(BuildContext context) {
+    final MediaQueryData mediaQueryData = MediaQuery.of(context);
     assert(animation != null);
     final ThemeData theme = Theme.of(context);
     final ThemeData darkTheme = new ThemeData(
@@ -213,8 +221,41 @@
     }
     final CurvedAnimation heightAnimation = new CurvedAnimation(parent: animation, curve: _snackBarHeightCurve);
     final CurvedAnimation fadeAnimation = new CurvedAnimation(parent: animation, curve: _snackBarFadeCurve, reverseCurve: const Threshold(0.0));
+    Widget snackbar = new SafeArea(
+      top: false,
+      child: new Row(
+        children: children,
+        crossAxisAlignment: CrossAxisAlignment.center,
+      ),
+    );
+    snackbar = new Semantics(
+      container: true,
+      liveRegion: true,
+      onDismiss: () {
+        Scaffold.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.dismiss);
+      },
+      child: new Dismissible(
+        key: const Key('dismissible'),
+        direction: DismissDirection.down,
+        resizeDuration: null,
+        onDismissed: (DismissDirection direction) {
+          Scaffold.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.swipe);
+        },
+        child: new Material(
+          elevation: 6.0,
+          color: backgroundColor ?? _kSnackBackground,
+          child: new Theme(
+            data: darkTheme,
+            child: mediaQueryData.accessibleNavigation ? snackbar : new FadeTransition(
+              opacity: fadeAnimation,
+              child: snackbar,
+            ),
+          ),
+        ),
+      ),
+    );
     return new ClipRect(
-      child: new AnimatedBuilder(
+      child: mediaQueryData.accessibleNavigation ? snackbar : new AnimatedBuilder(
         animation: heightAnimation,
         builder: (BuildContext context, Widget child) {
           return new Align(
@@ -223,38 +264,7 @@
             child: child,
           );
         },
-        child: new Semantics(
-          liveRegion: true,
-          container: true,
-          onDismiss: () {
-            Scaffold.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.swipe);
-          },
-          child: new Dismissible(
-            key: const Key('dismissible'),
-            direction: DismissDirection.down,
-            resizeDuration: null,
-            onDismissed: (DismissDirection direction) {
-              Scaffold.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.swipe);
-            },
-            child: new Material(
-              elevation: 6.0,
-              color: backgroundColor ?? _kSnackBackground,
-              child: new Theme(
-                data: darkTheme,
-                child: new FadeTransition(
-                  opacity: fadeAnimation,
-                  child: new SafeArea(
-                    top: false,
-                    child: new Row(
-                      children: children,
-                      crossAxisAlignment: CrossAxisAlignment.center,
-                    ),
-                  ),
-                ),
-              ),
-            ),
-          ),
-        ),
+        child: snackbar,
       ),
     );
   }
diff --git a/packages/flutter/lib/src/rendering/binding.dart b/packages/flutter/lib/src/rendering/binding.dart
index 4dcc304..88b1c2e 100644
--- a/packages/flutter/lib/src/rendering/binding.dart
+++ b/packages/flutter/lib/src/rendering/binding.dart
@@ -153,6 +153,12 @@
   @protected
   void handleTextScaleFactorChanged() { }
 
+  /// Called when the platform accessibility features change.
+  ///
+  /// See [Window.onAccessibilityFeaturesChanged].
+  @protected
+  void handleAccessibilityFeaturesChanged() {}
+
   /// Returns a [ViewConfiguration] configured for the [RenderView] based on the
   /// current environment.
   ///
diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart
index e9edccb..12ea869 100644
--- a/packages/flutter/lib/src/widgets/app.dart
+++ b/packages/flutter/lib/src/widgets/app.dart
@@ -527,6 +527,16 @@
     yield DefaultWidgetsLocalizations.delegate;
   }
 
+  // ACCESSIBILITY
+
+  @override
+  void didChangeAccessibilityFeatures() {
+    setState(() {
+      // The properties of ui.window have changed. We use them in our build
+      // function, so we need setState(), but we don't cache anything locally.
+    });
+  }
+
 
   // METRICS
 
diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart
index e789ea4..db4bd07 100644
--- a/packages/flutter/lib/src/widgets/binding.dart
+++ b/packages/flutter/lib/src/widgets/binding.dart
@@ -4,7 +4,7 @@
 
 import 'dart:async';
 import 'dart:developer' as developer;
-import 'dart:ui' show AppLifecycleState, Locale;
+import 'dart:ui' show AppLifecycleState, Locale, AccessibilityFeatures;
 import 'dart:ui' as ui show window;
 
 import 'package:flutter/foundation.dart';
@@ -229,6 +229,12 @@
   /// This method exposes the `memoryPressure` notification from
   /// [SystemChannels.system].
   void didHaveMemoryPressure() { }
+
+  /// Called when the system changes the set of currently active accessibility
+  /// features.
+  ///
+  /// This method exposes notifications from [Window.onAccessibilityFeaturesChanged].
+  void didChangeAccessibilityFeatures() {}
 }
 
 /// The glue between the widgets layer and the Flutter engine.
@@ -243,6 +249,7 @@
     _instance = this;
     buildOwner.onBuildScheduled = _handleBuildScheduled;
     ui.window.onLocaleChanged = handleLocaleChanged;
+    ui.window.onAccessibilityFeaturesChanged = handleAccessibilityFeaturesChanged;
     SystemChannels.navigation.setMethodCallHandler(_handleNavigationInvocation);
     SystemChannels.system.setMessageHandler(_handleSystemMessage);
   }
@@ -368,6 +375,13 @@
       observer.didChangeTextScaleFactor();
   }
 
+  @override
+  void handleAccessibilityFeaturesChanged() {
+    super.handleAccessibilityFeaturesChanged();
+    for (WidgetsBindingObserver observer in _observers)
+      observer.didChangeAccessibilityFeatures();
+  }
+
   /// Called when the system locale changes.
   ///
   /// Calls [dispatchLocaleChanged] to notify the binding observers.
@@ -392,6 +406,19 @@
       observer.didChangeLocale(locale);
   }
 
+  /// Notify all the observers that the active set of [AccessibilityFeatures]
+  /// has changed (using [WidgetsBindingObserver.didChangeAccessibilityFeatures]),
+  /// giving them the `features` argument.
+  ///
+  /// This is called by [handleAccessibilityFeaturesChanged] when the
+  /// [Window.onAccessibilityFeaturesChanged] notification is recieved.
+  @protected
+  @mustCallSuper
+  void dispatchAccessibilityFeaturesChanged() {
+    for (WidgetsBindingObserver observer in _observers)
+      observer.didChangeAccessibilityFeatures();
+  }
+
   /// Called when the system pops the current route.
   ///
   /// This first notifies the binding observers (using
diff --git a/packages/flutter/lib/src/widgets/media_query.dart b/packages/flutter/lib/src/widgets/media_query.dart
index a9440e0..d8fb50c 100644
--- a/packages/flutter/lib/src/widgets/media_query.dart
+++ b/packages/flutter/lib/src/widgets/media_query.dart
@@ -43,6 +43,9 @@
     this.padding = EdgeInsets.zero,
     this.viewInsets = EdgeInsets.zero,
     this.alwaysUse24HourFormat = false,
+    this.accessibleNavigation = false,
+    this.invertColors = false,
+    this.disableAnimations = false,
   });
 
   /// Creates data for a media query based on the given window.
@@ -57,6 +60,9 @@
       textScaleFactor = window.textScaleFactor,
       padding = new EdgeInsets.fromWindowPadding(window.padding, window.devicePixelRatio),
       viewInsets = new EdgeInsets.fromWindowPadding(window.viewInsets, window.devicePixelRatio),
+      accessibleNavigation = window.accessibilityFeatures.accessibleNavigation,
+      invertColors = window.accessibilityFeatures.accessibleNavigation,
+      disableAnimations = window.accessibilityFeatures.disableAnimations,
       alwaysUse24HourFormat = window.alwaysUse24HourFormat;
 
   /// The size of the media in logical pixel (e.g, the size of the screen).
@@ -120,6 +126,33 @@
   ///   formatting.
   final bool alwaysUse24HourFormat;
 
+  /// Whether the user is using an accessibility service like TalkBack or
+  /// VoiceOver to interact with the application.
+  ///
+  /// When this setting is true, features such as timeouts should be disabled or
+  /// have minimum durations increased.
+  ///
+  /// See also:
+  ///
+  ///  * [Window.AccessibilityFeatures], where the setting originates.
+  final bool accessibleNavigation;
+
+  /// Whether the device is inverting the colors of the platform.
+  ///
+  /// This flag is currently only updated on iOS devices.
+  ///
+  /// See also:
+  ///
+  ///  * [Window.AccessibilityFeatures], where the setting originates.
+  final bool invertColors;
+
+  /// Whether the platform is requesting that animations be disabled or reduced
+  /// as much as possible.
+  ///
+  ///  * [Window.AccessibilityFeatures], where the setting originates.
+  ///
+  final bool disableAnimations;
+
   /// The orientation of the media (e.g., whether the device is in landscape or portrait mode).
   Orientation get orientation {
     return size.width > size.height ? Orientation.landscape : Orientation.portrait;
@@ -134,6 +167,9 @@
     EdgeInsets padding,
     EdgeInsets viewInsets,
     bool alwaysUse24HourFormat,
+    bool disableAnimations,
+    bool invertColors,
+    bool accessibleNavigation,
   }) {
     return new MediaQueryData(
       size: size ?? this.size,
@@ -142,6 +178,9 @@
       padding: padding ?? this.padding,
       viewInsets: viewInsets ?? this.viewInsets,
       alwaysUse24HourFormat: alwaysUse24HourFormat ?? this.alwaysUse24HourFormat,
+      invertColors: invertColors ?? this.invertColors,
+      disableAnimations: disableAnimations ?? this.disableAnimations,
+      accessibleNavigation: accessibleNavigation ?? this.accessibleNavigation,
     );
   }
 
@@ -179,6 +218,9 @@
       ),
       viewInsets: viewInsets,
       alwaysUse24HourFormat: alwaysUse24HourFormat,
+      disableAnimations: disableAnimations,
+      invertColors: invertColors,
+      accessibleNavigation: accessibleNavigation,
     );
   }
 
@@ -214,6 +256,9 @@
         bottom: removeBottom ? 0.0 : null,
       ),
       alwaysUse24HourFormat: alwaysUse24HourFormat,
+      disableAnimations: disableAnimations,
+      invertColors: invertColors,
+      accessibleNavigation: accessibleNavigation,
     );
   }
 
@@ -227,11 +272,26 @@
         && typedOther.textScaleFactor == textScaleFactor
         && typedOther.padding == padding
         && typedOther.viewInsets == viewInsets
-        && typedOther.alwaysUse24HourFormat == alwaysUse24HourFormat;
+        && typedOther.alwaysUse24HourFormat == alwaysUse24HourFormat
+        && typedOther.disableAnimations == disableAnimations
+        && typedOther.invertColors == invertColors
+        && typedOther.accessibleNavigation == accessibleNavigation;
   }
 
   @override
-  int get hashCode => hashValues(size, devicePixelRatio, textScaleFactor, padding, viewInsets, alwaysUse24HourFormat);
+  int get hashCode {
+    return hashValues(
+      size,
+      devicePixelRatio,
+      textScaleFactor,
+      padding,
+      viewInsets,
+      alwaysUse24HourFormat,
+      disableAnimations,
+      invertColors,
+      accessibleNavigation,
+    );
+  }
 
   @override
   String toString() {
@@ -241,7 +301,10 @@
              'textScaleFactor: $textScaleFactor, '
              'padding: $padding, '
              'viewInsets: $viewInsets, '
-             'alwaysUse24HourFormat: $alwaysUse24HourFormat'
+             'alwaysUse24HourFormat: $alwaysUse24HourFormat, '
+             'accessibleNavigation: $accessibleNavigation'
+             'disableAnimations: $disableAnimations'
+             'invertColors: $invertColors'
            ')';
   }
 }
diff --git a/packages/flutter/test/material/snack_bar_test.dart b/packages/flutter/test/material/snack_bar_test.dart
index 7cae85f..0a5a984 100644
--- a/packages/flutter/test/material/snack_bar_test.dart
+++ b/packages/flutter/test/material/snack_bar_test.dart
@@ -482,6 +482,90 @@
     expect(closedReason, equals(SnackBarClosedReason.timeout));
   });
 
+  testWidgets('accessible navigation behavior with action', (WidgetTester tester) async {
+      final GlobalKey<ScaffoldState> scaffoldKey = new GlobalKey<ScaffoldState>();
+
+      await tester.pumpWidget(new MaterialApp(
+        home: new MediaQuery(
+          data: const MediaQueryData(accessibleNavigation: true),
+          child: Scaffold(
+            key: scaffoldKey,
+            body: new Builder(
+              builder: (BuildContext context) {
+                return new GestureDetector(
+                  onTap: () {
+                    Scaffold.of(context).showSnackBar(new SnackBar(
+                      content: const Text('snack'),
+                      duration: const Duration(seconds: 1),
+                      action: new SnackBarAction(
+                        label: 'ACTION',
+                        onPressed: () {}
+                      ),
+                    ));
+                  },
+                  child: const Text('X')
+                );
+              },
+            )
+          )
+        )
+      ));
+      await tester.tap(find.text('X'));
+      await tester.pump();
+      // Find action immediately
+      expect(find.text('ACTION'), findsOneWidget);
+      // Snackbar doesn't close
+      await tester.pump(const Duration(seconds: 10));
+      expect(find.text('ACTION'), findsOneWidget);
+      await tester.tap(find.text('ACTION'));
+      await tester.pump();
+      // Snackbar closes immediately
+      expect(find.text('ACTION'), findsNothing);
+  });
+
+  testWidgets('contributes dismiss semantics', (WidgetTester tester) async {
+    final SemanticsHandle handle = tester.ensureSemantics();
+    final GlobalKey<ScaffoldState> scaffoldKey = new GlobalKey<ScaffoldState>();
+
+    await tester.pumpWidget(new MaterialApp(
+        home: new MediaQuery(
+            data: const MediaQueryData(accessibleNavigation: true),
+            child: Scaffold(
+                key: scaffoldKey,
+                body: new Builder(
+                  builder: (BuildContext context) {
+                    return new GestureDetector(
+                        onTap: () {
+                          Scaffold.of(context).showSnackBar(new SnackBar(
+                            content: const Text('snack'),
+                            duration: const Duration(seconds: 1),
+                            action: new SnackBarAction(
+                                label: 'ACTION',
+                                onPressed: () {}
+                            ),
+                          ));
+                        },
+                        child: const Text('X')
+                    );
+                  },
+                )
+            )
+        )
+    ));
+    await tester.tap(find.text('X'));
+    await tester.pumpAndSettle();
+
+    expect(tester.getSemanticsData(find.text('snack')), matchesSemanticsData(
+      isLiveRegion: true,
+      hasDismissAction: true,
+      hasScrollDownAction: true,
+      hasScrollUpAction: true,
+      label: 'snack',
+      textDirection: TextDirection.ltr,
+    ));
+    handle.dispose();
+  });
+
   testWidgets('SnackBar default display duration test', (WidgetTester tester) async {
     const String helloSnackBar = 'Hello SnackBar';
     const Key tapTarget = Key('tap-target');
@@ -530,4 +614,52 @@
     expect(find.text(helloSnackBar), findsNothing);
   });
 
+  testWidgets('SnackBar handles updates to accessibleNavigation', (WidgetTester tester) async {
+    Future<void> boilerplate({bool accessibleNavigation}) {
+      return tester.pumpWidget(new MaterialApp(
+          home: new MediaQuery(
+              data: new MediaQueryData(accessibleNavigation: accessibleNavigation),
+              child: new Scaffold(
+                  body: new Builder(
+                      builder: (BuildContext context) {
+                        return new GestureDetector(
+                            onTap: () {
+                              Scaffold.of(context).showSnackBar(new SnackBar(
+                                  content: const Text('test'),
+                                  action: new SnackBarAction(label: 'foo', onPressed: () {}),
+                              ));
+                            },
+                            behavior: HitTestBehavior.opaque,
+                            child: const Text('X'),
+                        );
+                      }
+                  )
+              )
+          )
+      ));
+    }
+
+    await boilerplate(accessibleNavigation: false);
+    expect(find.text('test'), findsNothing);
+    await tester.tap(find.text('X'));
+    await tester.pump(); // schedule animation
+    expect(find.text('test'), findsOneWidget);
+    await tester.pump(); // begin animation
+    await tester.pump(const Duration(milliseconds: 4750)); // 4.75s
+    expect(find.text('test'), findsOneWidget);
+
+    // Enabled accessible navigation
+    await boilerplate(accessibleNavigation: true);
+
+    await tester.pump(const Duration(milliseconds: 4000)); // 8.75s
+    await tester.pump();
+    expect(find.text('test'), findsOneWidget);
+
+    // disable accessible navigation
+    await boilerplate(accessibleNavigation: false);
+    await tester.pumpAndSettle(const Duration(milliseconds: 5750));
+
+    expect(find.text('test'), findsNothing);
+  });
+
 }
diff --git a/packages/flutter/test/widgets/media_query_test.dart b/packages/flutter/test/widgets/media_query_test.dart
index 7e76ad9..d7e7928 100644
--- a/packages/flutter/test/widgets/media_query_test.dart
+++ b/packages/flutter/test/widgets/media_query_test.dart
@@ -43,6 +43,9 @@
     expect(data, hasOneLineDescription);
     expect(data.hashCode, equals(data.copyWith().hashCode));
     expect(data.size, equals(ui.window.physicalSize / ui.window.devicePixelRatio));
+    expect(data.accessibleNavigation, false);
+    expect(data.invertColors, false);
+    expect(data.disableAnimations, false);
   });
 
   testWidgets('MediaQueryData.copyWith defaults to source', (WidgetTester tester) async {
@@ -54,6 +57,9 @@
     expect(copied.padding, data.padding);
     expect(copied.viewInsets, data.viewInsets);
     expect(copied.alwaysUse24HourFormat, data.alwaysUse24HourFormat);
+    expect(copied.accessibleNavigation, data.accessibleNavigation);
+    expect(copied.invertColors, data.invertColors);
+    expect(copied.disableAnimations, data.disableAnimations);
   });
 
   testWidgets('MediaQuery.copyWith copies specified values', (WidgetTester tester) async {
@@ -65,6 +71,9 @@
       padding: const EdgeInsets.all(9.10938),
       viewInsets: const EdgeInsets.all(1.67262),
       alwaysUse24HourFormat: true,
+      accessibleNavigation: true,
+      invertColors: true,
+      disableAnimations: true,
     );
     expect(copied.size, const Size(3.14, 2.72));
     expect(copied.devicePixelRatio, 1.41);
@@ -72,6 +81,9 @@
     expect(copied.padding, const EdgeInsets.all(9.10938));
     expect(copied.viewInsets, const EdgeInsets.all(1.67262));
     expect(copied.alwaysUse24HourFormat, true);
+    expect(copied.accessibleNavigation, true);
+    expect(copied.invertColors, true);
+    expect(copied.disableAnimations, true);
   });
 
  testWidgets('MediaQuery.removePadding removes specified padding', (WidgetTester tester) async {
@@ -91,6 +103,9 @@
          padding: padding,
          viewInsets: viewInsets,
          alwaysUse24HourFormat: true,
+         accessibleNavigation: true,
+         invertColors: true,
+         disableAnimations: true,
        ),
        child: new Builder(
          builder: (BuildContext context) {
@@ -118,6 +133,9 @@
    expect(unpadded.padding, EdgeInsets.zero);
    expect(unpadded.viewInsets, viewInsets);
    expect(unpadded.alwaysUse24HourFormat, true);
+   expect(unpadded.accessibleNavigation, true);
+   expect(unpadded.invertColors, true);
+   expect(unpadded.disableAnimations, true);
   });
 
   testWidgets('MediaQuery.removeViewInsets removes specified viewInsets', (WidgetTester tester) async {
@@ -137,6 +155,9 @@
           padding: padding,
           viewInsets: viewInsets,
           alwaysUse24HourFormat: true,
+          accessibleNavigation: true,
+          invertColors: true,
+          disableAnimations: true,
         ),
         child: new Builder(
           builder: (BuildContext context) {
@@ -164,6 +185,9 @@
     expect(unpadded.padding, padding);
     expect(unpadded.viewInsets, EdgeInsets.zero);
     expect(unpadded.alwaysUse24HourFormat, true);
+    expect(unpadded.accessibleNavigation, true);
+    expect(unpadded.invertColors, true);
+    expect(unpadded.disableAnimations, true);
   });
 
  testWidgets('MediaQuery.textScaleFactorOf', (WidgetTester tester) async {