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 00000000000..fa9b10e3366 --- /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 bb5a41c6458..ea991bde540 100644 --- a/dev/devicelab/lib/tasks/integration_tests.dart +++ b/dev/devicelab/lib/tasks/integration_tests.dart @@ -52,6 +52,13 @@ TaskFunction createEmbeddedAndroidViewsIntegrationTest() { ); } +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 2bae9d7d7ca..8ffc13f06f7 100644 --- a/dev/devicelab/manifest.yaml +++ b/dev/devicelab/manifest.yaml @@ -136,6 +136,13 @@ tasks: 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 00000000000..3228d776220 --- /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 00000000000..67036414330 --- /dev/null +++ b/dev/integration_tests/android_semantics_testing/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + 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 00000000000..22733fe5288 --- /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 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 convertSemantics(AccessibilityNodeInfo node, int id) { + if (node == null) + return null; + Map result = new HashMap<>(); + Map flags = new HashMap<>(); + Map 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 actionList = node.getActionList(); + if (actionList.size() > 0) { + ArrayList 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 00000000000..db77bb4b7b0 Binary files /dev/null and b/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-hdpi/ic_launcher.png 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 00000000000..17987b79bb8 Binary files /dev/null and b/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-mdpi/ic_launcher.png 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 00000000000..09d4391482b Binary files /dev/null and b/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png 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 00000000000..d5f1c8d34e7 Binary files /dev/null and b/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png 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 00000000000..4d6372eebdb Binary files /dev/null and b/dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png 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 00000000000..d4225c7905b --- /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 00000000000..8bd86f68051 --- /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 00000000000..9372d0f3f41 --- /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 00000000000..115da6cb4f4 --- /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 00000000000..1a08aa46141 --- /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 dataHandler(String message) async { + if (message.contains('getSemanticsNode')) { + final int id = int.tryParse(message.split('#')[1]) ?? 0; + final dynamic result = await kSemanticsChannel.invokeMethod('getSemanticsNode', { + 'id': id, + }); + return json.encode(result); + } + throw new UnimplementedError(); +} + +const List routes = [ + selectionControlsRoute, +]; + +class TestApp extends StatelessWidget { + const TestApp(); + + @override + Widget build(BuildContext context) { + return new MaterialApp( + routes: { + 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 a088cda0e35..e993f82966c 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 @@ class AndroidSemanticsNode { /// "isLongClickable": bool, /// }, /// "text": String, + /// "contentDescription": String, /// "className": String, /// "id": int, /// "rect": { @@ -64,6 +65,16 @@ class AndroidSemanticsNode { /// 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 fb641e5bb7d..b1fe9480580 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 @@ class AndroidClassName { /// 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 00000000000..79d7310b120 --- /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() => new test_package.TypeMatcher(); // 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 896866c866f..a3ce3be86fc 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 @@ import 'constants.dart'; /// the Flutter framework. Matcher hasAndroidSemantics({ String text, + String contentDescription, String className, int id, Rect rect, @@ -33,6 +33,7 @@ Matcher hasAndroidSemantics({ }) { return new _AndroidSemanticsMatcher( text: text, + contentDescription: contentDescription, className: className, rect: rect, size: size, @@ -52,6 +53,7 @@ Matcher hasAndroidSemantics({ class _AndroidSemanticsMatcher extends Matcher { _AndroidSemanticsMatcher({ this.text, + this.contentDescription, this.className, this.id, this.actions, @@ -69,6 +71,7 @@ class _AndroidSemanticsMatcher extends Matcher { final String text; final String className; + final String contentDescription; final int id; final List actions; final Rect rect; @@ -87,6 +90,8 @@ class _AndroidSemanticsMatcher extends Matcher { 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 @@ class _AndroidSemanticsMatcher extends Matcher { bool matches(covariant AndroidSemanticsNode item, Map 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 00000000000..ab8e827fab9 --- /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 00000000000..c83034401f3 --- /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 createState() => new _SelectionControlsPageState(); +} + +class _SelectionControlsPageState extends State { + static const ValueKey checkbox1Key = ValueKey(checkboxKeyValue); + static const ValueKey checkbox2Key = ValueKey(disabledCheckboxKeyValue); + static const ValueKey radio1Key = ValueKey(radio1KeyValue); + static const ValueKey radio2Key = ValueKey(radio2KeyValue); + static const ValueKey radio3Key = ValueKey(radio3KeyValue); + static const ValueKey switchKey = ValueKey(switchKeyValue); + static const ValueKey labeledSwitchKey = ValueKey(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('back'))), + body: new Material( + child: new Column(children: [ + new Row( + children: [ + new Checkbox( + key: checkbox1Key, + value: _isChecked, + onChanged: _updateCheckbox, + ), + const Checkbox( + key: checkbox2Key, + value: false, + onChanged: null, + ), + ], + ), + const Spacer(), + new Row(children: [ + new Radio(key: radio1Key, value: 0, groupValue: _radio, onChanged: _updateRadio), + new Radio(key: radio2Key, value: 1, groupValue: _radio, onChanged: _updateRadio), + new Radio(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: [ + 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 00000000000..2ae68a8d93b --- /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 f5aec17dd14..ab6865fe369 100644 --- a/dev/integration_tests/android_semantics_testing/pubspec.yaml +++ b/dev/integration_tests/android_semantics_testing/pubspec.yaml @@ -26,4 +26,7 @@ dependencies: 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 90b90058a66..7f314440bdf 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 @@ const String source = r''' "isLongClickable": false }, "text": "hello", + "contentDescription": "other hello", "className": "android.view.View", "rect": { "left": 0, @@ -43,6 +44,7 @@ void main() { 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(), [ @@ -78,6 +80,7 @@ void main() { 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 00000000000..ca856e9e9d1 --- /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 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 [ + '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 [ + '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.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.click, + AndroidSemanticsAction.accessibilityFocus, + ], + )); + expect(await getSemantics(find.byValueKey(disabledCheckboxKeyValue)), hasAndroidSemantics( + className: AndroidClassName.checkBox, + isCheckable: true, + isEnabled: false, + actions: const [ + 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.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.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.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.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.click, + AndroidSemanticsAction.accessibilityFocus, + ], + )); + }); + + tearDownAll(() async { + await driver.tap(find.byValueKey('back')); + }); + }); + }); +}