| // Copyright 2014 The Flutter 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:io' as io; |
| |
| import 'package:android_semantics_testing/android_semantics_testing.dart'; |
| import 'package:android_semantics_testing/test_constants.dart'; |
| import 'package:flutter_driver/flutter_driver.dart'; |
| import 'package:path/path.dart' as path; |
| import 'package:pub_semver/pub_semver.dart'; |
| import 'package:test/test.dart' hide isInstanceOf; |
| |
| // The accessibility focus actions are added when a semantics node receives or |
| // lose accessibility focus. This test ignores these actions since it is hard to |
| // predict which node has the accessibility focus after a screen changes. |
| const List<AndroidSemanticsAction> ignoredAccessibilityFocusActions = <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.accessibilityFocus, |
| AndroidSemanticsAction.clearAccessibilityFocus, |
| ]; |
| |
| String adbPath() { |
| final String androidHome = io.Platform.environment['ANDROID_HOME'] ?? io.Platform.environment['ANDROID_SDK_ROOT']!; |
| if (androidHome == null) { |
| return 'adb'; |
| } else { |
| return path.join(androidHome, 'platform-tools', 'adb'); |
| } |
| } |
| |
| void main() { |
| group('AccessibilityBridge', () { |
| late FlutterDriver driver; |
| Future<AndroidSemanticsNode> getSemantics(SerializableFinder finder) async { |
| final int id = await driver.getSemanticsId(finder); |
| final String data = await driver.requestData('getSemanticsNode#$id'); |
| return AndroidSemanticsNode.deserialize(data); |
| } |
| |
| // The version of TalkBack running on the device. |
| Version? talkbackVersion; |
| |
| Future<Version> getTalkbackVersion() async { |
| final io.ProcessResult result = await io.Process.run(adbPath(), const <String>[ |
| 'shell', |
| 'dumpsys', |
| 'package', |
| 'com.google.android.marvin.talkback', |
| ]); |
| if (result.exitCode != 0) { |
| throw Exception('Failed to get TalkBack version: ${result.stdout as String}\n${result.stderr as String}'); |
| } |
| final List<String> lines = (result.stdout as String).split('\n'); |
| String? version; |
| for (final String line in lines) { |
| if (line.contains('versionName')) { |
| version = line.replaceAll(RegExp(r'\s*versionName='), ''); |
| break; |
| } |
| } |
| if (version == null) { |
| throw Exception('Unable to determine TalkBack version.'); |
| } |
| |
| // Android doesn't quite use semver, so convert the version string to semver form. |
| final RegExp startVersion = RegExp(r'(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(\.(?<build>\d+))?'); |
| final RegExpMatch? match = startVersion.firstMatch(version); |
| if (match == null) { |
| return Version(0, 0, 0); |
| } |
| return Version( |
| int.parse(match.namedGroup('major')!), |
| int.parse(match.namedGroup('minor')!), |
| int.parse(match.namedGroup('patch')!), |
| build: match.namedGroup('build'), |
| ); |
| } |
| |
| setUpAll(() async { |
| driver = await FlutterDriver.connect(); |
| talkbackVersion ??= await getTalkbackVersion(); |
| print('TalkBack version is $talkbackVersion'); |
| |
| // Say the magic words.. |
| final io.Process run = await io.Process.start(adbPath(), 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(adbPath(), const <String>[ |
| 'shell', |
| 'settings', |
| 'put', |
| 'secure', |
| 'enabled_accessibility_services', |
| 'null', |
| ]); |
| await run.exitCode; |
| driver.close(); |
| }); |
| |
| group('TextField', () { |
| setUpAll(() async { |
| await driver.tap(find.text(textFieldRoute)); |
| // Delay for TalkBack to update focus as of November 2019 with Pixel 3 and Android API 28 |
| await Future<void>.delayed(const Duration(milliseconds: 500)); |
| |
| // The text selection menu and related semantics vary depending on if |
| // the clipboard contents are pasteable. Copy some text into the |
| // clipboard to make sure these tests always run with pasteable content |
| // in the clipboard. |
| // Ideally this should test the case where there is nothing on the |
| // clipboard as well, but there is no reliable way to clear the |
| // clipboard on Android devices. |
| await driver.requestData('setClipboard#Hello World'); |
| await Future<void>.delayed(const Duration(milliseconds: 500)); |
| }); |
| |
| test('TextField has correct Android semantics', () async { |
| final SerializableFinder normalTextField = find.descendant( |
| of: find.byValueKey(normalTextFieldKeyValue), |
| matching: find.byType('Semantics'), |
| firstMatchOnly: true, |
| ); |
| expect( |
| await getSemantics(normalTextField), |
| hasAndroidSemantics( |
| className: AndroidClassName.editText, |
| isEditable: true, |
| isFocusable: true, |
| isFocused: false, |
| isPassword: false, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| // We can't predict the a11y focus when the screen changes. |
| ignoredActions: ignoredAccessibilityFocusActions, |
| ), |
| ); |
| |
| await driver.tap(normalTextField); |
| // Delay for TalkBack to update focus as of November 2019 with Pixel 3 and Android API 28 |
| await Future<void>.delayed(const Duration(milliseconds: 500)); |
| |
| expect( |
| await getSemantics(normalTextField), |
| hasAndroidSemantics( |
| className: AndroidClassName.editText, |
| isFocusable: true, |
| isFocused: true, |
| isEditable: true, |
| isPassword: false, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| AndroidSemanticsAction.copy, |
| AndroidSemanticsAction.setSelection, |
| AndroidSemanticsAction.setText, |
| ], |
| // We can't predict the a11y focus when the screen changes. |
| ignoredActions: ignoredAccessibilityFocusActions, |
| ), |
| ); |
| |
| await driver.enterText('hello world'); |
| // Delay for TalkBack to update focus as of November 2019 with Pixel 3 and Android API 28 |
| await Future<void>.delayed(const Duration(milliseconds: 500)); |
| |
| expect( |
| await getSemantics(normalTextField), |
| hasAndroidSemantics( |
| text: 'hello world', |
| className: AndroidClassName.editText, |
| isFocusable: true, |
| isFocused: true, |
| isEditable: true, |
| isPassword: false, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| AndroidSemanticsAction.copy, |
| AndroidSemanticsAction.setSelection, |
| AndroidSemanticsAction.setText, |
| AndroidSemanticsAction.previousAtMovementGranularity, |
| ], |
| // We can't predict the a11y focus when the screen changes. |
| ignoredActions: ignoredAccessibilityFocusActions, |
| ), |
| ); |
| }, timeout: Timeout.none); |
| |
| test('password TextField has correct Android semantics', () async { |
| final SerializableFinder passwordTextField = find.descendant( |
| of: find.byValueKey(passwordTextFieldKeyValue), |
| matching: find.byType('Semantics'), |
| firstMatchOnly: true, |
| ); |
| expect( |
| await getSemantics(passwordTextField), |
| hasAndroidSemantics( |
| className: AndroidClassName.editText, |
| isEditable: true, |
| isFocusable: true, |
| isFocused: false, |
| isPassword: true, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| // We can't predict the a11y focus when the screen changes. |
| ignoredActions: ignoredAccessibilityFocusActions, |
| ), |
| ); |
| |
| await driver.tap(passwordTextField); |
| // Delay for TalkBack to update focus as of November 2019 with Pixel 3 and Android API 28 |
| await Future<void>.delayed(const Duration(milliseconds: 500)); |
| |
| expect( |
| await getSemantics(passwordTextField), |
| hasAndroidSemantics( |
| className: AndroidClassName.editText, |
| isFocusable: true, |
| isFocused: true, |
| isEditable: true, |
| isPassword: true, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| AndroidSemanticsAction.copy, |
| AndroidSemanticsAction.setSelection, |
| AndroidSemanticsAction.setText, |
| ], |
| // We can't predict the a11y focus when the screen changes. |
| ignoredActions: ignoredAccessibilityFocusActions, |
| ), |
| ); |
| |
| await driver.enterText('hello world'); |
| // Delay for TalkBack to update focus as of November 2019 with Pixel 3 and Android API 28 |
| await Future<void>.delayed(const Duration(milliseconds: 500)); |
| |
| expect( |
| await getSemantics(passwordTextField), |
| hasAndroidSemantics( |
| text: '\u{2022}' * ('hello world'.length), |
| className: AndroidClassName.editText, |
| isFocusable: true, |
| isFocused: true, |
| isEditable: true, |
| isPassword: true, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| AndroidSemanticsAction.copy, |
| AndroidSemanticsAction.setSelection, |
| AndroidSemanticsAction.setText, |
| AndroidSemanticsAction.previousAtMovementGranularity, |
| ], |
| // We can't predict the a11y focus when the screen changes. |
| ignoredActions: ignoredAccessibilityFocusActions, |
| ), |
| ); |
| }, timeout: Timeout.none); |
| |
| tearDownAll(() async { |
| await driver.tap(find.byValueKey('back')); |
| }); |
| }); |
| |
| group('SelectionControls', () { |
| setUpAll(() async { |
| await driver.tap(find.text(selectionControlsRoute)); |
| }); |
| |
| test('Checkbox has correct Android semantics', () async { |
| Future<AndroidSemanticsNode> getCheckboxSemantics(String key) async { |
| return getSemantics(find.byValueKey(key)); |
| } |
| expect( |
| await getCheckboxSemantics(checkboxKeyValue), |
| hasAndroidSemantics( |
| className: AndroidClassName.checkBox, |
| isChecked: false, |
| isCheckable: true, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| |
| await driver.tap(find.byValueKey(checkboxKeyValue)); |
| |
| expect( |
| await getCheckboxSemantics(checkboxKeyValue), |
| hasAndroidSemantics( |
| className: AndroidClassName.checkBox, |
| isChecked: true, |
| isCheckable: true, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| expect( |
| await getCheckboxSemantics(disabledCheckboxKeyValue), |
| hasAndroidSemantics( |
| className: AndroidClassName.checkBox, |
| isCheckable: true, |
| isEnabled: false, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: const <AndroidSemanticsAction>[], |
| ), |
| ); |
| }, timeout: Timeout.none); |
| test('Radio has correct Android semantics', () async { |
| Future<AndroidSemanticsNode> getRadioSemantics(String key) async { |
| return getSemantics(find.byValueKey(key)); |
| } |
| expect( |
| await getRadioSemantics(radio2KeyValue), |
| hasAndroidSemantics( |
| className: AndroidClassName.radio, |
| isChecked: false, |
| isCheckable: true, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| |
| await driver.tap(find.byValueKey(radio2KeyValue)); |
| |
| expect( |
| await getRadioSemantics(radio2KeyValue), |
| hasAndroidSemantics( |
| className: AndroidClassName.radio, |
| isChecked: true, |
| isCheckable: true, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| }, timeout: Timeout.none); |
| test('Switch has correct Android semantics', () async { |
| Future<AndroidSemanticsNode> getSwitchSemantics(String key) async { |
| return getSemantics(find.byValueKey(key)); |
| } |
| expect( |
| await getSwitchSemantics(switchKeyValue), |
| hasAndroidSemantics( |
| className: AndroidClassName.toggleSwitch, |
| isChecked: false, |
| isCheckable: true, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| |
| await driver.tap(find.byValueKey(switchKeyValue)); |
| |
| expect( |
| await getSwitchSemantics(switchKeyValue), |
| hasAndroidSemantics( |
| className: AndroidClassName.toggleSwitch, |
| isChecked: true, |
| isCheckable: true, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| }, timeout: Timeout.none); |
| |
| // Regression test for https://github.com/flutter/flutter/issues/20820. |
| test('Switch can be labeled', () async { |
| Future<AndroidSemanticsNode> getSwitchSemantics(String key) async { |
| return getSemantics(find.byValueKey(key)); |
| } |
| expect( |
| await getSwitchSemantics(labeledSwitchKeyValue), |
| hasAndroidSemantics( |
| className: AndroidClassName.toggleSwitch, |
| isChecked: false, |
| isCheckable: true, |
| isEnabled: true, |
| isFocusable: true, |
| contentDescription: switchLabel, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| }, timeout: Timeout.none); |
| |
| tearDownAll(() async { |
| await driver.tap(find.byValueKey('back')); |
| }); |
| }); |
| |
| group('Popup Controls', () { |
| setUpAll(() async { |
| await driver.tap(find.text(popupControlsRoute)); |
| }); |
| |
| test('Popup Menu has correct Android semantics', () async { |
| expect( |
| await getSemantics(find.byValueKey(popupButtonKeyValue)), |
| hasAndroidSemantics( |
| className: AndroidClassName.button, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| |
| await driver.tap(find.byValueKey(popupButtonKeyValue)); |
| try { |
| // We have to wait wall time here because we're waiting for TalkBack to |
| // catch up. |
| await Future<void>.delayed(const Duration(milliseconds: 1500)); |
| |
| for (final String item in popupItems) { |
| expect( |
| await getSemantics(find.byValueKey('$popupKeyValue.$item')), |
| hasAndroidSemantics( |
| className: AndroidClassName.button, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| reason: "Popup $item doesn't have the right semantics"); |
| } |
| await driver.tap(find.byValueKey('$popupKeyValue.${popupItems.first}')); |
| |
| // Pop up the menu again, to verify that TalkBack gets the right answer |
| // more than just the first time. |
| await driver.tap(find.byValueKey(popupButtonKeyValue)); |
| await Future<void>.delayed(const Duration(milliseconds: 1500)); |
| |
| for (final String item in popupItems) { |
| expect( |
| await getSemantics(find.byValueKey('$popupKeyValue.$item')), |
| hasAndroidSemantics( |
| className: AndroidClassName.button, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| reason: "Popup $item doesn't have the right semantics the second time"); |
| } |
| } finally { |
| await driver.tap(find.byValueKey('$popupKeyValue.${popupItems.first}')); |
| } |
| }, timeout: Timeout.none); |
| |
| test('Dropdown Menu has correct Android semantics', () async { |
| expect( |
| await getSemantics(find.byValueKey(dropdownButtonKeyValue)), |
| hasAndroidSemantics( |
| className: AndroidClassName.button, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| |
| await driver.tap(find.byValueKey(dropdownButtonKeyValue)); |
| try { |
| await Future<void>.delayed(const Duration(milliseconds: 1500)); |
| |
| for (final String item in popupItems) { |
| // There are two copies of each item, so we want to find the version |
| // that is in the overlay, not the one in the dropdown. |
| expect( |
| await getSemantics(find.descendant( |
| of: find.byType('Scrollable'), |
| matching: find.byValueKey('$dropdownKeyValue.$item'), |
| )), |
| hasAndroidSemantics( |
| className: AndroidClassName.view, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| reason: "Dropdown $item doesn't have the right semantics"); |
| } |
| await driver.tap( |
| find.descendant( |
| of: find.byType('Scrollable'), |
| matching: find.byValueKey('$dropdownKeyValue.${popupItems.first}'), |
| ), |
| ); |
| |
| // Pop up the dropdown again, to verify that TalkBack gets the right answer |
| // more than just the first time. |
| await driver.tap(find.byValueKey(dropdownButtonKeyValue)); |
| await Future<void>.delayed(const Duration(milliseconds: 1500)); |
| |
| for (final String item in popupItems) { |
| // There are two copies of each item, so we want to find the version |
| // that is in the overlay, not the one in the dropdown. |
| expect( |
| await getSemantics(find.descendant( |
| of: find.byType('Scrollable'), |
| matching: find.byValueKey('$dropdownKeyValue.$item'), |
| )), |
| hasAndroidSemantics( |
| className: AndroidClassName.view, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| reason: "Dropdown $item doesn't have the right semantics the second time."); |
| } |
| } finally { |
| await driver.tap( |
| find.descendant( |
| of: find.byType('Scrollable'), |
| matching: find.byValueKey('$dropdownKeyValue.${popupItems.first}'), |
| ), |
| ); |
| } |
| }, timeout: Timeout.none); |
| |
| test('Modal alert dialog has correct Android semantics', () async { |
| expect( |
| await getSemantics(find.byValueKey(alertButtonKeyValue)), |
| hasAndroidSemantics( |
| className: AndroidClassName.button, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| |
| await driver.tap(find.byValueKey(alertButtonKeyValue)); |
| try { |
| await Future<void>.delayed(const Duration(milliseconds: 1500)); |
| |
| expect( |
| await getSemantics(find.byValueKey('$alertKeyValue.OK')), |
| hasAndroidSemantics( |
| className: AndroidClassName.button, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| reason: "Alert OK button doesn't have the right semantics"); |
| |
| for (final String item in <String>['Title', 'Body1', 'Body2']) { |
| expect( |
| await getSemantics(find.byValueKey('$alertKeyValue.$item')), |
| hasAndroidSemantics( |
| className: AndroidClassName.view, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[], |
| ), |
| reason: "Alert $item button doesn't have the right semantics"); |
| } |
| |
| await driver.tap(find.byValueKey('$alertKeyValue.OK')); |
| |
| // Pop up the alert again, to verify that TalkBack gets the right answer |
| // more than just the first time. |
| await driver.tap(find.byValueKey(alertButtonKeyValue)); |
| await Future<void>.delayed(const Duration(milliseconds: 1500)); |
| |
| expect( |
| await getSemantics(find.byValueKey('$alertKeyValue.OK')), |
| hasAndroidSemantics( |
| className: AndroidClassName.button, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| reason: "Alert OK button doesn't have the right semantics"); |
| |
| for (final String item in <String>['Title', 'Body1', 'Body2']) { |
| expect( |
| await getSemantics(find.byValueKey('$alertKeyValue.$item')), |
| hasAndroidSemantics( |
| className: AndroidClassName.view, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[], |
| ), |
| reason: "Alert $item button doesn't have the right semantics"); |
| } |
| } finally { |
| await driver.tap(find.byValueKey('$alertKeyValue.OK')); |
| } |
| }, timeout: Timeout.none); |
| |
| tearDownAll(() async { |
| await Future<void>.delayed(const Duration(milliseconds: 500)); |
| await driver.tap(find.byValueKey('back')); |
| }); |
| }); |
| |
| group('Headings', () { |
| setUpAll(() async { |
| await driver.tap(find.text(headingsRoute)); |
| }); |
| |
| test('AppBar title has correct Android heading semantics', () async { |
| expect( |
| await getSemantics(find.byValueKey(appBarTitleKeyValue)), |
| hasAndroidSemantics(isHeading: true), |
| ); |
| }, timeout: Timeout.none); |
| |
| test('body text does not have Android heading semantics', () async { |
| expect( |
| await getSemantics(find.byValueKey(bodyTextKeyValue)), |
| hasAndroidSemantics(isHeading: false), |
| ); |
| }, timeout: Timeout.none); |
| |
| tearDownAll(() async { |
| await driver.tap(find.byValueKey('back')); |
| }); |
| }); |
| |
| }); |
| } |