diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9526f8529..32a2aa6fa 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -31,7 +31,6 @@ diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java index 060dc3323..c3e785d4f 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java @@ -1,6 +1,7 @@ package deckers.thibault.aves.channel.calls; import android.content.Context; +import android.media.MediaScannerConnection; import android.os.Build; import android.os.storage.StorageManager; import android.os.storage.StorageVolume; @@ -51,6 +52,10 @@ public class StorageHandler implements MethodChannel.MethodCallHandler { } break; } + case "scanFile": { + scanFile(call, new MethodResultWrapper(result)); + break; + } default: result.notImplemented(); break; @@ -82,4 +87,12 @@ public class StorageHandler implements MethodChannel.MethodCallHandler { } return volumes; } -} + + private void scanFile(MethodCall call, MethodChannel.Result result) { + String path = call.argument("path"); + String mimeType = call.argument("mimeType"); + MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, uri) -> { + result.success(uri != null ? uri.toString() : null); + }); + } +} \ No newline at end of file diff --git a/lib/model/settings.dart b/lib/model/settings.dart index f634b4279..d7e326018 100644 --- a/lib/model/settings.dart +++ b/lib/model/settings.dart @@ -1,9 +1,10 @@ -import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/fullscreen/info/location_section.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'source/enums.dart'; + final Settings settings = Settings._private(); typedef SettingsCallback = void Function(String key, dynamic oldValue, dynamic newValue); diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 0b1b1e2cf..aaf4f93db 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -12,6 +12,8 @@ import 'package:aves/utils/change_notifier.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'enums.dart'; + class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSelectionMixin { final CollectionSource source; final Set filters; @@ -214,12 +216,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel } } -enum SortFactor { date, size, name } - -enum GroupFactor { none, album, month, day } - -enum Activity { browse, select } - mixin CollectionActivityMixin { final ValueNotifier _activityNotifier = ValueNotifier(Activity.browse); diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index bf56f6797..647b3f005 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -11,6 +11,8 @@ import 'package:aves/model/source/tag.dart'; import 'package:event_bus/event_bus.dart'; import 'package:flutter/foundation.dart'; +import 'enums.dart'; + mixin SourceBase { final List _rawEntries = []; diff --git a/lib/model/source/enums.dart b/lib/model/source/enums.dart new file mode 100644 index 000000000..7bb2d2dc9 --- /dev/null +++ b/lib/model/source/enums.dart @@ -0,0 +1,5 @@ +enum SortFactor { date, size, name } + +enum GroupFactor { none, album, month, day } + +enum Activity { browse, select } diff --git a/lib/services/android_file_service.dart b/lib/services/android_file_service.dart index 2eda39d07..03fbf287d 100644 --- a/lib/services/android_file_service.dart +++ b/lib/services/android_file_service.dart @@ -52,4 +52,19 @@ class AndroidFileService { } return false; } + + // return media URI + static Future scanFile(String path, String mimeType) async { + debugPrint('scanFile with path=$path, mimeType=$mimeType'); + try { + final uriString = await platform.invokeMethod('scanFile', { + 'path': path, + 'mimeType': mimeType, + }); + return Uri.tryParse(uriString ?? ''); + } on PlatformException catch (e) { + debugPrint('scanFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + } + return null; + } } diff --git a/lib/widgets/album/app_bar.dart b/lib/widgets/album/app_bar.dart index 2ac5de3c6..4def35827 100644 --- a/lib/widgets/album/app_bar.dart +++ b/lib/widgets/album/app_bar.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:aves/main.dart'; import 'package:aves/model/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/utils/durations.dart'; import 'package:aves/widgets/album/filter_bar.dart'; import 'package:aves/widgets/album/search/search_delegate.dart'; @@ -133,7 +134,7 @@ class _CollectionAppBarState extends State with SingleTickerPr Widget _buildAppBarTitle() { if (collection.isBrowsing) { - Widget title = Text(AvesApp.mode == AppMode.pick ? 'Select' : 'Aves'); + Widget title = Text(AvesApp.mode == AppMode.pick ? 'Select' : 'Aves', key: Key('appbar-title')); if (AvesApp.mode == AppMode.main) { title = SourceStateAwareAppBarTitle( title: title, @@ -184,15 +185,18 @@ class _CollectionAppBarState extends State with SingleTickerPr )), Builder( builder: (context) => PopupMenuButton( + key: Key('menu-button'), itemBuilder: (context) { final hasSelection = collection.selection.isNotEmpty; return [ PopupMenuItem( + key: Key('menu-sort'), value: CollectionAction.sort, child: MenuRow(text: 'Sort...', icon: AIcons.sort), ), if (collection.sortFactor == SortFactor.date) PopupMenuItem( + key: Key('menu-group'), value: CollectionAction.group, child: MenuRow(text: 'Group...', icon: AIcons.group), ), diff --git a/lib/widgets/album/grid/header_generic.dart b/lib/widgets/album/grid/header_generic.dart index 645c8c517..5dd1e3d44 100644 --- a/lib/widgets/album/grid/header_generic.dart +++ b/lib/widgets/album/grid/header_generic.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/durations.dart'; diff --git a/lib/widgets/album/thumbnail/overlay.dart b/lib/widgets/album/thumbnail/overlay.dart index c4893f9ec..a380e6d32 100644 --- a/lib/widgets/album/thumbnail/overlay.dart +++ b/lib/widgets/album/thumbnail/overlay.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/utils/durations.dart'; import 'package:aves/widgets/common/fx/sweeper.dart'; import 'package:aves/widgets/common/icons.dart'; diff --git a/lib/widgets/common/action_delegates/group_collection_dialog.dart b/lib/widgets/common/action_delegates/group_collection_dialog.dart index 111eb8765..9f097d9db 100644 --- a/lib/widgets/common/action_delegates/group_collection_dialog.dart +++ b/lib/widgets/common/action_delegates/group_collection_dialog.dart @@ -1,5 +1,5 @@ import 'package:aves/model/settings.dart'; -import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; @@ -35,6 +35,7 @@ class _GroupCollectionDialogState extends State { child: Text('Cancel'.toUpperCase()), ), FlatButton( + key: Key('apply-button'), onPressed: () => Navigator.pop(context, _selectedGroup), child: Text('Apply'.toUpperCase()), ), @@ -42,8 +43,9 @@ class _GroupCollectionDialogState extends State { ); } - Widget _buildRadioListTile(GroupFactor group, String title) => RadioListTile( - value: group, + Widget _buildRadioListTile(GroupFactor value, String title) => RadioListTile( + key: Key(value.toString()), + value: value, groupValue: _selectedGroup, onChanged: (group) => setState(() => _selectedGroup = group), title: Text( diff --git a/lib/widgets/common/action_delegates/sort_collection_dialog.dart b/lib/widgets/common/action_delegates/sort_collection_dialog.dart index 8286fd667..f61d19b64 100644 --- a/lib/widgets/common/action_delegates/sort_collection_dialog.dart +++ b/lib/widgets/common/action_delegates/sort_collection_dialog.dart @@ -1,5 +1,5 @@ import 'package:aves/model/settings.dart'; -import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; @@ -34,6 +34,7 @@ class _SortCollectionDialogState extends State { child: Text('Cancel'.toUpperCase()), ), FlatButton( + key: Key('apply-button'), onPressed: () => Navigator.pop(context, _selectedSort), child: Text('Apply'.toUpperCase()), ), @@ -41,8 +42,9 @@ class _SortCollectionDialogState extends State { ); } - Widget _buildRadioListTile(SortFactor sort, String title) => RadioListTile( - value: sort, + Widget _buildRadioListTile(SortFactor value, String title) => RadioListTile( + key: Key(value.toString()), + value: value, groupValue: _selectedSort, onChanged: (sort) => setState(() => _selectedSort = sort), title: Text( diff --git a/lib/widgets/welcome_page.dart b/lib/widgets/welcome_page.dart index 105548605..04a853983 100644 --- a/lib/widgets/welcome_page.dart +++ b/lib/widgets/welcome_page.dart @@ -93,11 +93,13 @@ class _WelcomePageState extends State { List _buildBottomControls(BuildContext context) { final checkbox = LabeledCheckbox( + key: Key('agree-checkbox'), value: _hasAcceptedTerms, onChanged: (v) => setState(() => _hasAcceptedTerms = v), text: 'I agree to the terms and conditions', ); final button = RaisedButton( + key: Key('continue-button'), child: Text('Continue'), onPressed: _hasAcceptedTerms ? () { diff --git a/pubspec.lock b/pubspec.lock index 4866bb809..3562e612a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,20 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + url: "https://pub.dartlang.org" + source: hosted + version: "7.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "0.39.17" ansicolor: dependency: transitive description: @@ -78,6 +92,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.9.0" + cli_util: + dependency: transitive + description: + name: cli_util + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" clock: dependency: transitive description: @@ -106,6 +127,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.1" + coverage: + dependency: transitive + description: + name: coverage + url: "https://pub.dartlang.org" + source: hosted + version: "0.14.0" crypto: dependency: transitive description: @@ -113,6 +141,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.5" + csslib: + dependency: transitive + description: + name: csslib + url: "https://pub.dartlang.org" + source: hosted + version: "0.16.2" draggable_scrollbar: dependency: "direct main" description: @@ -178,6 +213,11 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.4.1" + flutter_driver: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" flutter_ijkplayer: dependency: "direct main" description: @@ -246,6 +286,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" geocoder: dependency: "direct main" description: @@ -253,6 +298,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.1" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" google_maps_flutter: dependency: "direct main" description: @@ -267,6 +319,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.4" + html: + dependency: transitive + description: + name: html + url: "https://pub.dartlang.org" + source: hosted + version: "0.14.0+3" http: dependency: transitive description: @@ -274,6 +333,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.12.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" http_parser: dependency: transitive description: @@ -295,6 +361,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.16.1" + io: + dependency: transitive + description: + name: io + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.4" js: dependency: transitive description: @@ -302,6 +375,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.6.2" + json_rpc_2: + dependency: transitive + description: + name: json_rpc_2 + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1" latlong: dependency: "direct main" description: @@ -351,6 +431,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.1" + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.7" nested: dependency: transitive description: @@ -358,6 +445,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.0.4" + node_interop: + dependency: transitive + description: + name: node_interop + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + node_io: + dependency: transitive + description: + name: node_io + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + node_preamble: + dependency: transitive + description: + name: node_preamble + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.12" outline_material_icons: dependency: "direct main" description: @@ -365,6 +473,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.1" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.3" package_info: dependency: "direct main" description: @@ -500,6 +615,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.2" + pool: + dependency: transitive + description: + name: pool + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.0" positioned_tap_detector: dependency: transitive description: @@ -605,11 +727,53 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.2+7" + shelf: + dependency: transitive + description: + name: shelf + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.9" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + shelf_static: + dependency: transitive + description: + name: shelf_static + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.8" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.3" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.99" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + source_maps: + dependency: transitive + description: + name: source_maps + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.9" source_span: dependency: transitive description: @@ -666,6 +830,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.5" + sync_http: + dependency: transitive + description: + name: sync_http + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" synchronized: dependency: transitive description: @@ -680,6 +851,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0" + test: + dependency: "direct dev" + description: + name: test + url: "https://pub.dartlang.org" + source: hosted + version: "1.15.2" test_api: dependency: transitive description: @@ -687,6 +865,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.17" + test_core: + dependency: transitive + description: + name: test_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.10" transparent_image: dependency: transitive description: @@ -778,6 +963,48 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.8" + vm_service: + dependency: transitive + description: + name: vm_service + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.0" + vm_service_client: + dependency: transitive + description: + name: vm_service_client + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.6+2" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.7+15" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + webdriver: + dependency: transitive + description: + name: webdriver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.3" wkt_parser: dependency: transitive description: @@ -799,6 +1026,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.2.0" + yaml: + dependency: transitive + description: + name: yaml + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1" sdks: dart: ">=2.9.0-14.0.dev <3.0.0" flutter: ">=1.18.0-6.0.pre <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index ad6eb0610..cd7d16347 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -86,9 +86,12 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + # flutter drive --target=test_driver/app.dart + flutter_driver: + sdk: flutter + test: any flutter: - uses-material-design: true assets: diff --git a/test_driver/app.dart b/test_driver/app.dart new file mode 100644 index 000000000..f505976fc --- /dev/null +++ b/test_driver/app.dart @@ -0,0 +1,18 @@ +import 'package:aves/main.dart' as app; +import 'package:aves/services/android_file_service.dart'; +import 'package:flutter_driver/driver_extension.dart'; +import 'package:path/path.dart'; +import 'package:pedantic/pedantic.dart'; + +import 'constants.dart'; + +void main() { + enableFlutterDriverExtension(); + + // scan files copied from test assets + // we do it via the app instead of broadcasting via ADB + // because `MEDIA_SCANNER_SCAN_FILE` intent got deprecated in API 29 + unawaited(AndroidFileService.scanFile(join(targetPicturesDir, 'ipse.jpg'), 'image/jpeg')); + + app.main(); +} diff --git a/test_driver/app_test.dart b/test_driver/app_test.dart new file mode 100644 index 000000000..383850714 --- /dev/null +++ b/test_driver/app_test.dart @@ -0,0 +1,67 @@ +import 'package:aves/model/source/enums.dart'; +import 'package:flutter_driver/flutter_driver.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:test/test.dart'; + +import 'constants.dart'; +import 'utils.dart'; + +void main() { + group('Aves app', () { + FlutterDriver driver; + + setUpAll(() async { + await copyContent(sourcePicturesDir, targetPicturesDir); + await grantPermissions('deckers.thibault.aves.debug', [ + 'android.permission.READ_EXTERNAL_STORAGE', + 'android.permission.WRITE_EXTERNAL_STORAGE', + 'android.permission.ACCESS_MEDIA_LOCATION', + ]); + driver = await FlutterDriver.connect(); + }); + + tearDownAll(() async { + await removeDirectory(targetPicturesDir); + if (driver != null) { + unawaited(driver.close()); + } + }); + + final appBarTitleFinder = find.byValueKey('appbar-title'); + + test('agree to terms and reach home', () async { + await driver.scroll(find.text('Terms of Service'), 0, -300, Duration(milliseconds: 500)); + + final buttonFinder = find.byValueKey('continue-button'); + expect(await isEnabled(driver, buttonFinder), equals(false)); + + await driver.tap(find.byValueKey('agree-checkbox')); + await driver.waitUntilNoTransientCallbacks(); + expect(await isEnabled(driver, buttonFinder), equals(true)); + + await driver.tap(buttonFinder); + await driver.waitUntilNoTransientCallbacks(); + expect(await driver.getText(appBarTitleFinder), 'Aves'); + }); + + test('sort and group', () async { + await driver.tap(find.byValueKey('menu-button')); + await driver.waitUntilNoTransientCallbacks(); + + await driver.tap(find.byValueKey('menu-sort')); + await driver.waitUntilNoTransientCallbacks(); + + await driver.tap(find.byValueKey(SortFactor.date.toString())); + await driver.tap(find.byValueKey('apply-button')); + + await driver.tap(find.byValueKey('menu-button')); + await driver.waitUntilNoTransientCallbacks(); + + await driver.tap(find.byValueKey('menu-group')); + await driver.waitUntilNoTransientCallbacks(); + + await driver.tap(find.byValueKey(GroupFactor.album.toString())); + await driver.tap(find.byValueKey('apply-button')); + }); + }, timeout: Timeout(Duration(seconds: 10))); +} diff --git a/test_driver/assets/ipse.jpg b/test_driver/assets/ipse.jpg new file mode 100644 index 000000000..7d51ac93c Binary files /dev/null and b/test_driver/assets/ipse.jpg differ diff --git a/test_driver/constants.dart b/test_driver/constants.dart new file mode 100644 index 000000000..fb95e38fb --- /dev/null +++ b/test_driver/constants.dart @@ -0,0 +1,3 @@ +const sourcePicturesDir = 'test_driver/assets/'; +const targetPicturesDir = '/sdcard/Pictures/Aves Test Driver/'; + diff --git a/test_driver/utils.dart b/test_driver/utils.dart new file mode 100644 index 000000000..c4792884c --- /dev/null +++ b/test_driver/utils.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import 'package:flutter_driver/flutter_driver.dart'; +import 'package:path/path.dart'; + +String get adb { + final env = Platform.environment; + final sdkDir = env['ANDROID_SDK_ROOT'] ?? env['ANDROID_SDK']; + return join(sdkDir, 'platform-tools', Platform.isWindows ? 'adb.exe' : 'adb'); +} + +Future createDirectory(String dir) async { + await Process.run(adb, ['shell', 'mkdir -p', dir.replaceAll(' ', '\\ ')]); +} + +Future removeDirectory(String dir) async { + await Process.run(adb, ['shell', 'rm -r', dir.replaceAll(' ', '\\ ')]); +} + +Future copyContent(String sourceDir, String targetDir) async { + // to copy the content of `source` inside `target` + // `push source/* target/` works only when the target directory exists, and fails when `target` contains spaces + // `push source/ target/` works only when the target directory does not exist + await removeDirectory(targetDir); + await Process.run(adb, ['push', sourceDir, targetDir]); +} + +Future grantPermissions(String packageName, Iterable permissions) async { + await Future.forEach(permissions, (permission) => Process.run(adb, ['shell', 'pm', 'grant', packageName, permission])); +} + +Future isEnabled(FlutterDriver driver, SerializableFinder widgetFinder) async { + Map widgetDiagnostics = await driver.getWidgetDiagnostics(widgetFinder); + return widgetDiagnostics['properties'].firstWhere((property) => property['name'] == 'enabled')['value']; +}