Add android semantics integration test to device lab (#20971)

diff --git a/dev/devicelab/bin/tasks/android_semantics_integration_test.dart b/dev/devicelab/bin/tasks/android_semantics_integration_test.dart
new file mode 100644
index 0000000..fa9b10e
--- /dev/null
+++ b/dev/devicelab/bin/tasks/android_semantics_integration_test.dart
@@ -0,0 +1,12 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter_devicelab/framework/adb.dart';
+import 'package:flutter_devicelab/framework/framework.dart';
+import 'package:flutter_devicelab/tasks/integration_tests.dart';
+
+void main() async {
+  deviceOperatingSystem = DeviceOperatingSystem.android;
+  await task(createAndroidSemanticsIntegrationTest());
+}
diff --git a/dev/devicelab/lib/tasks/integration_tests.dart b/dev/devicelab/lib/tasks/integration_tests.dart
index bb5a41c..ea991bd 100644
--- a/dev/devicelab/lib/tasks/integration_tests.dart
+++ b/dev/devicelab/lib/tasks/integration_tests.dart
@@ -52,6 +52,13 @@
   );
 }
 
+TaskFunction createAndroidSemanticsIntegrationTest() {
+  return new DriverTest(
+    '${flutterDirectory.path}/dev/integration_tests/android_semantics_testing',
+    'lib/main.dart',
+  );
+}
+
 class DriverTest {
 
   DriverTest(
diff --git a/dev/devicelab/manifest.yaml b/dev/devicelab/manifest.yaml
index 2bae9d7..8ffc13f 100644
--- a/dev/devicelab/manifest.yaml
+++ b/dev/devicelab/manifest.yaml
@@ -136,6 +136,13 @@
     stage: devicelab
     required_agent_capabilities: ["mac/android"]
 
+  android_semantics_integration_test:
+    description: >
+      Tests that the Android accessibility bridge produces correct semantics.
+    stage: devicelab
+    required_agent_capabilities: ["mac/android"]
+    flaky: true
+
   run_release_test:
     description: >
       Checks that `flutter run --release` does not crash.
diff --git a/dev/integration_tests/android_semantics_testing/android/app/build.gradle b/dev/integration_tests/android_semantics_testing/android/app/build.gradle
new file mode 100644
index 0000000..3228d77
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/android/app/build.gradle
@@ -0,0 +1,57 @@
+def localProperties = new Properties()
+def localPropertiesFile = rootProject.file('local.properties')
+if (localPropertiesFile.exists()) {
+    localPropertiesFile.withInputStream { stream ->
+        localProperties.load(stream)
+    }
+}
+
+def flutterRoot = localProperties.getProperty('flutter.sdk')
+if (flutterRoot == null) {
+    throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
+}
+
+apply plugin: 'com.android.application'
+apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
+
+android {
+    compileSdkVersion 27
+
+    lintOptions {
+        disable 'InvalidPackage'
+    }
+
+    defaultConfig {
+        minSdkVersion 16
+        targetSdkVersion 27
+        versionCode 1
+        versionName "0.0.1"
+        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
+    }
+
+    buildTypes {
+        release {
+            // TODO: Add your own signing config for the release build.
+            // Signing with the debug keys for now, so `flutter run --release` works.
+            signingConfig signingConfigs.debug
+        }
+    }
+
+    aaptOptions {
+        // TODO(goderbauer): remove when https://github.com/flutter/flutter/issues/8986 is resolved.
+        if(System.getenv("FLUTTER_CI_WIN")) {
+            println "AAPT cruncher disabled when running on Win CI."
+            cruncherEnabled false
+        }
+    }
+}
+
+flutter {
+    source '../..'
+}
+
+dependencies {
+    testImplementation 'junit:junit:4.12'
+    androidTestImplementation 'com.android.support.test:runner:1.0.2'
+    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
+}
diff --git a/dev/integration_tests/android_semantics_testing/android/app/src/main/AndroidManifest.xml b/dev/integration_tests/android_semantics_testing/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..6703641
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,28 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.yourcompany.platforminteraction">
+
+    <!-- The INTERNET permission is required for development. Specifically,
+         flutter needs it to communicate with the running application
+         to allow setting breakpoints, to provide hot reload, etc.
+    -->
+    <uses-permission android:name="android.permission.INTERNET"/>
+
+    <!-- io.flutter.app.FlutterApplication is an android.app.Application that
+         calls FlutterMain.startInitialization(this); in its onCreate method.
+         In most cases you can leave this as-is, but you if you want to provide
+         additional functionality it is fine to subclass or reimplement
+         FlutterApplication and put your custom class here. -->
+    <application android:name="io.flutter.app.FlutterApplication" android:label="Platform Interaction" android:icon="@mipmap/ic_launcher">
+        <activity android:name="com.yourcompany.platforminteraction.MainActivity"
+                  android:launchMode="singleTop"
+                  android:theme="@android:style/Theme.Black.NoTitleBar"
+                  android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density"
+                  android:hardwareAccelerated="true"
+                  android:windowSoftInputMode="adjustResize">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN"/>
+                <category android:name="android.intent.category.LAUNCHER"/>
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/dev/integration_tests/android_semantics_testing/android/app/src/main/java/com/yourcompany/platforminteraction/MainActivity.java b/dev/integration_tests/android_semantics_testing/android/app/src/main/java/com/yourcompany/platforminteraction/MainActivity.java
new file mode 100644
index 0000000..22733fe
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/android/app/src/main/java/com/yourcompany/platforminteraction/MainActivity.java
@@ -0,0 +1,111 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package com.yourcompany.platforminteraction;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.List;
+import java.util.ArrayList;
+import java.lang.StringBuilder;
+
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.util.DisplayMetrics;
+import android.view.WindowManager;
+import android.content.Context;
+
+import io.flutter.app.FlutterActivity;
+import io.flutter.plugin.common.MethodCall;
+import io.flutter.plugin.common.MethodChannel;
+import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
+import io.flutter.plugins.GeneratedPluginRegistrant;
+import io.flutter.view.FlutterView;
+
+import android.view.accessibility.AccessibilityManager;
+import android.view.accessibility.AccessibilityNodeProvider;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+public class MainActivity extends FlutterActivity {
+  @Override
+  protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      GeneratedPluginRegistrant.registerWith(this);
+      new MethodChannel(getFlutterView(), "semantics").setMethodCallHandler(new SemanticsTesterMethodHandler());
+  }
+
+  class SemanticsTesterMethodHandler implements MethodCallHandler {
+    Float mScreenDensity = 1.0f;
+
+    @Override
+    public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
+        FlutterView flutterView = getFlutterView();
+        AccessibilityNodeProvider provider = flutterView.getAccessibilityNodeProvider();
+        DisplayMetrics displayMetrics = new DisplayMetrics();
+        WindowManager wm = (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
+        wm.getDefaultDisplay().getMetrics(displayMetrics);
+        mScreenDensity = displayMetrics.density;
+        if (methodCall.method.equals("getSemanticsNode")) {
+            Map<String, Object> data = methodCall.arguments();
+            @SuppressWarnings("unchecked")
+            Integer id = (Integer) data.get("id");
+            if (id == null) {
+                result.error("No ID provided", "", null);
+                return;
+            }
+            if (provider == null) {
+                result.error("Semantics not enabled", "", null);
+                return;
+            }
+            AccessibilityNodeInfo node = provider.createAccessibilityNodeInfo(id);
+            result.success(convertSemantics(node, id));
+            return;
+        }
+        result.notImplemented();
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> convertSemantics(AccessibilityNodeInfo node, int id) {
+        if (node == null)
+            return null;
+        Map<String, Object> result = new HashMap<>();
+        Map<String, Object> flags = new HashMap<>();
+        Map<String, Object> rect = new HashMap<>();
+        result.put("id", id);
+        result.put("text", node.getText());
+        result.put("contentDescription", node.getContentDescription());
+        flags.put("isChecked", node.isChecked());
+        flags.put("isCheckable", node.isCheckable());
+        flags.put("isDismissable", node.isDismissable());
+        flags.put("isEditable", node.isEditable());
+        flags.put("isEnabled", node.isEnabled());
+        flags.put("isFocusable", node.isFocusable());
+        flags.put("isFocused", node.isFocused());
+        flags.put("isPassword", node.isPassword());
+        flags.put("isLongClickable", node.isLongClickable());
+        result.put("flags", flags);
+        Rect nodeRect = new Rect();
+        node.getBoundsInScreen(nodeRect);
+        rect.put("left", nodeRect.left / mScreenDensity);
+        rect.put("top", nodeRect.top/ mScreenDensity);
+        rect.put("right", nodeRect.right / mScreenDensity);
+        rect.put("bottom", nodeRect.bottom/ mScreenDensity);
+        rect.put("width", nodeRect.width());
+        rect.put("height", nodeRect.height());
+        result.put("rect", rect);
+        result.put("className", node.getClassName());
+        result.put("contentDescription", node.getContentDescription());
+        result.put("liveRegion", node.getLiveRegion());
+        List<AccessibilityNodeInfo.AccessibilityAction> actionList = node.getActionList();
+        if (actionList.size() > 0) {
+            ArrayList<Integer> actions = new ArrayList<>();
+            for (AccessibilityNodeInfo.AccessibilityAction action : actionList) {
+                actions.add(action.getId());
+            }
+            result.put("actions", actions);
+        }
+        return result;
+    }
+  }
+}
diff --git a/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Binary files differ
diff --git a/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Binary files differ
diff --git a/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Binary files differ
diff --git a/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Binary files differ
diff --git a/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Binary files differ
diff --git a/dev/integration_tests/android_semantics_testing/android/build.gradle b/dev/integration_tests/android_semantics_testing/android/build.gradle
new file mode 100644
index 0000000..d4225c7
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/android/build.gradle
@@ -0,0 +1,29 @@
+buildscript {
+    repositories {
+        google()
+        jcenter()
+    }
+
+    dependencies {
+        classpath 'com.android.tools.build:gradle:3.1.2'
+    }
+}
+
+allprojects {
+    repositories {
+        google()
+        jcenter()
+    }
+}
+
+rootProject.buildDir = '../build'
+subprojects {
+    project.buildDir = "${rootProject.buildDir}/${project.name}"
+}
+subprojects {
+    project.evaluationDependsOn(':app')
+}
+
+task clean(type: Delete) {
+    delete rootProject.buildDir
+}
diff --git a/dev/integration_tests/android_semantics_testing/android/gradle.properties b/dev/integration_tests/android_semantics_testing/android/gradle.properties
new file mode 100644
index 0000000..8bd86f6
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/android/gradle.properties
@@ -0,0 +1 @@
+org.gradle.jvmargs=-Xmx1536M
diff --git a/dev/integration_tests/android_semantics_testing/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/android_semantics_testing/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100755
index 0000000..9372d0f
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Fri Jun 23 08:50:38 CEST 2017
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-all.zip
diff --git a/dev/integration_tests/android_semantics_testing/android/settings.gradle b/dev/integration_tests/android_semantics_testing/android/settings.gradle
new file mode 100644
index 0000000..115da6c
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/android/settings.gradle
@@ -0,0 +1,15 @@
+include ':app'
+
+def flutterProjectRoot = rootProject.projectDir.parentFile.toPath()
+
+def plugins = new Properties()
+def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins')
+if (pluginsFile.exists()) {
+    pluginsFile.withInputStream { stream -> plugins.load(stream) }
+}
+
+plugins.each { name, path ->
+    def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile()
+    include ":$name"
+    project(":$name").projectDir = pluginDirectory
+}
diff --git a/dev/integration_tests/android_semantics_testing/lib/main.dart b/dev/integration_tests/android_semantics_testing/lib/main.dart
new file mode 100644
index 0000000..1a08aa4
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/lib/main.dart
@@ -0,0 +1,63 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_driver/driver_extension.dart';
+
+import 'src/tests/controls_page.dart';
+
+void main() {
+  enableFlutterDriverExtension(handler: dataHandler);
+  runApp(const TestApp());
+}
+
+const MethodChannel kSemanticsChannel = MethodChannel('semantics');
+
+Future<String> dataHandler(String message) async {
+  if (message.contains('getSemanticsNode')) {
+    final int id = int.tryParse(message.split('#')[1]) ?? 0;
+    final dynamic result = await kSemanticsChannel.invokeMethod('getSemanticsNode', <String, dynamic>{
+      'id': id,
+    });
+    return json.encode(result);
+  }
+  throw new UnimplementedError();
+}
+
+const List<String> routes = <String>[
+  selectionControlsRoute,
+];
+
+class TestApp extends StatelessWidget {
+  const TestApp();
+
+  @override
+  Widget build(BuildContext context) {
+    return new MaterialApp(
+      routes: <String, WidgetBuilder>{
+        selectionControlsRoute: (BuildContext context) => new SelectionControlsPage(),
+      },
+      home: new Builder(
+        builder: (BuildContext context) {
+          return new Scaffold(
+            body: new Column(
+              children: routes.map((String value) {
+                return new MaterialButton(
+                  child: new Text(value),
+                  onPressed: () {
+                    Navigator.of(context).pushNamed(value);
+                  },
+                );
+              }).toList(),
+            ),
+          );
+        }
+      ),
+    );
+  }
+}
diff --git a/dev/integration_tests/android_semantics_testing/lib/src/common.dart b/dev/integration_tests/android_semantics_testing/lib/src/common.dart
index a088cda..e993f82 100644
--- a/dev/integration_tests/android_semantics_testing/lib/src/common.dart
+++ b/dev/integration_tests/android_semantics_testing/lib/src/common.dart
@@ -37,6 +37,7 @@
   ///         "isLongClickable": bool,
   ///       },
   ///       "text": String,
+  ///       "contentDescription": String,
   ///       "className": String,
   ///       "id": int,
   ///       "rect": {
@@ -64,6 +65,16 @@
   /// the Flutter [SemanticsNode].
   String get text => _values['text'];
 
+  /// The contentDescription of the semantics node.
+  ///
+  /// This field is used for the Switch, Radio, and Checkbox widgets
+  /// instead of [text]. If the text property is used for these, TalkBack
+  /// will not read out the "checked" or "not checked" label by default.
+  ///
+  /// This is produced by combining the value, label, and hint fields from
+  /// the Flutter [SemanticsNode].
+  String get contentDescription => _values['contentDescription'];
+
   /// The className of the semantics node.
   ///
   /// Certain kinds of Flutter semantics are mapped to Android classes to
diff --git a/dev/integration_tests/android_semantics_testing/lib/src/constants.dart b/dev/integration_tests/android_semantics_testing/lib/src/constants.dart
index fb641e5..b1fe948 100644
--- a/dev/integration_tests/android_semantics_testing/lib/src/constants.dart
+++ b/dev/integration_tests/android_semantics_testing/lib/src/constants.dart
@@ -21,6 +21,9 @@
 
   /// The class name used for read only text fields.
   static const String textView = 'android.widget.TextView';
+
+  /// The class name used for toggle switches.
+  static const String toggleSwitch = 'android.widget.Switch';
 }
 
 /// Action constants which correspond to `AccessibilityAction` in Android.
diff --git a/dev/integration_tests/android_semantics_testing/lib/src/flutter_test_alternative.dart b/dev/integration_tests/android_semantics_testing/lib/src/flutter_test_alternative.dart
new file mode 100644
index 0000000..79d7310
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/lib/src/flutter_test_alternative.dart
@@ -0,0 +1,14 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Defines a 'package:test' shim.
+// TODO(ianh): Remove this file once https://github.com/dart-lang/matcher/issues/98 is fixed
+
+import 'package:test/test.dart' hide TypeMatcher, isInstanceOf;
+import 'package:test/test.dart' as test_package show TypeMatcher;
+
+export 'package:test/test.dart' hide TypeMatcher, isInstanceOf;
+
+/// A matcher that compares the type of the actual value to the type argument T.
+Matcher isInstanceOf<T>() => new test_package.TypeMatcher<T>(); // ignore: prefer_const_constructors, https://github.com/dart-lang/sdk/issues/32544
diff --git a/dev/integration_tests/android_semantics_testing/lib/src/matcher.dart b/dev/integration_tests/android_semantics_testing/lib/src/matcher.dart
index 896866c..a3ce3be 100644
--- a/dev/integration_tests/android_semantics_testing/lib/src/matcher.dart
+++ b/dev/integration_tests/android_semantics_testing/lib/src/matcher.dart
@@ -2,10 +2,9 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'package:flutter_test/flutter_test.dart';
-
 import 'common.dart';
 import 'constants.dart';
+import 'flutter_test_alternative.dart';
 
 /// Matches an [AndroidSemanticsNode].
 ///
@@ -16,6 +15,7 @@
 /// the Flutter framework.
 Matcher hasAndroidSemantics({
   String text,
+  String contentDescription,
   String className,
   int id,
   Rect rect,
@@ -33,6 +33,7 @@
 }) {
   return new _AndroidSemanticsMatcher(
     text: text,
+    contentDescription: contentDescription,
     className: className,
     rect: rect,
     size: size,
@@ -52,6 +53,7 @@
 class _AndroidSemanticsMatcher extends Matcher {
   _AndroidSemanticsMatcher({
     this.text,
+    this.contentDescription,
     this.className,
     this.id,
     this.actions,
@@ -69,6 +71,7 @@
 
   final String text;
   final String className;
+  final String contentDescription;
   final int id;
   final List<AndroidSemanticsAction> actions;
   final Rect rect;
@@ -87,6 +90,8 @@
     description.add('AndroidSemanticsNode');
     if (text != null)
       description.add(' with text: $text');
+    if (contentDescription != null)
+      description.add( 'with contentDescription $contentDescription');
     if (className != null)
       description.add(' with className: $className');
     if (id != null)
@@ -118,6 +123,8 @@
   bool matches(covariant AndroidSemanticsNode item, Map<Object, Object> matchState) {
     if (text != null && text != item.text)
       return _failWithMessage('Expected text: $text', matchState);
+    if (contentDescription != null && contentDescription != item.contentDescription)
+      return _failWithMessage('Expected contentDescription: $contentDescription', matchState);
     if (className != null && className != item.className)
       return _failWithMessage('Expected className: $className', matchState);
     if (id != null && id != item.id)
diff --git a/dev/integration_tests/android_semantics_testing/lib/src/tests/controls_constants.dart b/dev/integration_tests/android_semantics_testing/lib/src/tests/controls_constants.dart
new file mode 100644
index 0000000..ab8e827
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/lib/src/tests/controls_constants.dart
@@ -0,0 +1,30 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/// The name of the route containing the test suite.
+const String selectionControlsRoute = 'controls';
+
+/// The string supplied to the [ValueKey] for the enabled checkbox.
+const String checkboxKeyValue = 'SelectionControls#Checkbox1';
+
+/// The string supplied to the [ValueKey] for the disabled checkbox.
+const String disabledCheckboxKeyValue = 'SelectionControls#Checkbox2';
+
+/// The string supplied to the [ValueKey] for the radio button with value 1.
+const String radio1KeyValue = 'SelectionControls#Radio1';
+
+/// The string supplied to the [ValueKey] for the radio button with value 2.
+const String radio2KeyValue = 'SelectionControls#Radio2';
+
+/// The string supplied to the [ValueKey] for the radio button with value 3.
+const String radio3KeyValue = 'SelectionControls#Radio3';
+
+/// The string supplied to the [ValueKey] for the switch.
+const String switchKeyValue = 'SelectionControls#Switch1';
+
+/// The string supplied to the [ValueKey] for the labeled switch.
+const String labeledSwitchKeyValue = 'SelectionControls#Switch2';
+
+/// The label of the labeled switch.
+const String switchLabel = 'Label';
diff --git a/dev/integration_tests/android_semantics_testing/lib/src/tests/controls_page.dart b/dev/integration_tests/android_semantics_testing/lib/src/tests/controls_page.dart
new file mode 100644
index 0000000..c830344
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/lib/src/tests/controls_page.dart
@@ -0,0 +1,103 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:flutter/widgets.dart';
+
+import 'controls_constants.dart';
+export 'controls_constants.dart';
+
+/// A test page with a checkbox, three radio buttons, and a switch.
+class SelectionControlsPage extends StatefulWidget {
+  @override
+  State<StatefulWidget> createState() => new _SelectionControlsPageState();
+}
+
+class _SelectionControlsPageState extends State<SelectionControlsPage> {
+  static const ValueKey<String> checkbox1Key = ValueKey<String>(checkboxKeyValue);
+  static const ValueKey<String> checkbox2Key = ValueKey<String>(disabledCheckboxKeyValue);
+  static const ValueKey<String> radio1Key = ValueKey<String>(radio1KeyValue);
+  static const ValueKey<String> radio2Key = ValueKey<String>(radio2KeyValue);
+  static const ValueKey<String> radio3Key = ValueKey<String>(radio3KeyValue);
+  static const ValueKey<String> switchKey = ValueKey<String>(switchKeyValue);
+  static const ValueKey<String> labeledSwitchKey = ValueKey<String>(labeledSwitchKeyValue);
+  bool _isChecked = false;
+  bool _isOn = false;
+  bool _isLabeledOn = false;
+  int _radio = 0;
+
+  void _updateCheckbox(bool newValue) {
+    setState(() {
+      _isChecked = newValue;
+    });
+  }
+
+  void _updateRadio(int newValue) {
+    setState(() {
+      _radio = newValue;
+    });
+  }
+
+  void _updateSwitch(bool newValue) {
+    setState(() {
+      _isOn = newValue;
+    });
+  }
+
+  void _updateLabeledSwitch(bool newValue) {
+    setState(() {
+      _isLabeledOn = newValue;
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return new Scaffold(
+      appBar: new AppBar(leading: const BackButton(key: ValueKey<String>('back'))),
+      body: new Material(
+        child: new Column(children: <Widget>[
+          new Row(
+            children: <Widget>[
+              new Checkbox(
+                key: checkbox1Key,
+                value: _isChecked,
+                onChanged: _updateCheckbox,
+              ),
+              const Checkbox(
+                key: checkbox2Key,
+                value: false,
+                onChanged: null,
+              ),
+            ],
+          ),
+          const Spacer(),
+          new Row(children: <Widget>[
+            new Radio<int>(key: radio1Key, value: 0, groupValue: _radio, onChanged: _updateRadio),
+            new Radio<int>(key: radio2Key, value: 1, groupValue: _radio, onChanged: _updateRadio),
+            new Radio<int>(key: radio3Key, value: 2, groupValue: _radio, onChanged: _updateRadio),
+          ]),
+          const Spacer(),
+          new Switch(
+            key: switchKey,
+            value: _isOn,
+            onChanged: _updateSwitch,
+          ),
+          const Spacer(),
+          new MergeSemantics(
+            child: new Row(
+              children: <Widget>[
+                const Text(switchLabel),
+                new Switch(
+                  key: labeledSwitchKey,
+                  value: _isLabeledOn,
+                  onChanged: _updateLabeledSwitch,
+                ),
+              ],
+            ),
+          ),
+        ]),
+      ),
+    );
+  }
+}
diff --git a/dev/integration_tests/android_semantics_testing/lib/test_constants.dart b/dev/integration_tests/android_semantics_testing/lib/test_constants.dart
new file mode 100644
index 0000000..2ae68a8
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/lib/test_constants.dart
@@ -0,0 +1,5 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+export 'src/tests/controls_constants.dart';
diff --git a/dev/integration_tests/android_semantics_testing/pubspec.yaml b/dev/integration_tests/android_semantics_testing/pubspec.yaml
index f5aec17..ab6865f 100644
--- a/dev/integration_tests/android_semantics_testing/pubspec.yaml
+++ b/dev/integration_tests/android_semantics_testing/pubspec.yaml
@@ -26,4 +26,7 @@
   vm_service_client: 0.2.6 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
   web_socket_channel: 1.0.9 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
 
+flutter:
+  uses-material-design: true
+
 # PUBSPEC CHECKSUM: 2086
diff --git a/dev/integration_tests/android_semantics_testing/test/android_semantics_testing_test.dart b/dev/integration_tests/android_semantics_testing/test/android_semantics_testing_test.dart
index 90b9005..7f31444 100644
--- a/dev/integration_tests/android_semantics_testing/test/android_semantics_testing_test.dart
+++ b/dev/integration_tests/android_semantics_testing/test/android_semantics_testing_test.dart
@@ -19,6 +19,7 @@
     "isLongClickable": false
   },
   "text": "hello",
+  "contentDescription": "other hello",
   "className": "android.view.View",
   "rect": {
     "left": 0,
@@ -43,6 +44,7 @@
       expect(node.isPassword, false);
       expect(node.isLongClickable, false);
       expect(node.text, 'hello');
+      expect(node.contentDescription, 'other hello');
       expect(node.id, 23);
       expect(node.getRect(), const Rect.fromLTRB(0.0, 0.0, 10.0, 10.0));
       expect(node.getActions(), <AndroidSemanticsAction>[
@@ -78,6 +80,7 @@
         isPassword: false,
         isLongClickable: false,
         text: 'hello',
+        contentDescription: 'other hello',
         className: 'android.view.View',
         id: 23,
         rect:  const Rect.fromLTRB(0.0, 0.0, 10.0, 10.0),
diff --git a/dev/integration_tests/android_semantics_testing/test_driver/main_test.dart b/dev/integration_tests/android_semantics_testing/test_driver/main_test.dart
new file mode 100644
index 0000000..ca856e9
--- /dev/null
+++ b/dev/integration_tests/android_semantics_testing/test_driver/main_test.dart
@@ -0,0 +1,166 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:io' as io;
+
+import 'package:android_semantics_testing/test_constants.dart';
+import 'package:android_semantics_testing/android_semantics_testing.dart';
+
+import 'package:test/test.dart' hide TypeMatcher, isInstanceOf;
+import 'package:flutter_driver/flutter_driver.dart';
+
+void main() {
+  group('AccessibilityBridge', () {
+    FlutterDriver driver;
+    Future<AndroidSemanticsNode> getSemantics(SerializableFinder finder) async {
+      final int id = await driver.getSemanticsId(finder);
+      final String data = await driver.requestData('getSemanticsNode#$id');
+      return new AndroidSemanticsNode.deserialize(data);
+    }
+
+    setUpAll(() async {
+      driver = await FlutterDriver.connect();
+      // Say the magic words..
+      final io.Process run = await io.Process.start('adb', const <String>[
+        'shell',
+        'settings',
+        'put',
+        'secure',
+        'enabled_accessibility_services',
+        'com.google.android.marvin.talkback/com.google.android.marvin.talkback.TalkBackService',
+      ]);
+      await run.exitCode;
+    });
+
+    tearDownAll(() async {
+      // ... And turn it off again
+      final io.Process run = await io.Process.start('adb', const <String>[
+        'shell',
+        'settings',
+        'put',
+        'secure',
+        'enabled_accessibility_services',
+        'null',
+      ]);
+      await run.exitCode;
+      driver?.close();
+    });
+    group('SelectionControls', () {
+      setUpAll(() async {
+        await driver.tap(find.text(selectionControlsRoute));
+      });
+
+      test('Checkbox has correct Android semantics', () async {
+        expect(await getSemantics(find.byValueKey(checkboxKeyValue)), hasAndroidSemantics(
+          className: AndroidClassName.checkBox,
+          isChecked: false,
+          isCheckable: true,
+          isEnabled: true,
+          isFocusable: true,
+          actions: <AndroidSemanticsAction>[
+            AndroidSemanticsAction.click,
+            AndroidSemanticsAction.accessibilityFocus,
+          ],
+        ));
+
+        await driver.tap(find.byValueKey(checkboxKeyValue));
+
+        expect(await getSemantics(find.byValueKey(checkboxKeyValue)), hasAndroidSemantics(
+          className: AndroidClassName.checkBox,
+          isChecked: true,
+          isCheckable: true,
+          isEnabled: true,
+          isFocusable: true,
+          actions: <AndroidSemanticsAction>[
+            AndroidSemanticsAction.click,
+            AndroidSemanticsAction.accessibilityFocus,
+          ],
+        ));
+        expect(await getSemantics(find.byValueKey(disabledCheckboxKeyValue)), hasAndroidSemantics(
+          className: AndroidClassName.checkBox,
+          isCheckable: true,
+          isEnabled: false,
+          actions: const <AndroidSemanticsAction>[
+            AndroidSemanticsAction.accessibilityFocus,
+          ],
+        ));
+      });
+      test('Radio has correct Android semantics', () async {
+        expect(await getSemantics(find.byValueKey(radio2KeyValue)), hasAndroidSemantics(
+          className: AndroidClassName.radio,
+          isChecked: false,
+          isCheckable: true,
+          isEnabled: true,
+          isFocusable: true,
+          actions: <AndroidSemanticsAction>[
+            AndroidSemanticsAction.click,
+            AndroidSemanticsAction.accessibilityFocus,
+          ],
+        ));
+
+        await driver.tap(find.byValueKey(radio2KeyValue));
+
+        expect(await getSemantics(find.byValueKey(radio2KeyValue)), hasAndroidSemantics(
+          className: AndroidClassName.radio,
+          isChecked: true,
+          isCheckable: true,
+          isEnabled: true,
+          isFocusable: true,
+          actions: <AndroidSemanticsAction>[
+            AndroidSemanticsAction.click,
+            AndroidSemanticsAction.accessibilityFocus,
+          ],
+        ));
+      });
+      test('Switch has correct Android semantics', () async {
+        expect(await getSemantics(find.byValueKey(switchKeyValue)), hasAndroidSemantics(
+          className: AndroidClassName.toggleSwitch,
+          isChecked: false,
+          isCheckable: true,
+          isEnabled: true,
+          isFocusable: true,
+          actions: <AndroidSemanticsAction>[
+            AndroidSemanticsAction.click,
+            AndroidSemanticsAction.accessibilityFocus,
+          ],
+        ));
+
+        await driver.tap(find.byValueKey(switchKeyValue));
+
+        expect(await getSemantics(find.byValueKey(switchKeyValue)), hasAndroidSemantics(
+          className: AndroidClassName.toggleSwitch,
+          isChecked: true,
+          isCheckable: true,
+          isEnabled: true,
+          isFocusable: true,
+          actions: <AndroidSemanticsAction>[
+            AndroidSemanticsAction.click,
+            AndroidSemanticsAction.accessibilityFocus,
+          ],
+        ));
+      });
+
+      // Regression test for https://github.com/flutter/flutter/issues/20820.
+      test('Switch can be labeled', () async {
+        expect(await getSemantics(find.byValueKey(labeledSwitchKeyValue)), hasAndroidSemantics(
+          className: AndroidClassName.toggleSwitch,
+          isChecked: false,
+          isCheckable: true,
+          isEnabled: true,
+          isFocusable: true,
+          contentDescription: switchLabel,
+          actions: <AndroidSemanticsAction>[
+            AndroidSemanticsAction.click,
+            AndroidSemanticsAction.accessibilityFocus,
+          ],
+        ));
+      });
+
+      tearDownAll(() async {
+        await driver.tap(find.byValueKey('back'));
+      });
+    });
+  });
+}