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 app.loup.streams_channel.StreamsChannel;
|
||||||
import deckers.thibault.aves.channelhandlers.AppAdapterHandler;
|
import deckers.thibault.aves.channelhandlers.AppAdapterHandler;
|
||||||
import deckers.thibault.aves.channelhandlers.FileAdapterHandler;
|
|
||||||
import deckers.thibault.aves.channelhandlers.ImageByteStreamHandler;
|
import deckers.thibault.aves.channelhandlers.ImageByteStreamHandler;
|
||||||
import deckers.thibault.aves.channelhandlers.ImageFileHandler;
|
import deckers.thibault.aves.channelhandlers.ImageFileHandler;
|
||||||
import deckers.thibault.aves.channelhandlers.ImageOpStreamHandler;
|
import deckers.thibault.aves.channelhandlers.ImageOpStreamHandler;
|
||||||
import deckers.thibault.aves.channelhandlers.MediaStoreStreamHandler;
|
import deckers.thibault.aves.channelhandlers.MediaStoreStreamHandler;
|
||||||
import deckers.thibault.aves.channelhandlers.MetadataHandler;
|
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.Constants;
|
||||||
import deckers.thibault.aves.utils.Env;
|
import deckers.thibault.aves.utils.Env;
|
||||||
import deckers.thibault.aves.utils.PermissionManager;
|
import deckers.thibault.aves.utils.PermissionManager;
|
||||||
|
@ -43,12 +44,15 @@ public class MainActivity extends FlutterActivity {
|
||||||
MediaStoreStreamHandler mediaStoreStreamHandler = new MediaStoreStreamHandler();
|
MediaStoreStreamHandler mediaStoreStreamHandler = new MediaStoreStreamHandler();
|
||||||
|
|
||||||
FlutterView messenger = getFlutterView();
|
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, AppAdapterHandler.CHANNEL).setMethodCallHandler(new AppAdapterHandler(this));
|
||||||
new MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(new ImageFileHandler(this, mediaStoreStreamHandler));
|
new MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(new ImageFileHandler(this, mediaStoreStreamHandler));
|
||||||
new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this));
|
new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this));
|
||||||
new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler);
|
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);
|
final StreamsChannel imageByteStreamChannel = new StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL);
|
||||||
imageByteStreamChannel.setStreamHandlerFactory(arguments -> new ImageByteStreamHandler(this, arguments));
|
imageByteStreamChannel.setStreamHandlerFactory(arguments -> new ImageByteStreamHandler(this, arguments));
|
||||||
|
|
||||||
|
@ -79,22 +83,23 @@ public class MainActivity extends FlutterActivity {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||||
if (requestCode == Constants.SD_CARD_PERMISSION_REQUEST_CODE && resultCode == RESULT_OK) {
|
if (requestCode == Constants.SD_CARD_PERMISSION_REQUEST_CODE) {
|
||||||
Uri sdCardDocumentUri = data.getData();
|
if (resultCode != RESULT_OK || data.getData() == null) {
|
||||||
if (sdCardDocumentUri == null) {
|
PermissionManager.onPermissionResult(requestCode, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Env.setSdCardDocumentUri(this, sdCardDocumentUri.toString());
|
Uri treeUri = data.getData();
|
||||||
|
Env.setSdCardDocumentUri(this, treeUri.toString());
|
||||||
|
|
||||||
// save access permissions across reboots
|
// save access permissions across reboots
|
||||||
final int takeFlags = data.getFlags()
|
final int takeFlags = data.getFlags()
|
||||||
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||||
getContentResolver().takePersistableUriPermission(sdCardDocumentUri, takeFlags);
|
getContentResolver().takePersistableUriPermission(treeUri, takeFlags);
|
||||||
|
|
||||||
// resume pending action
|
// 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 java.util.Map;
|
||||||
|
|
||||||
import deckers.thibault.aves.utils.Env;
|
import deckers.thibault.aves.utils.Env;
|
||||||
|
import deckers.thibault.aves.utils.PermissionManager;
|
||||||
import io.flutter.plugin.common.MethodCall;
|
import io.flutter.plugin.common.MethodCall;
|
||||||
import io.flutter.plugin.common.MethodChannel;
|
import io.flutter.plugin.common.MethodChannel;
|
||||||
|
|
||||||
public class FileAdapterHandler implements MethodChannel.MethodCallHandler {
|
public class StorageHandler implements MethodChannel.MethodCallHandler {
|
||||||
public static final String CHANNEL = "deckers.thibault/aves/file";
|
public static final String CHANNEL = "deckers.thibault/aves/storage";
|
||||||
|
|
||||||
private Activity activity;
|
private Activity activity;
|
||||||
|
|
||||||
public FileAdapterHandler(Activity activity) {
|
public StorageHandler(Activity activity) {
|
||||||
this.activity = activity;
|
this.activity = activity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,6 +55,12 @@ public class FileAdapterHandler implements MethodChannel.MethodCallHandler {
|
||||||
result.success(volumes);
|
result.success(volumes);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "hasGrantedPermissionToVolumeRoot": {
|
||||||
|
String path = call.argument("path");
|
||||||
|
boolean granted = PermissionManager.hasGrantedPermissionToVolumeRoot(activity, path);
|
||||||
|
result.success(granted);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
result.notImplemented();
|
result.notImplemented();
|
||||||
break;
|
break;
|
|
@ -1,6 +1,7 @@
|
||||||
package deckers.thibault.aves.utils;
|
package deckers.thibault.aves.utils;
|
||||||
|
|
||||||
import android.media.MediaMetadataRetriever;
|
import android.media.MediaMetadataRetriever;
|
||||||
|
import android.os.Build;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
@ -17,7 +18,9 @@ public class Constants {
|
||||||
put(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO, "Has Audio");
|
put(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO, "Has Audio");
|
||||||
put(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO, "Has Video");
|
put(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO, "Has Video");
|
||||||
put(MediaMetadataRetriever.METADATA_KEY_BITRATE, "Bitrate");
|
put(MediaMetadataRetriever.METADATA_KEY_BITRATE, "Bitrate");
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
put(MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT, "Frame Count");
|
put(MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT, "Frame Count");
|
||||||
|
}
|
||||||
put(MediaMetadataRetriever.METADATA_KEY_DATE, "Date");
|
put(MediaMetadataRetriever.METADATA_KEY_DATE, "Date");
|
||||||
put(MediaMetadataRetriever.METADATA_KEY_LOCATION, "Location");
|
put(MediaMetadataRetriever.METADATA_KEY_LOCATION, "Location");
|
||||||
put(MediaMetadataRetriever.METADATA_KEY_YEAR, "Year");
|
put(MediaMetadataRetriever.METADATA_KEY_YEAR, "Year");
|
||||||
|
|
|
@ -2,23 +2,30 @@ package deckers.thibault.aves.utils;
|
||||||
|
|
||||||
import android.annotation.TargetApi;
|
import android.annotation.TargetApi;
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.UriPermission;
|
import android.content.UriPermission;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
import android.os.storage.StorageManager;
|
||||||
|
import android.os.storage.StorageVolume;
|
||||||
|
import android.provider.DocumentsContract;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.appcompat.app.AlertDialog;
|
import androidx.appcompat.app.AlertDialog;
|
||||||
import androidx.core.app.ActivityCompat;
|
import androidx.core.app.ActivityCompat;
|
||||||
|
import androidx.core.util.Pair;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
public class PermissionManager {
|
public class PermissionManager {
|
||||||
private static final String LOG_TAG = Utils.createLogTag(PermissionManager.class);
|
private static final String LOG_TAG = Utils.createLogTag(PermissionManager.class);
|
||||||
|
|
||||||
// permission request code to pending runnable
|
// 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
|
// check access permission to SD card directory & return its content URI if available
|
||||||
public static Uri getSdCardTreeUri(Activity activity) {
|
public static Uri getSdCardTreeUri(Activity activity) {
|
||||||
|
@ -34,20 +41,64 @@ public class PermissionManager {
|
||||||
new AlertDialog.Builder(activity)
|
new AlertDialog.Builder(activity)
|
||||||
.setTitle("SD Card Access")
|
.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.")
|
.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) -> {
|
.setPositiveButton(android.R.string.ok, (dialog, button) -> requestVolumeAccess(activity, null, pendingRunnable, null))
|
||||||
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);
|
|
||||||
})
|
|
||||||
.show();
|
.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void onPermissionGranted(int requestCode) {
|
public static void requestVolumeAccess(Activity activity, String volumePath, Runnable onGranted, Runnable onDenied) {
|
||||||
Runnable runnable = pendingPermissionMap.remove(requestCode);
|
Log.i(LOG_TAG, "request user to select and grant access permission to volume=" + volumePath);
|
||||||
if (runnable != null) {
|
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();
|
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/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:streams_channel/streams_channel.dart';
|
||||||
|
|
||||||
class AndroidFileService {
|
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 {
|
static Future<List<Map>> getStorageVolumes() async {
|
||||||
try {
|
try {
|
||||||
|
@ -13,4 +17,37 @@ class AndroidFileService {
|
||||||
}
|
}
|
||||||
return [];
|
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 {
|
Future<void> init() async {
|
||||||
storageVolumes = (await AndroidFileService.getStorageVolumes()).map((map) => StorageVolume.fromMap(map)).toList();
|
storageVolumes = (await AndroidFileService.getStorageVolumes()).map((map) => StorageVolume.fromMap(map)).toList();
|
||||||
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files'
|
// 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');
|
dcimPath = join(externalStorage, 'DCIM');
|
||||||
downloadPath = join(externalStorage, 'Download');
|
downloadPath = join(externalStorage, 'Download');
|
||||||
moviesPath = join(externalStorage, 'Movies');
|
moviesPath = join(externalStorage, 'Movies');
|
||||||
|
@ -34,7 +34,7 @@ class AndroidFileUtils {
|
||||||
|
|
||||||
StorageVolume getStorageVolume(String path) => storageVolumes.firstWhere((v) => path.startsWith(v.path), orElse: () => null);
|
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) {
|
AlbumType getAlbumType(String albumDirectory) {
|
||||||
if (albumDirectory != null) {
|
if (albumDirectory != null) {
|
||||||
|
|
|
@ -1,24 +1,19 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/collection_lens.dart';
|
import 'package:aves/model/collection_lens.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
|
||||||
import 'package:aves/model/settings.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/utils/constants.dart';
|
||||||
import 'package:aves/widgets/album/filter_bar.dart';
|
import 'package:aves/widgets/album/filter_bar.dart';
|
||||||
import 'package:aves/widgets/album/search/search_delegate.dart';
|
import 'package:aves/widgets/album/search/search_delegate.dart';
|
||||||
import 'package:aves/widgets/common/entry_actions.dart';
|
import 'package:aves/widgets/common/entry_actions.dart';
|
||||||
import 'package:aves/widgets/common/menu_row.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:aves/widgets/stats/stats.dart';
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flushbar/flushbar.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:outline_material_icons/outline_material_icons.dart';
|
import 'package:outline_material_icons/outline_material_icons.dart';
|
||||||
import 'package:pedantic/pedantic.dart';
|
import 'package:pedantic/pedantic.dart';
|
||||||
import 'package:percent_indicator/circular_percent_indicator.dart';
|
|
||||||
|
|
||||||
class CollectionAppBar extends StatefulWidget {
|
class CollectionAppBar extends StatefulWidget {
|
||||||
final ValueNotifier<double> appBarHeightNotifier;
|
final ValueNotifier<double> appBarHeightNotifier;
|
||||||
|
@ -36,7 +31,7 @@ class CollectionAppBar extends StatefulWidget {
|
||||||
|
|
||||||
class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin {
|
class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin {
|
||||||
final TextEditingController _searchFieldController = TextEditingController();
|
final TextEditingController _searchFieldController = TextEditingController();
|
||||||
|
SelectionActionDelegate _actionDelegate;
|
||||||
AnimationController _browseToSelectAnimation;
|
AnimationController _browseToSelectAnimation;
|
||||||
|
|
||||||
CollectionLens get collection => widget.collection;
|
CollectionLens get collection => widget.collection;
|
||||||
|
@ -46,6 +41,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_actionDelegate = SelectionActionDelegate(
|
||||||
|
collection: collection,
|
||||||
|
);
|
||||||
_browseToSelectAnimation = AnimationController(
|
_browseToSelectAnimation = AnimationController(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
|
@ -160,7 +158,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
icon: Icon(action.getIcon()),
|
icon: Icon(action.getIcon()),
|
||||||
onPressed: collection.selection.isEmpty ? null : () => _onSelectionActionSelected(action),
|
onPressed: collection.selection.isEmpty ? null : () => _actionDelegate.onActionSelected(context, action),
|
||||||
tooltip: action.getText(),
|
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 }
|
enum CollectionAction { select, stats, groupByAlbum, groupByMonth, groupByDay, sortByDate, sortBySize, sortByName }
|
||||||
|
|
|
@ -99,7 +99,7 @@ class _CollectionDrawerState extends State<CollectionDrawer> {
|
||||||
source: source,
|
source: source,
|
||||||
leading: IconUtils.getAlbumIcon(context: context, album: album),
|
leading: IconUtils.getAlbumIcon(context: context, album: album),
|
||||||
title: uniqueName,
|
title: uniqueName,
|
||||||
trailing: androidFileUtils.isOnSD(album)
|
trailing: androidFileUtils.isOnRemovableStorage(album)
|
||||||
? const Icon(
|
? const Icon(
|
||||||
OMIcons.sdStorage,
|
OMIcons.sdStorage,
|
||||||
size: 16,
|
size: 16,
|
||||||
|
|
|
@ -29,7 +29,7 @@ class AlbumSectionHeader extends StatelessWidget {
|
||||||
sectionKey: folderPath,
|
sectionKey: folderPath,
|
||||||
leading: albumIcon,
|
leading: albumIcon,
|
||||||
title: albumName,
|
title: albumName,
|
||||||
trailing: androidFileUtils.isOnSD(folderPath)
|
trailing: androidFileUtils.isOnRemovableStorage(folderPath)
|
||||||
? const Icon(
|
? const Icon(
|
||||||
OMIcons.sdStorage,
|
OMIcons.sdStorage,
|
||||||
size: 16,
|
size: 16,
|
||||||
|
|
|
@ -66,7 +66,7 @@ class SectionHeader extends StatelessWidget {
|
||||||
if (sectionKey is String) {
|
if (sectionKey is String) {
|
||||||
// only compute height for album headers, as they're the only likely ones to split on multiple lines
|
// 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 hasLeading = androidFileUtils.getAlbumType(sectionKey) != AlbumType.Default;
|
||||||
final hasTrailing = androidFileUtils.isOnSD(sectionKey);
|
final hasTrailing = androidFileUtils.isOnRemovableStorage(sectionKey);
|
||||||
final text = source.getUniqueAlbumName(sectionKey);
|
final text = source.getUniqueAlbumName(sectionKey);
|
||||||
final maxWidth = scrollableWidth - TitleSectionHeader.padding.horizontal;
|
final maxWidth = scrollableWidth - TitleSectionHeader.padding.horizontal;
|
||||||
final para = RenderParagraph(
|
final para = RenderParagraph(
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'package:aves/model/collection_lens.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/services/android_app_service.dart';
|
import 'package:aves/services/android_app_service.dart';
|
||||||
import 'package:aves/services/image_file_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/entry_actions.dart';
|
||||||
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
|
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
|
||||||
import 'package:aves/widgets/fullscreen/debug.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:pedantic/pedantic.dart';
|
||||||
import 'package:printing/printing.dart';
|
import 'package:printing/printing.dart';
|
||||||
|
|
||||||
class EntryActionDelegate {
|
class EntryActionDelegate with PermissionAwareMixin {
|
||||||
final CollectionLens collection;
|
final CollectionLens collection;
|
||||||
final VoidCallback showInfo;
|
final VoidCallback showInfo;
|
||||||
|
|
||||||
|
@ -122,6 +123,8 @@ class EntryActionDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _rotate(BuildContext context, ImageEntry entry, {@required bool clockwise}) async {
|
Future<void> _rotate(BuildContext context, ImageEntry entry, {@required bool clockwise}) async {
|
||||||
|
if (!await checkStoragePermission(context, [entry])) return;
|
||||||
|
|
||||||
final success = await entry.rotate(clockwise: clockwise);
|
final success = await entry.rotate(clockwise: clockwise);
|
||||||
if (!success) _showFeedback(context, 'Failed');
|
if (!success) _showFeedback(context, 'Failed');
|
||||||
}
|
}
|
||||||
|
@ -146,6 +149,9 @@ class EntryActionDelegate {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (confirmed == null || !confirmed) return;
|
if (confirmed == null || !confirmed) return;
|
||||||
|
|
||||||
|
if (!await checkStoragePermission(context, [entry])) return;
|
||||||
|
|
||||||
if (!await entry.delete()) {
|
if (!await entry.delete()) {
|
||||||
_showFeedback(context, 'Failed');
|
_showFeedback(context, 'Failed');
|
||||||
} else if (hasCollection) {
|
} else if (hasCollection) {
|
||||||
|
@ -184,6 +190,9 @@ class EntryActionDelegate {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
if (newName == null || newName.isEmpty) return;
|
if (newName == null || newName.isEmpty) return;
|
||||||
|
|
||||||
|
if (!await checkStoragePermission(context, [entry])) return;
|
||||||
|
|
||||||
_showFeedback(context, await entry.rename(newName) ? 'Done!' : 'Failed');
|
_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/image_metadata.dart';
|
||||||
import 'package:aves/model/metadata_db.dart';
|
import 'package:aves/model/metadata_db.dart';
|
||||||
import 'package:aves/model/settings.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/android_file_utils.dart';
|
||||||
import 'package:aves/utils/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/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/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
class DebugPage extends StatefulWidget {
|
class DebugPage extends StatefulWidget {
|
||||||
final CollectionSource source;
|
final CollectionSource source;
|
||||||
|
@ -26,6 +29,7 @@ class DebugPageState extends State<DebugPage> {
|
||||||
Future<List<CatalogMetadata>> _dbMetadataLoader;
|
Future<List<CatalogMetadata>> _dbMetadataLoader;
|
||||||
Future<List<AddressDetails>> _dbAddressLoader;
|
Future<List<AddressDetails>> _dbAddressLoader;
|
||||||
Future<List<FavouriteRow>> _dbFavouritesLoader;
|
Future<List<FavouriteRow>> _dbFavouritesLoader;
|
||||||
|
Future<List<Tuple2<String, bool>>> _volumePermissionLoader;
|
||||||
|
|
||||||
List<ImageEntry> get entries => widget.source.entries;
|
List<ImageEntry> get entries => widget.source.entries;
|
||||||
|
|
||||||
|
@ -33,6 +37,7 @@ class DebugPageState extends State<DebugPage> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_startDbReport();
|
_startDbReport();
|
||||||
|
_checkVolumePermissions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -50,7 +55,31 @@ class DebugPageState extends State<DebugPage> {
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
children: [
|
children: [
|
||||||
const Text('Storage'),
|
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(),
|
const Divider(),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
@ -209,4 +238,14 @@ class DebugPageState extends State<DebugPage> {
|
||||||
_dbFavouritesLoader = metadataDb.loadFavourites();
|
_dbFavouritesLoader = metadataDb.loadFavourites();
|
||||||
setState(() {});
|
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/album/collection_page.dart';
|
||||||
import 'package:aves/widgets/common/image_providers/thumbnail_provider.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/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/image_page.dart';
|
||||||
import 'package:aves/widgets/fullscreen/info/info_page.dart';
|
import 'package:aves/widgets/fullscreen/info/info_page.dart';
|
||||||
import 'package:aves/widgets/fullscreen/overlay/bottom.dart';
|
import 'package:aves/widgets/fullscreen/overlay/bottom.dart';
|
||||||
|
|
Loading…
Reference in a new issue