check storage permission before platform calls
This commit is contained in:
parent
c1d6b95829
commit
836e7fe4d0
16 changed files with 452 additions and 145 deletions
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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");
|
||||
|
|
|
@ -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 + ":"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
55
lib/widgets/common/action_delegates/permission_aware.dart
Normal file
55
lib/widgets/common/action_delegates/permission_aware.dart
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
Loading…
Reference in a new issue