From c6e8a513f05b1da77fb4a23b6f486f29f587336d Mon Sep 17 00:00:00 2001 From: Jonah Williams Date: Thu, 6 Sep 2018 12:31:18 -0700 Subject: [PATCH] Add android semantics integration test to device lab (#20971) --- .../android_semantics_integration_test.dart | 12 ++ .../lib/tasks/integration_tests.dart | 7 + dev/devicelab/manifest.yaml | 7 + .../android/app/build.gradle | 57 ++++++ .../android/app/src/main/AndroidManifest.xml | 28 +++ .../platforminteraction/MainActivity.java | 111 ++++++++++++ .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../android/build.gradle | 29 +++ .../android/gradle.properties | 1 + .../gradle/wrapper/gradle-wrapper.properties | 6 + .../android/settings.gradle | 15 ++ .../android_semantics_testing/lib/main.dart | 63 +++++++ .../lib/src/common.dart | 11 ++ .../lib/src/constants.dart | 3 + .../lib/src/flutter_test_alternative.dart | 14 ++ .../lib/src/matcher.dart | 11 +- .../lib/src/tests/controls_constants.dart | 30 ++++ .../lib/src/tests/controls_page.dart | 103 +++++++++++ .../lib/test_constants.dart | 5 + .../android_semantics_testing/pubspec.yaml | 3 + .../test/android_semantics_testing_test.dart | 3 + .../test_driver/main_test.dart | 166 ++++++++++++++++++ 26 files changed, 683 insertions(+), 2 deletions(-) create mode 100644 dev/devicelab/bin/tasks/android_semantics_integration_test.dart create mode 100644 dev/integration_tests/android_semantics_testing/android/app/build.gradle create mode 100644 dev/integration_tests/android_semantics_testing/android/app/src/main/AndroidManifest.xml create mode 100644 dev/integration_tests/android_semantics_testing/android/app/src/main/java/com/yourcompany/platforminteraction/MainActivity.java create mode 100644 dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 dev/integration_tests/android_semantics_testing/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 dev/integration_tests/android_semantics_testing/android/build.gradle create mode 100644 dev/integration_tests/android_semantics_testing/android/gradle.properties create mode 100755 dev/integration_tests/android_semantics_testing/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 dev/integration_tests/android_semantics_testing/android/settings.gradle create mode 100644 dev/integration_tests/android_semantics_testing/lib/main.dart create mode 100644 dev/integration_tests/android_semantics_testing/lib/src/flutter_test_alternative.dart create mode 100644 dev/integration_tests/android_semantics_testing/lib/src/tests/controls_constants.dart create mode 100644 dev/integration_tests/android_semantics_testing/lib/src/tests/controls_page.dart create mode 100644 dev/integration_tests/android_semantics_testing/lib/test_constants.dart create mode 100644 dev/integration_tests/android_semantics_testing/test_driver/main_test.dart 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 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 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')); + }); + }); + }); +}