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'];
+}