From 836e7fe4d0144fecb3e60749c9294c95ea003ad8 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 27 Apr 2020 18:23:50 +0900 Subject: [PATCH] check storage permission before platform calls --- .../deckers/thibault/aves/MainActivity.java | 21 ++- .../StorageAccessStreamHandler.java | 55 +++++++ ...dapterHandler.java => StorageHandler.java} | 13 +- .../thibault/aves/utils/Constants.java | 5 +- .../aves/utils/PermissionManager.java | 75 +++++++-- lib/services/android_file_service.dart | 39 ++++- lib/utils/android_file_utils.dart | 4 +- lib/widgets/album/app_bar.dart | 118 +------------- lib/widgets/album/collection_drawer.dart | 2 +- lib/widgets/album/grid/header_album.dart | 2 +- lib/widgets/album/grid/header_generic.dart | 2 +- .../entry_action_delegate.dart | 11 +- .../action_delegates/permission_aware.dart | 55 +++++++ .../selection_action_delegate.dart | 152 ++++++++++++++++++ lib/widgets/debug_page.dart | 41 ++++- lib/widgets/fullscreen/fullscreen_body.dart | 2 +- 16 files changed, 452 insertions(+), 145 deletions(-) create mode 100644 android/app/src/main/java/deckers/thibault/aves/channelhandlers/StorageAccessStreamHandler.java rename android/app/src/main/java/deckers/thibault/aves/channelhandlers/{FileAdapterHandler.java => StorageHandler.java} (82%) rename lib/widgets/common/{ => action_delegates}/entry_action_delegate.dart (95%) create mode 100644 lib/widgets/common/action_delegates/permission_aware.dart create mode 100644 lib/widgets/common/action_delegates/selection_action_delegate.dart diff --git a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java index d5d3c0e15..711b108b9 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -10,12 +10,13 @@ import java.util.Map; import app.loup.streams_channel.StreamsChannel; import deckers.thibault.aves.channelhandlers.AppAdapterHandler; -import deckers.thibault.aves.channelhandlers.FileAdapterHandler; import deckers.thibault.aves.channelhandlers.ImageByteStreamHandler; import deckers.thibault.aves.channelhandlers.ImageFileHandler; import deckers.thibault.aves.channelhandlers.ImageOpStreamHandler; import deckers.thibault.aves.channelhandlers.MediaStoreStreamHandler; import deckers.thibault.aves.channelhandlers.MetadataHandler; +import deckers.thibault.aves.channelhandlers.StorageAccessStreamHandler; +import deckers.thibault.aves.channelhandlers.StorageHandler; import deckers.thibault.aves.utils.Constants; import deckers.thibault.aves.utils.Env; import deckers.thibault.aves.utils.PermissionManager; @@ -43,12 +44,15 @@ public class MainActivity extends FlutterActivity { MediaStoreStreamHandler mediaStoreStreamHandler = new MediaStoreStreamHandler(); FlutterView messenger = getFlutterView(); - new MethodChannel(messenger, FileAdapterHandler.CHANNEL).setMethodCallHandler(new FileAdapterHandler(this)); + new MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(new StorageHandler(this)); new MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(new AppAdapterHandler(this)); new MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(new ImageFileHandler(this, mediaStoreStreamHandler)); new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this)); new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler); + final StreamsChannel fileAccessStreamChannel = new StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL); + fileAccessStreamChannel.setStreamHandlerFactory(arguments -> new StorageAccessStreamHandler(this, arguments)); + final StreamsChannel imageByteStreamChannel = new StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL); imageByteStreamChannel.setStreamHandlerFactory(arguments -> new ImageByteStreamHandler(this, arguments)); @@ -79,22 +83,23 @@ public class MainActivity extends FlutterActivity { @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == Constants.SD_CARD_PERMISSION_REQUEST_CODE && resultCode == RESULT_OK) { - Uri sdCardDocumentUri = data.getData(); - if (sdCardDocumentUri == null) { + if (requestCode == Constants.SD_CARD_PERMISSION_REQUEST_CODE) { + if (resultCode != RESULT_OK || data.getData() == null) { + PermissionManager.onPermissionResult(requestCode, false); return; } - Env.setSdCardDocumentUri(this, sdCardDocumentUri.toString()); + Uri treeUri = data.getData(); + Env.setSdCardDocumentUri(this, treeUri.toString()); // save access permissions across reboots final int takeFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - getContentResolver().takePersistableUriPermission(sdCardDocumentUri, takeFlags); + getContentResolver().takePersistableUriPermission(treeUri, takeFlags); // resume pending action - PermissionManager.onPermissionGranted(requestCode); + PermissionManager.onPermissionResult(requestCode, true); } } } diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/StorageAccessStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/StorageAccessStreamHandler.java new file mode 100644 index 000000000..dad6f725d --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/StorageAccessStreamHandler.java @@ -0,0 +1,55 @@ +package deckers.thibault.aves.channelhandlers; + +import android.app.Activity; +import android.os.Handler; +import android.os.Looper; + +import java.util.Map; + +import deckers.thibault.aves.utils.PermissionManager; +import io.flutter.plugin.common.EventChannel; + +// starting activity to give access with the native dialog +// breaks the regular `MethodChannel` so we use a stream channel instead +public class StorageAccessStreamHandler implements EventChannel.StreamHandler { + public static final String CHANNEL = "deckers.thibault/aves/storageaccessstream"; + + private Activity activity; + private EventChannel.EventSink eventSink; + private Handler handler; + private String volumePath; + + public StorageAccessStreamHandler(Activity activity, Object arguments) { + this.activity = activity; + if (arguments instanceof Map) { + Map argMap = (Map) arguments; + this.volumePath = (String) argMap.get("path"); + } + } + + @Override + public void onListen(Object o, final EventChannel.EventSink eventSink) { + this.eventSink = eventSink; + this.handler = new Handler(Looper.getMainLooper()); + Runnable onGranted = () -> success(PermissionManager.hasGrantedPermissionToVolumeRoot(activity, volumePath)); + Runnable onDenied = () -> success(false); + PermissionManager.requestVolumeAccess(activity, volumePath, onGranted, onDenied); + } + + @Override + public void onCancel(Object o) { + } + + private void success(final boolean result) { + handler.post(() -> eventSink.success(result)); + endOfStream(); + } + + private void error(final String errorCode, final String errorMessage, final Object errorDetails) { + handler.post(() -> eventSink.error(errorCode, errorMessage, errorDetails)); + } + + private void endOfStream() { + handler.post(() -> eventSink.endOfStream()); + } +} diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/FileAdapterHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/StorageHandler.java similarity index 82% rename from android/app/src/main/java/deckers/thibault/aves/channelhandlers/FileAdapterHandler.java rename to android/app/src/main/java/deckers/thibault/aves/channelhandlers/StorageHandler.java index f60035ef9..1b644ffef 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/FileAdapterHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/StorageHandler.java @@ -13,15 +13,16 @@ import java.util.List; import java.util.Map; import deckers.thibault.aves.utils.Env; +import deckers.thibault.aves.utils.PermissionManager; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; -public class FileAdapterHandler implements MethodChannel.MethodCallHandler { - public static final String CHANNEL = "deckers.thibault/aves/file"; +public class StorageHandler implements MethodChannel.MethodCallHandler { + public static final String CHANNEL = "deckers.thibault/aves/storage"; private Activity activity; - public FileAdapterHandler(Activity activity) { + public StorageHandler(Activity activity) { this.activity = activity; } @@ -54,6 +55,12 @@ public class FileAdapterHandler implements MethodChannel.MethodCallHandler { result.success(volumes); break; } + case "hasGrantedPermissionToVolumeRoot": { + String path = call.argument("path"); + boolean granted = PermissionManager.hasGrantedPermissionToVolumeRoot(activity, path); + result.success(granted); + break; + } default: result.notImplemented(); break; diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java b/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java index cb857ac95..e88b50774 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java @@ -1,6 +1,7 @@ package deckers.thibault.aves.utils; import android.media.MediaMetadataRetriever; +import android.os.Build; import java.util.HashMap; import java.util.Map; @@ -17,7 +18,9 @@ public class Constants { put(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO, "Has Audio"); put(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO, "Has Video"); put(MediaMetadataRetriever.METADATA_KEY_BITRATE, "Bitrate"); - put(MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT, "Frame Count"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + put(MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT, "Frame Count"); + } put(MediaMetadataRetriever.METADATA_KEY_DATE, "Date"); put(MediaMetadataRetriever.METADATA_KEY_LOCATION, "Location"); put(MediaMetadataRetriever.METADATA_KEY_YEAR, "Year"); diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java index 434f7085b..96f84ac89 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java @@ -2,23 +2,30 @@ package deckers.thibault.aves.utils; import android.annotation.TargetApi; import android.app.Activity; +import android.content.Context; import android.content.Intent; import android.content.UriPermission; import android.net.Uri; import android.os.Build; +import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; +import android.provider.DocumentsContract; import android.util.Log; import androidx.appcompat.app.AlertDialog; import androidx.core.app.ActivityCompat; +import androidx.core.util.Pair; +import java.io.File; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; public class PermissionManager { private static final String LOG_TAG = Utils.createLogTag(PermissionManager.class); // permission request code to pending runnable - private static ConcurrentHashMap pendingPermissionMap = new ConcurrentHashMap<>(); + private static ConcurrentHashMap> pendingPermissionMap = new ConcurrentHashMap<>(); // check access permission to SD card directory & return its content URI if available public static Uri getSdCardTreeUri(Activity activity) { @@ -34,20 +41,64 @@ public class PermissionManager { new AlertDialog.Builder(activity) .setTitle("SD Card Access") .setMessage("Please select the root directory of the SD card in the next screen, so that this app has permission to access it and complete your request.") - .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> { - Log.i(LOG_TAG, "request user to select and grant access permission to SD card"); - pendingPermissionMap.put(Constants.SD_CARD_PERMISSION_REQUEST_CODE, pendingRunnable); - ActivityCompat.startActivityForResult(activity, - new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE), - Constants.SD_CARD_PERMISSION_REQUEST_CODE, null); - }) + .setPositiveButton(android.R.string.ok, (dialog, button) -> requestVolumeAccess(activity, null, pendingRunnable, null)) .show(); } - public static void onPermissionGranted(int requestCode) { - Runnable runnable = pendingPermissionMap.remove(requestCode); - if (runnable != null) { - runnable.run(); + public static void requestVolumeAccess(Activity activity, String volumePath, Runnable onGranted, Runnable onDenied) { + Log.i(LOG_TAG, "request user to select and grant access permission to volume=" + volumePath); + pendingPermissionMap.put(Constants.SD_CARD_PERMISSION_REQUEST_CODE, Pair.create(onGranted, onDenied)); + + Intent intent = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && volumePath != null) { + StorageManager sm = activity.getSystemService(StorageManager.class); + if (sm != null) { + StorageVolume volume = sm.getStorageVolume(new File(volumePath)); + if (volume != null) { + intent = volume.createOpenDocumentTreeIntent(); + } + } } + + // fallback to basic open document tree intent + if (intent == null) { + intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + } + + ActivityCompat.startActivityForResult(activity, intent, Constants.SD_CARD_PERMISSION_REQUEST_CODE, null); + } + + public static void onPermissionResult(int requestCode, boolean granted) { + Log.d(LOG_TAG, "onPermissionResult with requestCode=" + requestCode + ", granted=" + granted); + Pair runnables = pendingPermissionMap.remove(requestCode); + if (runnables == null) return; + Runnable runnable = granted ? runnables.first : runnables.second; + if (runnable == null) return; + runnable.run(); + } + + + public static boolean hasGrantedPermissionToVolumeRoot(Context context, String path) { + boolean canAccess = false; + Stream permittedUris = context.getContentResolver().getPersistedUriPermissions().stream().map(UriPermission::getUri); + // e.g. content://com.android.externalstorage.documents/tree/12A9-8B42%3A + StorageManager sm = context.getSystemService(StorageManager.class); + if (sm != null) { + StorageVolume volume = sm.getStorageVolume(new File(path)); + if (volume != null) { + // primary storage doesn't have a UUID + String uuid = volume.isPrimary() ? "primary" : volume.getUuid(); + Uri targetVolumeTreeUri = getVolumeTreeUriFromUuid(uuid); + canAccess = permittedUris.anyMatch(uri -> uri.equals(targetVolumeTreeUri)); + } + } + return canAccess; + } + + private static Uri getVolumeTreeUriFromUuid(String uuid) { + return DocumentsContract.buildTreeDocumentUri( + "com.android.externalstorage.documents", + uuid + ":" + ); } } diff --git a/lib/services/android_file_service.dart b/lib/services/android_file_service.dart index f43cc7e9b..ccf02d3fa 100644 --- a/lib/services/android_file_service.dart +++ b/lib/services/android_file_service.dart @@ -1,8 +1,12 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:streams_channel/streams_channel.dart'; class AndroidFileService { - static const platform = MethodChannel('deckers.thibault/aves/file'); + static const platform = MethodChannel('deckers.thibault/aves/storage'); + static final StreamsChannel storageAccessChannel = StreamsChannel('deckers.thibault/aves/storageaccessstream'); static Future> getStorageVolumes() async { try { @@ -13,4 +17,37 @@ class AndroidFileService { } return []; } + + static Future hasGrantedPermissionToVolumeRoot(String path) async { + try { + final result = await platform.invokeMethod('hasGrantedPermissionToVolumeRoot', { + 'path': path, + }); + return result as bool; + } on PlatformException catch (e) { + debugPrint('hasGrantedPermissionToVolumeRoot failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + } + return false; + } + + // returns whether user granted access to volume root at `volumePath` + static Future requestVolumeAccess(String volumePath) async { + try { + final completer = Completer(); + storageAccessChannel.receiveBroadcastStream({ + 'path': volumePath, + }).listen( + (data) => completer.complete(data as bool), + onError: completer.completeError, + onDone: () { + if (!completer.isCompleted) completer.complete(false); + }, + cancelOnError: true, + ); + return completer.future; + } on PlatformException catch (e) { + debugPrint('requestVolumeAccess failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + } + return false; + } } diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index c94cb7149..c14187243 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -15,7 +15,7 @@ class AndroidFileUtils { Future init() async { storageVolumes = (await AndroidFileService.getStorageVolumes()).map((map) => StorageVolume.fromMap(map)).toList(); // path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files' - externalStorage = '/storage/emulated/0'; + externalStorage = storageVolumes.firstWhere((volume) => volume.isPrimary).path; dcimPath = join(externalStorage, 'DCIM'); downloadPath = join(externalStorage, 'Download'); moviesPath = join(externalStorage, 'Movies'); @@ -34,7 +34,7 @@ class AndroidFileUtils { StorageVolume getStorageVolume(String path) => storageVolumes.firstWhere((v) => path.startsWith(v.path), orElse: () => null); - bool isOnSD(String path) => getStorageVolume(path).isRemovable; + bool isOnRemovableStorage(String path) => getStorageVolume(path).isRemovable; AlbumType getAlbumType(String albumDirectory) { if (albumDirectory != null) { diff --git a/lib/widgets/album/app_bar.dart b/lib/widgets/album/app_bar.dart index 3b4b2debf..0f8023ba4 100644 --- a/lib/widgets/album/app_bar.dart +++ b/lib/widgets/album/app_bar.dart @@ -1,24 +1,19 @@ import 'dart:async'; import 'package:aves/model/collection_lens.dart'; -import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings.dart'; -import 'package:aves/services/android_app_service.dart'; -import 'package:aves/services/image_file_service.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/album/filter_bar.dart'; import 'package:aves/widgets/album/search/search_delegate.dart'; import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/menu_row.dart'; +import 'package:aves/widgets/common/action_delegates/selection_action_delegate.dart'; import 'package:aves/widgets/stats/stats.dart'; -import 'package:collection/collection.dart'; -import 'package:flushbar/flushbar.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:outline_material_icons/outline_material_icons.dart'; import 'package:pedantic/pedantic.dart'; -import 'package:percent_indicator/circular_percent_indicator.dart'; class CollectionAppBar extends StatefulWidget { final ValueNotifier appBarHeightNotifier; @@ -36,7 +31,7 @@ class CollectionAppBar extends StatefulWidget { class _CollectionAppBarState extends State with SingleTickerProviderStateMixin { final TextEditingController _searchFieldController = TextEditingController(); - + SelectionActionDelegate _actionDelegate; AnimationController _browseToSelectAnimation; CollectionLens get collection => widget.collection; @@ -46,6 +41,9 @@ class _CollectionAppBarState extends State with SingleTickerPr @override void initState() { super.initState(); + _actionDelegate = SelectionActionDelegate( + collection: collection, + ); _browseToSelectAnimation = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, @@ -160,7 +158,7 @@ class _CollectionAppBarState extends State with SingleTickerPr builder: (context, child) { return IconButton( icon: Icon(action.getIcon()), - onPressed: collection.selection.isEmpty ? null : () => _onSelectionActionSelected(action), + onPressed: collection.selection.isEmpty ? null : () => _actionDelegate.onActionSelected(context, action), tooltip: action.getText(), ); }, @@ -293,110 +291,6 @@ class _CollectionAppBarState extends State with SingleTickerPr ), ); } - - void _onSelectionActionSelected(EntryAction action) { - switch (action) { - case EntryAction.share: - _shareSelection(); - break; - case EntryAction.delete: - _deleteSelection(); - break; - default: - break; - } - } - - void _shareSelection() { - final urisByMimeType = groupBy(collection.selection, (e) => e.mimeType).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); - AndroidAppService.share(urisByMimeType); - } - - void _deleteSelection() { - final selection = collection.selection.toList(); - _showOpReport( - selection: selection, - opStream: ImageFileService.delete(selection), - onDone: (processed) { - final deletedUris = processed.where((e) => e.success).map((e) => e.uri); - final deletedCount = deletedUris.length; - final selectionCount = selection.length; - if (deletedCount < selectionCount) { - _showFeedback(context, 'Failed to delete ${selectionCount - deletedCount} items'); - } - if (deletedCount > 0) { - collection.source.removeEntries(selection.where((e) => deletedUris.contains(e.uri))); - } - collection.browse(); - }, - ); - } - - // selection action report overlay - - OverlayEntry _opReportOverlayEntry; - - static const _overlayAnimationDuration = Duration(milliseconds: 300); - - void _showOpReport({ - @required List selection, - @required Stream opStream, - @required void Function(Set processed) onDone, - }) { - final processed = {}; - _opReportOverlayEntry = OverlayEntry( - builder: (context) { - return StreamBuilder( - stream: opStream, - builder: (context, snapshot) { - if (snapshot.hasData) { - processed.add(snapshot.data); - } - - Widget child = const SizedBox.shrink(); - if (snapshot.hasError || snapshot.connectionState == ConnectionState.done) { - _hideOpReportOverlay().then((_) => onDone(processed)); - } else if (snapshot.connectionState == ConnectionState.active) { - final percent = processed.length.toDouble() / selection.length; - child = CircularPercentIndicator( - percent: percent, - lineWidth: 16, - radius: 160, - backgroundColor: Colors.white24, - progressColor: Theme.of(context).accentColor, - animation: true, - center: Text(NumberFormat.percentPattern().format(percent)), - animateFromLastPercent: true, - ); - } - return AnimatedSwitcher( - duration: _overlayAnimationDuration, - child: child, - ); - }); - }, - ); - Overlay.of(context).insert(_opReportOverlayEntry); - } - - Future _hideOpReportOverlay() async { - await Future.delayed(_overlayAnimationDuration); - _opReportOverlayEntry.remove(); - _opReportOverlayEntry = null; - } - - void _showFeedback(BuildContext context, String message) { - Flushbar( - message: message, - margin: const EdgeInsets.all(8), - borderRadius: 8, - borderColor: Colors.white30, - borderWidth: 0.5, - duration: const Duration(seconds: 2), - flushbarPosition: FlushbarPosition.TOP, - animationDuration: const Duration(milliseconds: 600), - ).show(context); - } } enum CollectionAction { select, stats, groupByAlbum, groupByMonth, groupByDay, sortByDate, sortBySize, sortByName } diff --git a/lib/widgets/album/collection_drawer.dart b/lib/widgets/album/collection_drawer.dart index b60dda7af..93148a110 100644 --- a/lib/widgets/album/collection_drawer.dart +++ b/lib/widgets/album/collection_drawer.dart @@ -99,7 +99,7 @@ class _CollectionDrawerState extends State { source: source, leading: IconUtils.getAlbumIcon(context: context, album: album), title: uniqueName, - trailing: androidFileUtils.isOnSD(album) + trailing: androidFileUtils.isOnRemovableStorage(album) ? const Icon( OMIcons.sdStorage, size: 16, diff --git a/lib/widgets/album/grid/header_album.dart b/lib/widgets/album/grid/header_album.dart index d1cb70aaf..ef0f3befb 100644 --- a/lib/widgets/album/grid/header_album.dart +++ b/lib/widgets/album/grid/header_album.dart @@ -29,7 +29,7 @@ class AlbumSectionHeader extends StatelessWidget { sectionKey: folderPath, leading: albumIcon, title: albumName, - trailing: androidFileUtils.isOnSD(folderPath) + trailing: androidFileUtils.isOnRemovableStorage(folderPath) ? const Icon( OMIcons.sdStorage, size: 16, diff --git a/lib/widgets/album/grid/header_generic.dart b/lib/widgets/album/grid/header_generic.dart index c98c53722..fd6f860f8 100644 --- a/lib/widgets/album/grid/header_generic.dart +++ b/lib/widgets/album/grid/header_generic.dart @@ -66,7 +66,7 @@ class SectionHeader extends StatelessWidget { if (sectionKey is String) { // only compute height for album headers, as they're the only likely ones to split on multiple lines final hasLeading = androidFileUtils.getAlbumType(sectionKey) != AlbumType.Default; - final hasTrailing = androidFileUtils.isOnSD(sectionKey); + final hasTrailing = androidFileUtils.isOnRemovableStorage(sectionKey); final text = source.getUniqueAlbumName(sectionKey); final maxWidth = scrollableWidth - TitleSectionHeader.padding.horizontal; final para = RenderParagraph( diff --git a/lib/widgets/common/entry_action_delegate.dart b/lib/widgets/common/action_delegates/entry_action_delegate.dart similarity index 95% rename from lib/widgets/common/entry_action_delegate.dart rename to lib/widgets/common/action_delegates/entry_action_delegate.dart index 66d08ca51..d39d04855 100644 --- a/lib/widgets/common/entry_action_delegate.dart +++ b/lib/widgets/common/action_delegates/entry_action_delegate.dart @@ -4,6 +4,7 @@ import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/image_file_service.dart'; +import 'package:aves/widgets/common/action_delegates/permission_aware.dart'; import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/fullscreen/debug.dart'; @@ -15,7 +16,7 @@ import 'package:pdf/widgets.dart' as pdf; import 'package:pedantic/pedantic.dart'; import 'package:printing/printing.dart'; -class EntryActionDelegate { +class EntryActionDelegate with PermissionAwareMixin { final CollectionLens collection; final VoidCallback showInfo; @@ -122,6 +123,8 @@ class EntryActionDelegate { } Future _rotate(BuildContext context, ImageEntry entry, {@required bool clockwise}) async { + if (!await checkStoragePermission(context, [entry])) return; + final success = await entry.rotate(clockwise: clockwise); if (!success) _showFeedback(context, 'Failed'); } @@ -146,6 +149,9 @@ class EntryActionDelegate { }, ); if (confirmed == null || !confirmed) return; + + if (!await checkStoragePermission(context, [entry])) return; + if (!await entry.delete()) { _showFeedback(context, 'Failed'); } else if (hasCollection) { @@ -184,6 +190,9 @@ class EntryActionDelegate { ); }); if (newName == null || newName.isEmpty) return; + + if (!await checkStoragePermission(context, [entry])) return; + _showFeedback(context, await entry.rename(newName) ? 'Done!' : 'Failed'); } diff --git a/lib/widgets/common/action_delegates/permission_aware.dart b/lib/widgets/common/action_delegates/permission_aware.dart new file mode 100644 index 000000000..d04d6f15f --- /dev/null +++ b/lib/widgets/common/action_delegates/permission_aware.dart @@ -0,0 +1,55 @@ +import 'package:aves/model/image_entry.dart'; +import 'package:aves/services/android_file_service.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:tuple/tuple.dart'; + +mixin PermissionAwareMixin { + Future checkStoragePermission(BuildContext context, Iterable entries) async { + final byVolume = groupBy(entries.where((e) => e.path != null), (e) => androidFileUtils.getStorageVolume(e.path)); + final removableVolumes = byVolume.keys.where((v) => v.isRemovable); + final volumePermissions = await Future.wait>( + removableVolumes.map( + (volume) => AndroidFileService.hasGrantedPermissionToVolumeRoot(volume.path).then( + (granted) => Tuple2(volume, granted), + ), + ), + ); + final ungrantedVolumes = volumePermissions.where((t) => !t.item2).map((t) => t.item1).toList(); + while (ungrantedVolumes.isNotEmpty) { + final volume = ungrantedVolumes.first; + final confirmed = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Storage Volume Access'), + content: Text('Please select the root directory of “${volume.description}” in the next screen, so that this app can access it and complete your request.'), + actions: [ + FlatButton( + onPressed: () => Navigator.pop(context), + child: const Text('CANCEL'), + ), + FlatButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('OK'), + ), + ], + ); + }, + ); + // abort if the user cancels in Flutter + if (confirmed == null || !confirmed) return false; + + final granted = await AndroidFileService.requestVolumeAccess(volume.path); + debugPrint('$runtimeType _checkStoragePermission with volume=${volume.path} got granted=$granted'); + if (granted) { + ungrantedVolumes.remove(volume); + } else { + // abort if the user denies access from the native dialog + return false; + } + } + return true; + } +} diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart new file mode 100644 index 000000000..d70c34bf2 --- /dev/null +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -0,0 +1,152 @@ +import 'dart:async'; + +import 'package:aves/model/collection_lens.dart'; +import 'package:aves/model/image_entry.dart'; +import 'package:aves/services/android_app_service.dart'; +import 'package:aves/services/image_file_service.dart'; +import 'package:aves/widgets/common/action_delegates/permission_aware.dart'; +import 'package:aves/widgets/common/entry_actions.dart'; +import 'package:collection/collection.dart'; +import 'package:flushbar/flushbar.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:percent_indicator/circular_percent_indicator.dart'; + +class SelectionActionDelegate with PermissionAwareMixin { + final CollectionLens collection; + + SelectionActionDelegate({ + @required this.collection, + }); + + void onActionSelected(BuildContext context, EntryAction action) { + switch (action) { + case EntryAction.delete: + _showDeleteDialog(context); + break; + case EntryAction.share: + _share(); + break; + default: + break; + } + } + + void _showDeleteDialog(BuildContext context) async { + final selection = collection.selection.toList(); + final count = selection.length; + + final confirmed = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + content: Text('Are you sure you want to delete ${Intl.plural(count, one: 'this item', other: 'these ${count} items')}?'), + actions: [ + FlatButton( + onPressed: () => Navigator.pop(context), + child: const Text('CANCEL'), + ), + FlatButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('DELETE'), + ), + ], + ); + }, + ); + if (confirmed == null || !confirmed) return; + + if (!await checkStoragePermission(context, selection)) return; + + _showOpReport( + context: context, + selection: selection, + opStream: ImageFileService.delete(selection), + onDone: (processed) { + final deletedUris = processed.where((e) => e.success).map((e) => e.uri); + final deletedCount = deletedUris.length; + final selectionCount = selection.length; + if (deletedCount < selectionCount) { + _showFeedback(context, 'Failed to delete ${selectionCount - deletedCount} items'); + } + if (deletedCount > 0) { + collection.source.removeEntries(selection.where((e) => deletedUris.contains(e.uri))); + } + collection.browse(); + }, + ); + } + + void _share() { + final urisByMimeType = groupBy(collection.selection, (e) => e.mimeType).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); + AndroidAppService.share(urisByMimeType); + } + + // selection action report overlay + + OverlayEntry _opReportOverlayEntry; + + static const _overlayAnimationDuration = Duration(milliseconds: 300); + + void _showOpReport({ + @required BuildContext context, + @required List selection, + @required Stream opStream, + @required void Function(Set processed) onDone, + }) { + final processed = {}; + _opReportOverlayEntry = OverlayEntry( + builder: (context) { + return StreamBuilder( + stream: opStream, + builder: (context, snapshot) { + if (snapshot.hasData) { + processed.add(snapshot.data); + } + + Widget child = const SizedBox.shrink(); + if (snapshot.hasError || snapshot.connectionState == ConnectionState.done) { + _hideOpReportOverlay().then((_) => onDone(processed)); + } else if (snapshot.connectionState == ConnectionState.active) { + final percent = processed.length.toDouble() / selection.length; + child = CircularPercentIndicator( + percent: percent, + lineWidth: 16, + radius: 160, + backgroundColor: Colors.white24, + progressColor: Theme.of(context).accentColor, + animation: true, + center: Text(NumberFormat.percentPattern().format(percent)), + animateFromLastPercent: true, + ); + } + return AnimatedSwitcher( + duration: _overlayAnimationDuration, + child: child, + ); + }); + }, + ); + Overlay.of(context).insert(_opReportOverlayEntry); + } + + Future _hideOpReportOverlay() async { + await Future.delayed(_overlayAnimationDuration); + _opReportOverlayEntry.remove(); + _opReportOverlayEntry = null; + } + + void _showFeedback(BuildContext context, String message) { + Flushbar( + message: message, + margin: const EdgeInsets.all(8), + borderRadius: 8, + borderColor: Colors.white30, + borderWidth: 0.5, + duration: const Duration(seconds: 2), + flushbarPosition: FlushbarPosition.TOP, + animationDuration: const Duration(milliseconds: 600), + ).show(context); + } +} diff --git a/lib/widgets/debug_page.dart b/lib/widgets/debug_page.dart index 7d3bdde33..a69058056 100644 --- a/lib/widgets/debug_page.dart +++ b/lib/widgets/debug_page.dart @@ -4,12 +4,15 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/settings.dart'; +import 'package:aves/services/android_file_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; +import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:tuple/tuple.dart'; class DebugPage extends StatefulWidget { final CollectionSource source; @@ -26,6 +29,7 @@ class DebugPageState extends State { Future> _dbMetadataLoader; Future> _dbAddressLoader; Future> _dbFavouritesLoader; + Future>> _volumePermissionLoader; List get entries => widget.source.entries; @@ -33,6 +37,7 @@ class DebugPageState extends State { void initState() { super.initState(); _startDbReport(); + _checkVolumePermissions(); } @override @@ -50,7 +55,31 @@ class DebugPageState extends State { padding: const EdgeInsets.all(8), children: [ const Text('Storage'), - ...AndroidFileUtils.storageVolumes.map((v) => Text('${v.description}: ${v.path} (removable: ${v.isRemovable})')), + FutureBuilder( + future: _volumePermissionLoader, + builder: (context, AsyncSnapshot>> snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); + final permissions = snapshot.data; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...AndroidFileUtils.storageVolumes.expand((v) => [ + const SizedBox(height: 16), + Text(v.path), + InfoRowGroup({ + 'description': '${v.description}', + 'isEmulated': '${v.isEmulated}', + 'isPrimary': '${v.isPrimary}', + 'isRemovable': '${v.isRemovable}', + 'state': '${v.state}', + 'permission': '${permissions.firstWhere((t) => t.item1 == v.path, orElse: () => null)?.item2 ?? false}', + }), + ]) + ], + ); + }, + ), const Divider(), Row( children: [ @@ -209,4 +238,14 @@ class DebugPageState extends State { _dbFavouritesLoader = metadataDb.loadFavourites(); setState(() {}); } + + void _checkVolumePermissions() { + _volumePermissionLoader = Future.wait>( + AndroidFileUtils.storageVolumes.map( + (volume) => AndroidFileService.hasGrantedPermissionToVolumeRoot(volume.path).then( + (value) => Tuple2(volume.path, value), + ), + ), + ); + } } diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index bc2ffe733..99ba128d8 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -9,7 +9,7 @@ import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/album/collection_page.dart'; import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; -import 'package:aves/widgets/common/entry_action_delegate.dart'; +import 'package:aves/widgets/common/action_delegates/entry_action_delegate.dart'; import 'package:aves/widgets/fullscreen/image_page.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/overlay/bottom.dart';