check storage permission before platform calls

This commit is contained in:
Thibault Deckers 2020-04-27 18:23:50 +09:00
parent c1d6b95829
commit 836e7fe4d0
16 changed files with 452 additions and 145 deletions

View file

@ -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);
}
}
}

View file

@ -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());
}
}

View file

@ -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;

View file

@ -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");

View file

@ -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<Integer, Runnable> pendingPermissionMap = new ConcurrentHashMap<>();
private static ConcurrentHashMap<Integer, Pair<Runnable, Runnable>> 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<Runnable, Runnable> 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<Uri> 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 + ":"
);
}
}

View file

@ -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<List<Map>> getStorageVolumes() async {
try {
@ -13,4 +17,37 @@ class AndroidFileService {
}
return [];
}
static Future<bool> hasGrantedPermissionToVolumeRoot(String path) async {
try {
final result = await platform.invokeMethod('hasGrantedPermissionToVolumeRoot', <String, dynamic>{
'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<bool> requestVolumeAccess(String volumePath) async {
try {
final completer = Completer<bool>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'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;
}
}

View file

@ -15,7 +15,7 @@ class AndroidFileUtils {
Future<void> 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) {

View file

@ -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<double> appBarHeightNotifier;
@ -36,7 +31,7 @@ class CollectionAppBar extends StatefulWidget {
class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin {
final TextEditingController _searchFieldController = TextEditingController();
SelectionActionDelegate _actionDelegate;
AnimationController _browseToSelectAnimation;
CollectionLens get collection => widget.collection;
@ -46,6 +41,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> 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<CollectionAppBar> 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<CollectionAppBar> 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<ImageEntry, String>(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<ImageEntry> selection,
@required Stream<ImageOpEvent> opStream,
@required void Function(Set<ImageOpEvent> processed) onDone,
}) {
final processed = <ImageOpEvent>{};
_opReportOverlayEntry = OverlayEntry(
builder: (context) {
return StreamBuilder<ImageOpEvent>(
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<void> _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 }

View file

@ -99,7 +99,7 @@ class _CollectionDrawerState extends State<CollectionDrawer> {
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,

View file

@ -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,

View file

@ -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(

View file

@ -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<void> _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');
}

View file

@ -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<bool> checkStoragePermission(BuildContext context, Iterable<ImageEntry> 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<Tuple2<StorageVolume, bool>>(
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<bool>(
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;
}
}

View file

@ -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<bool>(
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<ImageEntry, String>(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<ImageEntry> selection,
@required Stream<ImageOpEvent> opStream,
@required void Function(Set<ImageOpEvent> processed) onDone,
}) {
final processed = <ImageOpEvent>{};
_opReportOverlayEntry = OverlayEntry(
builder: (context) {
return StreamBuilder<ImageOpEvent>(
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<void> _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);
}
}

View file

@ -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<DebugPage> {
Future<List<CatalogMetadata>> _dbMetadataLoader;
Future<List<AddressDetails>> _dbAddressLoader;
Future<List<FavouriteRow>> _dbFavouritesLoader;
Future<List<Tuple2<String, bool>>> _volumePermissionLoader;
List<ImageEntry> get entries => widget.source.entries;
@ -33,6 +37,7 @@ class DebugPageState extends State<DebugPage> {
void initState() {
super.initState();
_startDbReport();
_checkVolumePermissions();
}
@override
@ -50,7 +55,31 @@ class DebugPageState extends State<DebugPage> {
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<List<Tuple2<String, bool>>> 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<DebugPage> {
_dbFavouritesLoader = metadataDb.loadFavourites();
setState(() {});
}
void _checkVolumePermissions() {
_volumePermissionLoader = Future.wait<Tuple2<String, bool>>(
AndroidFileUtils.storageVolumes.map(
(volume) => AndroidFileService.hasGrantedPermissionToVolumeRoot(volume.path).then(
(value) => Tuple2(volume.path, value),
),
),
);
}
}

View file

@ -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';