From d368fbe65c81bac2eb752dea5c4f05d4ecb242ed Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 26 Jul 2020 01:12:22 +0900 Subject: [PATCH] API 30: handle access at directory level, request max but can process with min --- .../deckers/thibault/aves/MainActivity.java | 4 +- .../aves/channel/calls/StorageHandler.java | 24 +- .../channel/streams/ImageOpStreamHandler.java | 12 +- .../streams/MediaStoreStreamHandler.java | 10 +- .../streams/StorageAccessStreamHandler.java | 4 +- .../thibault/aves/model/SourceImageEntry.java | 13 +- .../aves/model/provider/ImageProvider.java | 43 ++-- .../provider/MediaStoreImageProvider.java | 60 ++--- .../aves/utils/PermissionManager.java | 129 ++++++++--- .../thibault/aves/utils/StorageUtils.java | 216 ++++++++++++------ lib/services/android_app_service.dart | 2 +- lib/services/android_file_service.dart | 14 +- .../action_delegates/permission_aware.dart | 40 ++-- .../selection_action_delegate.dart | 2 +- lib/widgets/common/app_bar_subtitle.dart | 40 ++-- lib/widgets/debug_page.dart | 46 +--- .../fullscreen/info/location_section.dart | 2 +- lib/widgets/stats/stats.dart | 2 +- 18 files changed, 380 insertions(+), 283 deletions(-) diff --git a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java index e7296457b..f248ce1cf 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -100,7 +100,7 @@ public class MainActivity extends FlutterActivity { protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == PermissionManager.VOLUME_ROOT_PERMISSION_REQUEST_CODE) { if (resultCode != RESULT_OK || data.getData() == null) { - PermissionManager.onPermissionResult(this, requestCode, null); + PermissionManager.onPermissionResult(requestCode, null); return; } @@ -113,7 +113,7 @@ public class MainActivity extends FlutterActivity { getContentResolver().takePersistableUriPermission(treeUri, takeFlags); // resume pending action - PermissionManager.onPermissionResult(this, requestCode, treeUri); + PermissionManager.onPermissionResult(requestCode, treeUri); } } } diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java index 764c033bb..060dc3323 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java @@ -1,6 +1,6 @@ package deckers.thibault.aves.channel.calls; -import android.app.Activity; +import android.content.Context; import android.os.Build; import android.os.storage.StorageManager; import android.os.storage.StorageVolume; @@ -22,10 +22,10 @@ import io.flutter.plugin.common.MethodChannel; public class StorageHandler implements MethodChannel.MethodCallHandler { public static final String CHANNEL = "deckers.thibault/aves/storage"; - private Activity activity; + private Context context; - public StorageHandler(Activity activity) { - this.activity = activity; + public StorageHandler(Context context) { + this.context = context; } @Override @@ -42,12 +42,12 @@ public class StorageHandler implements MethodChannel.MethodCallHandler { result.success(volumes); break; } - case "requireVolumeAccessDialog": { - String path = call.argument("path"); - if (path == null) { - result.success(true); + case "getInaccessibleDirectories": { + List dirPaths = call.argument("dirPaths"); + if (dirPaths == null) { + result.error("getInaccessibleDirectories-args", "failed because of missing arguments", null); } else { - result.success(PermissionManager.requireVolumeAccessDialog(activity, path)); + result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths)); } break; } @@ -60,15 +60,15 @@ public class StorageHandler implements MethodChannel.MethodCallHandler { @RequiresApi(api = Build.VERSION_CODES.N) private List> getStorageVolumes() { List> volumes = new ArrayList<>(); - StorageManager sm = activity.getSystemService(StorageManager.class); + StorageManager sm = context.getSystemService(StorageManager.class); if (sm != null) { - for (String volumePath : StorageUtils.getVolumePaths(activity)) { + for (String volumePath : StorageUtils.getVolumePaths(context)) { try { StorageVolume volume = sm.getStorageVolume(new File(volumePath)); if (volume != null) { Map volumeMap = new HashMap<>(); volumeMap.put("path", volumePath); - volumeMap.put("description", volume.getDescription(activity)); + volumeMap.put("description", volume.getDescription(context)); volumeMap.put("isPrimary", volume.isPrimary()); volumeMap.put("isRemovable", volume.isRemovable()); volumeMap.put("isEmulated", volume.isEmulated()); diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java index 82f3d3825..c6d3e3fc3 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java @@ -1,6 +1,6 @@ package deckers.thibault.aves.channel.streams; -import android.app.Activity; +import android.content.Context; import android.net.Uri; import android.os.Handler; import android.os.Looper; @@ -25,7 +25,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { public static final String CHANNEL = "deckers.thibault/aves/imageopstream"; - private Activity activity; + private Context context; private EventChannel.EventSink eventSink; private Handler handler; private Map argMap; @@ -33,8 +33,8 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { private String op; @SuppressWarnings("unchecked") - public ImageOpStreamHandler(Activity activity, Object arguments) { - this.activity = activity; + public ImageOpStreamHandler(Context context, Object arguments) { + this.context = context; if (arguments instanceof Map) { argMap = (Map) arguments; this.op = (String) argMap.get("op"); @@ -100,7 +100,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { } List entries = entryMapList.stream().map(AvesImageEntry::new).collect(Collectors.toList()); - provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() { + provider.moveMultiple(context, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() { @Override public void onSuccess(Map fields) { success(fields); @@ -138,7 +138,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { put("uri", uriString); }}; try { - provider.delete(activity, path, uri).get(); + provider.delete(context, path, uri).get(); result.put("success", true); } catch (ExecutionException | InterruptedException e) { Log.w(LOG_TAG, "failed to delete entry with path=" + path, e); diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java index 2d0dfe633..819f83afb 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java @@ -1,6 +1,6 @@ package deckers.thibault.aves.channel.streams; -import android.app.Activity; +import android.content.Context; import android.os.Handler; import android.os.Looper; @@ -12,14 +12,14 @@ import io.flutter.plugin.common.EventChannel; public class MediaStoreStreamHandler implements EventChannel.StreamHandler { public static final String CHANNEL = "deckers.thibault/aves/mediastorestream"; - private Activity activity; + private Context context; private EventChannel.EventSink eventSink; private Handler handler; private Map knownEntries; @SuppressWarnings("unchecked") - public MediaStoreStreamHandler(Activity activity, Object arguments) { - this.activity = activity; + public MediaStoreStreamHandler(Context context, Object arguments) { + this.context = context; if (arguments instanceof Map) { Map argMap = (Map) arguments; this.knownEntries = (Map) argMap.get("knownEntries"); @@ -47,7 +47,7 @@ public class MediaStoreStreamHandler implements EventChannel.StreamHandler { } void fetchAll() { - new MediaStoreImageProvider().fetchAll(activity, knownEntries, this::success); // 350ms + new MediaStoreImageProvider().fetchAll(context, knownEntries, this::success); // 350ms endOfStream(); } } \ No newline at end of file diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java index 810decc4f..d7dac8d63 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java @@ -32,8 +32,8 @@ public class StorageAccessStreamHandler implements EventChannel.StreamHandler { public void onListen(Object o, final EventChannel.EventSink eventSink) { this.eventSink = eventSink; this.handler = new Handler(Looper.getMainLooper()); - Runnable onGranted = () -> success(!PermissionManager.requireVolumeAccessDialog(activity, path)); - Runnable onDenied = () -> success(false); + Runnable onGranted = () -> success(true); // user gave access to a directory, with no guarantee that it matches the specified `path` + Runnable onDenied = () -> success(false); // user cancelled PermissionManager.requestVolumeAccess(activity, path, onGranted, onDenied); } diff --git a/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java b/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java index 50067895b..ade906ac4 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java @@ -8,6 +8,7 @@ import android.media.MediaMetadataRetriever; import android.net.Uri; import android.os.Build; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.drew.imaging.ImageMetadataReader; @@ -53,7 +54,7 @@ public class SourceImageEntry { public SourceImageEntry() { } - public SourceImageEntry(Map map) { + public SourceImageEntry(@NonNull Map map) { this.uri = Uri.parse((String) map.get("uri")); this.path = (String) map.get("path"); this.sourceMimeType = (String) map.get("sourceMimeType"); @@ -121,7 +122,7 @@ public class SourceImageEntry { // expects entry with: uri, mimeType // finds: width, height, orientation/rotation, date, title, duration - public SourceImageEntry fillPreCatalogMetadata(Context context) { + public SourceImageEntry fillPreCatalogMetadata(@NonNull Context context) { fillByMediaMetadataRetriever(context); if (hasSize() && (!isVideo() || hasDuration())) return this; fillByMetadataExtractor(context); @@ -132,7 +133,7 @@ public class SourceImageEntry { // expects entry with: uri, mimeType // finds: width, height, orientation/rotation, date, title, duration - private void fillByMediaMetadataRetriever(Context context) { + private void fillByMediaMetadataRetriever(@NonNull Context context) { MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri); try { String width = null, height = null, rotation = null, durationMillis = null; @@ -182,7 +183,7 @@ public class SourceImageEntry { // expects entry with: uri, mimeType // finds: width, height, orientation, date - private void fillByMetadataExtractor(Context context) { + private void fillByMetadataExtractor(@NonNull Context context) { if (isSvg()) return; try (InputStream is = StorageUtils.openInputStream(context, uri)) { @@ -244,7 +245,7 @@ public class SourceImageEntry { // expects entry with: uri // finds: width, height - private void fillByBitmapDecode(Context context) { + private void fillByBitmapDecode(@NonNull Context context) { if (isSvg()) return; try (InputStream is = StorageUtils.openInputStream(context, uri)) { @@ -260,7 +261,7 @@ public class SourceImageEntry { // convenience method - private static Long toLong(Object o) { + private static Long toLong(@Nullable Object o) { if (o == null) return null; if (o instanceof Integer) return Long.valueOf((Integer) o); return (long) o; diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index 34b912559..547f0367d 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -1,6 +1,5 @@ package deckers.thibault.aves.model.provider; -import android.app.Activity; import android.content.ContentUris; import android.content.Context; import android.database.Cursor; @@ -49,15 +48,15 @@ public abstract class ImageProvider { callback.onFailure(new UnsupportedOperationException()); } - public ListenableFuture delete(final Activity activity, final String path, final Uri uri) { + public ListenableFuture delete(final Context context, final String path, final Uri uri) { return Futures.immediateFailedFuture(new UnsupportedOperationException()); } - public void moveMultiple(final Activity activity, final Boolean copy, final String destinationDir, final List entries, @NonNull final ImageOpCallback callback) { + public void moveMultiple(final Context context, final Boolean copy, final String destinationDir, final List entries, @NonNull final ImageOpCallback callback) { callback.onFailure(new UnsupportedOperationException()); } - public void rename(final Activity activity, final String oldPath, final Uri oldMediaUri, final String mimeType, final String newFilename, final ImageOpCallback callback) { + public void rename(final Context context, final String oldPath, final Uri oldMediaUri, final String mimeType, final String newFilename, final ImageOpCallback callback) { if (oldPath == null) { callback.onFailure(new IllegalArgumentException("entry does not have a path, uri=" + oldMediaUri)); return; @@ -71,7 +70,7 @@ public abstract class ImageProvider { return; } - DocumentFileCompat df = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri); + DocumentFileCompat df = StorageUtils.getDocumentFile(context, oldPath, oldMediaUri); try { boolean renamed = df != null && df.renameTo(newFilename); if (!renamed) { @@ -83,27 +82,27 @@ public abstract class ImageProvider { return; } - MediaScannerConnection.scanFile(activity, new String[]{oldPath}, new String[]{mimeType}, null); - scanNewPath(activity, newFile.getPath(), mimeType, callback); + MediaScannerConnection.scanFile(context, new String[]{oldPath}, new String[]{mimeType}, null); + scanNewPath(context, newFile.getPath(), mimeType, callback); } - public void rotate(final Activity activity, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) { + public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) { switch (mimeType) { case MimeTypes.JPEG: - rotateJpeg(activity, path, uri, clockwise, callback); + rotateJpeg(context, path, uri, clockwise, callback); break; case MimeTypes.PNG: - rotatePng(activity, path, uri, clockwise, callback); + rotatePng(context, path, uri, clockwise, callback); break; default: callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType)); } } - private void rotateJpeg(final Activity activity, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) { + private void rotateJpeg(final Context context, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) { final String mimeType = MimeTypes.JPEG; - final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(activity, path, uri); + final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(context, path, uri); if (originalDocumentFile == null) { callback.onFailure(new Exception("failed to get document file for path=" + path + ", uri=" + uri)); return; @@ -148,7 +147,7 @@ public abstract class ImageProvider { Map newFields = new HashMap<>(); newFields.put("orientationDegrees", orientationDegrees); -// ContentResolver contentResolver = activity.getContentResolver(); +// ContentResolver contentResolver = context.getContentResolver(); // ContentValues values = new ContentValues(); // // from Android Q, media store update needs to be flagged IS_PENDING first // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -163,17 +162,17 @@ public abstract class ImageProvider { // // TODO TLAD catch RecoverableSecurityException // int updatedRowCount = contentResolver.update(uri, values, null, null); // if (updatedRowCount > 0) { - MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields)); + MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields)); // } else { // Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri); // callback.onSuccess(newFields); // } } - private void rotatePng(final Activity activity, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) { + private void rotatePng(final Context context, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) { final String mimeType = MimeTypes.PNG; - final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(activity, path, uri); + final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(context, path, uri); if (originalDocumentFile == null) { callback.onFailure(new Exception("failed to get document file for path=" + path + ", uri=" + uri)); return; @@ -188,7 +187,7 @@ public abstract class ImageProvider { Bitmap originalImage; try { - originalImage = BitmapFactory.decodeStream(StorageUtils.openInputStream(activity, uri)); + originalImage = BitmapFactory.decodeStream(StorageUtils.openInputStream(context, uri)); } catch (FileNotFoundException e) { callback.onFailure(e); return; @@ -220,7 +219,7 @@ public abstract class ImageProvider { newFields.put("width", rotatedWidth); newFields.put("height", rotatedHeight); -// ContentResolver contentResolver = activity.getContentResolver(); +// ContentResolver contentResolver = context.getContentResolver(); // ContentValues values = new ContentValues(); // // from Android Q, media store update needs to be flagged IS_PENDING first // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -235,15 +234,15 @@ public abstract class ImageProvider { // // TODO TLAD catch RecoverableSecurityException // int updatedRowCount = contentResolver.update(uri, values, null, null); // if (updatedRowCount > 0) { - MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields)); + MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields)); // } else { // Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri); // callback.onSuccess(newFields); // } } - protected void scanNewPath(final Activity activity, final String path, final String mimeType, final ImageOpCallback callback) { - MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (newPath, newUri) -> { + protected void scanNewPath(final Context context, final String path, final String mimeType, final ImageOpCallback callback) { + MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (newPath, newUri) -> { Log.d(LOG_TAG, "scanNewPath onScanCompleted with newPath=" + newPath + ", newUri=" + newUri); long contentId = 0; @@ -267,7 +266,7 @@ public abstract class ImageProvider { // we retrieve updated fields as the renamed file became a new entry in the Media Store String[] projection = {MediaStore.MediaColumns.TITLE}; try { - Cursor cursor = activity.getContentResolver().query(contentUri, projection, null, null, null); + Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, null); if (cursor != null) { if (cursor.moveToNext()) { newFields.put("uri", contentUri.toString()); diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index cc7c15401..475cd67b3 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -1,7 +1,6 @@ package deckers.thibault.aves.model.provider; import android.annotation.SuppressLint; -import android.app.Activity; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; @@ -210,14 +209,14 @@ public class MediaStoreImageProvider extends ImageProvider { } @Override - public ListenableFuture delete(final Activity activity, final String path, final Uri mediaUri) { + public ListenableFuture delete(final Context context, final String path, final Uri mediaUri) { SettableFuture future = SettableFuture.create(); if (StorageUtils.requireAccessPermission(path)) { // if the file is on SD card, calling the content resolver delete() removes the entry from the Media Store // but it doesn't delete the file, even if the app has the permission try { - DocumentFileCompat df = StorageUtils.getDocumentFile(activity, path, mediaUri); + DocumentFileCompat df = StorageUtils.getDocumentFile(context, path, mediaUri); if (df != null && df.delete()) { future.set(null); } else { @@ -230,7 +229,7 @@ public class MediaStoreImageProvider extends ImageProvider { } try { - if (activity.getContentResolver().delete(mediaUri, null, null) > 0) { + if (context.getContentResolver().delete(mediaUri, null, null) > 0) { future.set(null); } else { future.setException(new Exception("failed to delete row from content provider")); @@ -242,11 +241,11 @@ public class MediaStoreImageProvider extends ImageProvider { return future; } - private String getVolumeName(final Activity activity, String path) { + private String getVolumeNameForMediaStore(@NonNull Context context, @NonNull String anyPath) { String volumeName = "external"; - StorageManager sm = activity.getSystemService(StorageManager.class); + StorageManager sm = context.getSystemService(StorageManager.class); if (sm != null) { - StorageVolume volume = sm.getStorageVolume(new File(path)); + StorageVolume volume = sm.getStorageVolume(new File(anyPath)); if (volume != null && !volume.isPrimary()) { String uuid = volume.getUuid(); if (uuid != null) { @@ -260,14 +259,14 @@ public class MediaStoreImageProvider extends ImageProvider { } @Override - public void moveMultiple(final Activity activity, final Boolean copy, final String destinationDir, final List entries, @NonNull final ImageOpCallback callback) { - DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(activity, destinationDir); + public void moveMultiple(final Context context, final Boolean copy, final String destinationDir, final List entries, @NonNull final ImageOpCallback callback) { + DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(context, destinationDir); if (destinationDirDocFile == null) { callback.onFailure(new Exception("failed to create directory at path=" + destinationDir)); return; } - MediaStoreMoveDestination destination = new MediaStoreMoveDestination(activity, destinationDir); + MediaStoreMoveDestination destination = new MediaStoreMoveDestination(context, destinationDir); if (destination.volumePath == null) { callback.onFailure(new Exception("failed to set up destination volume path for path=" + destinationDir)); return; @@ -282,14 +281,14 @@ public class MediaStoreImageProvider extends ImageProvider { put("uri", sourceUri.toString()); }}; - // TODO TLAD check if there is any downside to use tree document files with scoped storage on API 30+ - // when testing scoped storage on API 29, it seems less constraining to use tree document files than to rely on the Media Store + // on API 30 we cannot get access granted directly to a volume root from its document tree, + // but it is still less constraining to use tree document files than to rely on the Media Store try { ListenableFuture> newFieldsFuture; // if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { -// newFieldsFuture = moveSingleByMediaStoreInsert(activity, sourcePath, sourceUri, destination, mimeType, copy); +// newFieldsFuture = moveSingleByMediaStoreInsert(context, sourcePath, sourceUri, destination, mimeType, copy); // } else { - newFieldsFuture = moveSingleByTreeDocAndScan(activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy); + newFieldsFuture = moveSingleByTreeDocAndScan(context, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy); // } Map newFields = newFieldsFuture.get(); result.put("success", true); @@ -309,7 +308,7 @@ public class MediaStoreImageProvider extends ImageProvider { // - there is no documentation regarding support for usage with removable storage // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage @RequiresApi(api = Build.VERSION_CODES.Q) - private ListenableFuture> moveSingleByMediaStoreInsert(final Activity activity, final String sourcePath, final Uri sourceUri, + private ListenableFuture> moveSingleByMediaStoreInsert(final Context context, final String sourcePath, final Uri sourceUri, final MediaStoreMoveDestination destination, final String mimeType, final boolean copy) { SettableFuture> future = SettableFuture.create(); @@ -323,22 +322,23 @@ public class MediaStoreImageProvider extends ImageProvider { // from API 29, changing MediaColumns.RELATIVE_PATH can move files on disk (same storage device) contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, destination.relativePath); contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName); + String volumeName = destination.volumeNameForMediaStore; Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? - MediaStore.Video.Media.getContentUri(destination.volumeName) : - MediaStore.Images.Media.getContentUri(destination.volumeName); - Uri destinationUri = activity.getContentResolver().insert(tableUrl, contentValues); + MediaStore.Video.Media.getContentUri(volumeName) : + MediaStore.Images.Media.getContentUri(volumeName); + Uri destinationUri = context.getContentResolver().insert(tableUrl, contentValues); if (destinationUri == null) { future.setException(new Exception("failed to insert row to content resolver")); } else { - DocumentFileCompat sourceFile = DocumentFileCompat.fromSingleUri(activity, sourceUri); - DocumentFileCompat destinationFile = DocumentFileCompat.fromSingleUri(activity, destinationUri); + DocumentFileCompat sourceFile = DocumentFileCompat.fromSingleUri(context, sourceUri); + DocumentFileCompat destinationFile = DocumentFileCompat.fromSingleUri(context, destinationUri); sourceFile.copyTo(destinationFile); boolean deletedSource = false; if (!copy) { // delete original entry try { - delete(activity, sourcePath, sourceUri).get(); + delete(context, sourcePath, sourceUri).get(); deletedSource = true; } catch (ExecutionException | InterruptedException e) { Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, e); @@ -363,7 +363,7 @@ public class MediaStoreImageProvider extends ImageProvider { // We can create an item via `DocumentFile.createFile()`, but: // - we need to scan the file to get the Media Store content URI // - the underlying document provider controls the new file name - private ListenableFuture> moveSingleByTreeDocAndScan(final Activity activity, final String sourcePath, final Uri sourceUri, final String destinationDir, final DocumentFileCompat destinationDirDocFile, final String mimeType, boolean copy) { + private ListenableFuture> moveSingleByTreeDocAndScan(final Context context, final String sourcePath, final Uri sourceUri, final String destinationDir, final DocumentFileCompat destinationDirDocFile, final String mimeType, boolean copy) { SettableFuture> future = SettableFuture.create(); try { @@ -375,12 +375,12 @@ public class MediaStoreImageProvider extends ImageProvider { // through a document URI, not a tree URI // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first DocumentFileCompat destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension); - DocumentFileCompat destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.getUri()); + DocumentFileCompat destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.getUri()); // `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry // `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument" // when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri` - DocumentFileCompat source = DocumentFileCompat.fromSingleUri(activity, sourceUri); + DocumentFileCompat source = DocumentFileCompat.fromSingleUri(context, sourceUri); source.copyTo(destinationDocFile); // the source file name and the created document file name can be different when: @@ -393,7 +393,7 @@ public class MediaStoreImageProvider extends ImageProvider { if (!copy) { // delete original entry try { - delete(activity, sourcePath, sourceUri).get(); + delete(context, sourcePath, sourceUri).get(); deletedSource = true; } catch (ExecutionException | InterruptedException e) { Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, e); @@ -401,7 +401,7 @@ public class MediaStoreImageProvider extends ImageProvider { } boolean finalDeletedSource = deletedSource; - scanNewPath(activity, destinationFullPath, mimeType, new ImageProvider.ImageOpCallback() { + scanNewPath(context, destinationFullPath, mimeType, new ImageProvider.ImageOpCallback() { @Override public void onSuccess(Map newFields) { newFields.put("deletedSource", finalDeletedSource); @@ -430,15 +430,15 @@ public class MediaStoreImageProvider extends ImageProvider { } class MediaStoreMoveDestination { - final String volumeName; + final String volumeNameForMediaStore; final String volumePath; final String relativePath; final String fullPath; - MediaStoreMoveDestination(Activity activity, String destinationDir) { + MediaStoreMoveDestination(@NonNull Context context, @NonNull String destinationDir) { fullPath = destinationDir; - volumeName = getVolumeName(activity, destinationDir); - volumePath = StorageUtils.getVolumePath(activity, destinationDir).orElse(null); + volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir); + volumePath = StorageUtils.getVolumePath(context, destinationDir).orElse(null); relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null; } } diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java index cbbf9beac..b7e9cc979 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java @@ -1,6 +1,7 @@ package deckers.thibault.aves.utils; import android.app.Activity; +import android.content.Context; import android.content.Intent; import android.content.UriPermission; import android.net.Uri; @@ -11,12 +12,19 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.appcompat.app.AlertDialog; import androidx.core.app.ActivityCompat; +import com.google.common.base.Splitter; + import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; public class PermissionManager { @@ -27,20 +35,6 @@ public class PermissionManager { // permission request code to pending runnable private static ConcurrentHashMap pendingPermissionMap = new ConcurrentHashMap<>(); - public static boolean requireVolumeAccessDialog(Activity activity, @NonNull String anyPath) { - return StorageUtils.requireAccessPermission(anyPath) && getVolumeTreeUri(activity, anyPath) == null; - } - - // check access permission to volume root directory & return its tree URI if available - @Nullable - public static Uri getVolumeTreeUri(Activity activity, @NonNull String anyPath) { - String volumeTreeUri = StorageUtils.getVolumeTreeUriForPath(activity, anyPath).orElse(null); - Optional uriPermissionOptional = activity.getContentResolver().getPersistedUriPermissions().stream() - .filter(uriPermission -> uriPermission.getUri().toString().equals(volumeTreeUri)) - .findFirst(); - return uriPermissionOptional.map(UriPermission::getUri).orElse(null); - } - public static void requestVolumeAccess(@NonNull Activity activity, @NonNull String path, @NonNull Runnable onGranted, @NonNull Runnable onDenied) { Log.i(LOG_TAG, "request user to select and grant access permission to volume=" + path); pendingPermissionMap.put(VOLUME_ROOT_PERMISSION_REQUEST_CODE, new PendingPermissionHandler(path, onGranted, onDenied)); @@ -64,39 +58,106 @@ public class PermissionManager { ActivityCompat.startActivityForResult(activity, intent, VOLUME_ROOT_PERMISSION_REQUEST_CODE, null); } - public static void onPermissionResult(Activity activity, int requestCode, @Nullable Uri treeUri) { + public static void onPermissionResult(int requestCode, @Nullable Uri treeUri) { Log.d(LOG_TAG, "onPermissionResult with requestCode=" + requestCode + ", treeUri=" + treeUri); boolean granted = treeUri != null; PendingPermissionHandler handler = pendingPermissionMap.remove(requestCode); if (handler == null) return; - if (granted) { - String requestedPath = handler.path; - if (isTreeUriPath(requestedPath, treeUri)) { - StorageUtils.setVolumeTreeUri(activity, requestedPath, treeUri.toString()); - } else { - granted = false; - } - } - Runnable runnable = granted ? handler.onGranted : handler.onDenied; if (runnable == null) return; runnable.run(); } - private static boolean isTreeUriPath(String path, Uri treeUri) { - // TODO TLAD check requestedPath match treeUri - // e.g. OK match for path=/storage/emulated/0/, treeUri=content://com.android.externalstorage.documents/tree/primary%3A - // e.g. NO match for path=/storage/10F9-3F13/, treeUri=content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures - Log.d(LOG_TAG, "isTreeUriPath path=" + path + ", treeUri=" + treeUri); - return true; + public static Optional getGrantedDirForPath(@NonNull Context context, @NonNull String anyPath) { + return getGrantedDirs(context).stream().filter(anyPath::startsWith).findFirst(); + } + + public static List> getInaccessibleDirectories(@NonNull Context context, @NonNull List dirPaths) { + Set grantedDirs = getGrantedDirs(context); + + // find set of inaccessible directories for each volume + Map> dirsPerVolume = new HashMap<>(); + for (String dirPath : dirPaths) { + if (!dirPath.endsWith(File.separator)) { + dirPath += File.separator; + } + if (grantedDirs.stream().noneMatch(dirPath::startsWith)) { + // inaccessible dirs + StorageUtils.PathSegments segments = new StorageUtils.PathSegments(context, dirPath); + Set dirSet = dirsPerVolume.getOrDefault(segments.volumePath, new HashSet<>()); + if (dirSet != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // request primary directory on volume from Android R + String relativeDir = segments.relativeDir; + if (relativeDir != null) { + Iterator iterator = Splitter.on(File.separatorChar).omitEmptyStrings().split(relativeDir).iterator(); + if (iterator.hasNext()) { + // primary dir + dirSet.add(iterator.next()); + } + } + } else { + // request volume root until Android Q + dirSet.add(""); + } + } + dirsPerVolume.put(segments.volumePath, dirSet); + } + } + + // format for easier handling on Flutter + List> inaccessibleDirs = new ArrayList<>(); + StorageManager sm = context.getSystemService(StorageManager.class); + if (sm != null) { + for (Map.Entry> volumeEntry : dirsPerVolume.entrySet()) { + String volumePath = volumeEntry.getKey(); + String volumeDescription = ""; + try { + StorageVolume volume = sm.getStorageVolume(new File(volumePath)); + if (volume != null) { + volumeDescription = volume.getDescription(context); + } + } catch (IllegalArgumentException e) { + // ignore + } + for (String relativeDir : volumeEntry.getValue()) { + HashMap dirMap = new HashMap<>(); + dirMap.put("volumePath", volumePath); + dirMap.put("volumeDescription", volumeDescription); + dirMap.put("relativeDir", relativeDir); + inaccessibleDirs.add(dirMap); + } + } + } + Log.d(LOG_TAG, "getInaccessibleDirectories dirPaths=" + dirPaths + " -> inaccessibleDirs=" + inaccessibleDirs); + return inaccessibleDirs; + } + + private static Set getGrantedDirs(Context context) { + HashSet accessibleDirs = new HashSet<>(); + + // find paths matching URIs granted by the user + for (UriPermission uriPermission : context.getContentResolver().getPersistedUriPermissions()) { + Optional dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.getUri()); + dirPath.ifPresent(accessibleDirs::add); + } + + // from Android R, we no longer have access permission by default on primary volume + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { + String primaryPath = StorageUtils.getPrimaryVolumePath(); + accessibleDirs.add(primaryPath); + } + + Log.d(LOG_TAG, "getGrantedDirs accessibleDirs=" + accessibleDirs); + return accessibleDirs; } static class PendingPermissionHandler { final String path; - final Runnable onGranted; - final Runnable onDenied; + final Runnable onGranted; // user gave access to a directory, with no guarantee that it matches the specified `path` + final Runnable onDenied; // user cancelled PendingPermissionHandler(@NonNull String path, @NonNull Runnable onGranted, @NonNull Runnable onDenied) { this.path = path; diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java index 378720aac..b0a814ab3 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java @@ -1,14 +1,15 @@ package deckers.thibault.aves.utils; import android.annotation.SuppressLint; -import android.app.Activity; import android.content.ContentResolver; import android.content.Context; -import android.content.SharedPreferences; import android.media.MediaMetadataRetriever; import android.net.Uri; import android.os.Build; import android.os.Environment; +import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; +import android.provider.DocumentsContract; import android.provider.MediaStore; import android.text.TextUtils; import android.util.Log; @@ -21,9 +22,6 @@ import com.commonsware.cwac.document.DocumentFileCompat; import com.google.common.base.Splitter; import com.google.common.collect.Lists; -import org.json.JSONException; -import org.json.JSONObject; - import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; @@ -31,13 +29,13 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class StorageUtils { private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class); @@ -52,35 +50,37 @@ public class StorageUtils { // primary volume path, with trailing "/" private static String mPrimaryVolumePath; - private static String getPrimaryVolumePath() { + public static String getPrimaryVolumePath() { if (mPrimaryVolumePath == null) { mPrimaryVolumePath = findPrimaryVolumePath(); } return mPrimaryVolumePath; } - public static String[] getVolumePaths(Context context) { + public static String[] getVolumePaths(@NonNull Context context) { if (mStorageVolumePaths == null) { mStorageVolumePaths = findVolumePaths(context); } return mStorageVolumePaths; } - public static Optional getVolumePath(Context context, @NonNull String anyPath) { + public static Optional getVolumePath(@NonNull Context context, @NonNull String anyPath) { return Arrays.stream(getVolumePaths(context)).filter(anyPath::startsWith).findFirst(); } @Nullable - private static Iterator getPathStepIterator(Context context, @NonNull String anyPath) { - Optional volumePathOpt = getVolumePath(context, anyPath); - if (!volumePathOpt.isPresent()) return null; + private static Iterator getPathStepIterator(@NonNull Context context, @NonNull String anyPath, @Nullable String root) { + if (root == null) { + root = getVolumePath(context, anyPath).orElse(null); + if (root == null) return null; + } String relativePath = null, filename = null; int lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1; - int volumePathLength = volumePathOpt.get().length(); - if (lastSeparatorIndex > volumePathLength) { + int rootLength = root.length(); + if (lastSeparatorIndex > rootLength) { filename = anyPath.substring(lastSeparatorIndex); - relativePath = anyPath.substring(volumePathLength, lastSeparatorIndex); + relativePath = anyPath.substring(rootLength, lastSeparatorIndex); } if (relativePath == null) return null; @@ -134,7 +134,7 @@ public class StorageUtils { Log.e(LOG_TAG, "insomnia", e); } } - } while(!validFiles); + } while (!validFiles); for (File file : files) { String applicationSpecificAbsolutePath = file.getAbsolutePath(); String emulatedRootPath = applicationSpecificAbsolutePath.substring(0, applicationSpecificAbsolutePath.indexOf("Android/data")); @@ -225,43 +225,86 @@ public class StorageUtils { * Volume tree URIs */ - // serialized map from storage volume paths to their document tree URIs, from the Documents Provider - // e.g. "/storage/12A9-8B42" -> "content://com.android.externalstorage.documents/tree/12A9-8B42%3A" - private static final String PREF_VOLUME_TREE_URIS = "volume_tree_uris"; - - public static void setVolumeTreeUri(Activity activity, String volumePath, String treeUri) { - Map map = getVolumeTreeUris(activity); - map.put(volumePath, treeUri); - - SharedPreferences.Editor editor = activity.getPreferences(Context.MODE_PRIVATE).edit(); - String json = new JSONObject(map).toString(); - editor.putString(PREF_VOLUME_TREE_URIS, json); - editor.apply(); - } - - private static Map getVolumeTreeUris(Activity activity) { - Map map = new HashMap<>(); - - SharedPreferences preferences = activity.getPreferences(Context.MODE_PRIVATE); - String json = preferences.getString(PREF_VOLUME_TREE_URIS, new JSONObject().toString()); - if (json != null) { - try { - JSONObject jsonObject = new JSONObject(json); - Iterator iterator = jsonObject.keys(); - while (iterator.hasNext()) { - String k = iterator.next(); - String v = (String) jsonObject.get(k); - map.put(k, v); + private static Optional getVolumeUuidForTreeUri(@NonNull Context context, @NonNull String anyPath) { + StorageManager sm = context.getSystemService(StorageManager.class); + if (sm != null) { + StorageVolume volume = sm.getStorageVolume(new File(anyPath)); + if (volume != null) { + if (volume.isPrimary()) { + return Optional.of("primary"); + } + String uuid = volume.getUuid(); + if (uuid != null) { + return Optional.of(uuid.toUpperCase()); } - } catch (JSONException e) { - Log.w(LOG_TAG, "failed to read volume tree URIs from preferences", e); } } - return map; + Log.e(LOG_TAG, "failed to find volume UUID for anyPath=" + anyPath); + return Optional.empty(); } - public static Optional getVolumeTreeUriForPath(Activity activity, String anyPath) { - return StorageUtils.getVolumePath(activity, anyPath).map(volumePath -> getVolumeTreeUris(activity).get(volumePath)); + private static Optional getVolumePathFromTreeUriUuid(@NonNull Context context, @NonNull String uuid) { + if (uuid.equals("primary")) { + return Optional.of(getPrimaryVolumePath()); + } + StorageManager sm = context.getSystemService(StorageManager.class); + if (sm != null) { + for (String volumePath : StorageUtils.getVolumePaths(context)) { + try { + StorageVolume volume = sm.getStorageVolume(new File(volumePath)); + if (volume != null && uuid.equalsIgnoreCase(volume.getUuid())) { + return Optional.of(volumePath); + } + } catch (IllegalArgumentException e) { + // ignore + } + } + } + Log.e(LOG_TAG, "failed to find volume path for UUID=" + uuid); + return Optional.empty(); + } + + // e.g. + // /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A + // /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures + static Optional convertDirPathToTreeUri(@NonNull Context context, @NonNull String dirPath) { + Optional uuid = getVolumeUuidForTreeUri(context, dirPath); + if (uuid.isPresent()) { + String relativeDir = new PathSegments(context, dirPath).relativeDir; + if (relativeDir == null) { + relativeDir = ""; + } else if (relativeDir.endsWith(File.separator)) { + relativeDir = relativeDir.substring(0, relativeDir.length() - 1); + } + Uri treeUri = DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", uuid.get() + ":" + relativeDir); + return Optional.of(treeUri); + } + Log.e(LOG_TAG, "failed to convert dirPath=" + dirPath + " to tree URI"); + return Optional.empty(); + } + + // e.g. + // content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/ + // content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/ + static Optional convertTreeUriToDirPath(@NonNull Context context, @NonNull Uri treeUri) { + String encoded = treeUri.toString().substring("content://com.android.externalstorage.documents/tree/".length()); + Matcher matcher = Pattern.compile("(.*?):(.*)").matcher(Uri.decode(encoded)); + if (matcher.find()) { + String uuid = matcher.group(1); + String relativePath = matcher.group(2); + if (uuid != null && relativePath != null) { + Optional volumePath = getVolumePathFromTreeUriUuid(context, uuid); + if (volumePath.isPresent()) { + String dirPath = volumePath.get() + relativePath; + if (!dirPath.endsWith(File.separator)) { + dirPath += File.separator; + } + return Optional.of(dirPath); + } + } + } + Log.e(LOG_TAG, "failed to convert treeUri=" + treeUri + " to path"); + return Optional.empty(); } /** @@ -269,20 +312,22 @@ public class StorageUtils { */ @Nullable - public static DocumentFileCompat getDocumentFile(@NonNull Activity activity, @NonNull String anyPath, @NonNull Uri mediaUri) { + public static DocumentFileCompat getDocumentFile(@NonNull Context context, @NonNull String anyPath, @NonNull Uri mediaUri) { if (requireAccessPermission(anyPath)) { // need a document URI (not a media content URI) to open a `DocumentFile` output stream if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // cleanest API to get it - Uri docUri = MediaStore.getDocumentUri(activity, mediaUri); + Uri docUri = MediaStore.getDocumentUri(context, mediaUri); if (docUri != null) { - return DocumentFileCompat.fromSingleUri(activity, docUri); + return DocumentFileCompat.fromSingleUri(context, docUri); } } // fallback for older APIs - Uri volumeTreeUri = PermissionManager.getVolumeTreeUri(activity, anyPath); - Optional docFile = getDocumentFileFromVolumeTree(activity, volumeTreeUri, anyPath); - return docFile.orElse(null); + return getVolumePath(context, anyPath) + .flatMap(volumePath -> convertDirPathToTreeUri(context, volumePath) + .flatMap(treeUri -> getDocumentFileFromVolumeTree(context, treeUri, anyPath))) + .orElse(null); + } // good old `File` return DocumentFileCompat.fromFile(new File(anyPath)); @@ -290,16 +335,21 @@ public class StorageUtils { // returns the directory `DocumentFile` (from tree URI when scoped storage is required, `File` otherwise) // returns null if directory does not exist and could not be created - public static DocumentFileCompat createDirectoryIfAbsent(@NonNull Activity activity, @NonNull String directoryPath) { - if (requireAccessPermission(directoryPath)) { - Uri rootTreeUri = PermissionManager.getVolumeTreeUri(activity, directoryPath); - DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(activity, rootTreeUri); + public static DocumentFileCompat createDirectoryIfAbsent(@NonNull Context context, @NonNull String dirPath) { + if (!dirPath.endsWith(File.separator)) { + dirPath += File.separator; + } + if (requireAccessPermission(dirPath)) { + String grantedDir = PermissionManager.getGrantedDirForPath(context, dirPath).orElse(null); + if (grantedDir == null) return null; + + Uri rootTreeUri = convertDirPathToTreeUri(context, grantedDir).orElse(null); + if (rootTreeUri == null) return null; + + DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri); if (parentFile == null) return null; - if (!directoryPath.endsWith(File.separator)) { - directoryPath += File.separator; - } - Iterator pathIterator = getPathStepIterator(activity, directoryPath); + Iterator pathIterator = getPathStepIterator(context, dirPath, grantedDir); while (pathIterator != null && pathIterator.hasNext()) { String dirName = pathIterator.next(); DocumentFileCompat dirFile = findDocumentFileIgnoreCase(parentFile, dirName); @@ -319,10 +369,10 @@ public class StorageUtils { } return parentFile; } else { - File directory = new File(directoryPath); + File directory = new File(dirPath); if (!directory.exists()) { if (!directory.mkdirs()) { - Log.e(LOG_TAG, "failed to create directories at path=" + directoryPath); + Log.e(LOG_TAG, "failed to create directories at path=" + dirPath); return null; } } @@ -338,23 +388,19 @@ public class StorageUtils { temp.deleteOnExit(); return temp.getPath(); } catch (IOException e) { - Log.w(LOG_TAG, "failed to copy file from path=" + path); + Log.e(LOG_TAG, "failed to copy file from path=" + path); } return null; } - private static Optional getDocumentFileFromVolumeTree(Context context, Uri rootTreeUri, String path) { - if (rootTreeUri == null || path == null) { - return Optional.empty(); - } - + private static Optional getDocumentFileFromVolumeTree(Context context, @NonNull Uri rootTreeUri, @NonNull String anyPath) { DocumentFileCompat documentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri); if (documentFile == null) { return Optional.empty(); } // follow the entry path down the document tree - Iterator pathIterator = getPathStepIterator(context, path); + Iterator pathIterator = getPathStepIterator(context, anyPath, null); while (pathIterator != null && pathIterator.hasNext()) { documentFile = findDocumentFileIgnoreCase(documentFile, pathIterator.next()); if (documentFile == null) { @@ -393,7 +439,7 @@ public class StorageUtils { return uri != null && ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme()) && MediaStore.AUTHORITY.equalsIgnoreCase(uri.getHost()); } - public static InputStream openInputStream(Context context, Uri uri) throws FileNotFoundException { + public static InputStream openInputStream(@NonNull Context context, @NonNull Uri uri) throws FileNotFoundException { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // we get a permission denial if we require original from a provider other than the media store if (isMediaStoreContentUri(uri)) { @@ -403,7 +449,7 @@ public class StorageUtils { return context.getContentResolver().openInputStream(uri); } - public static MediaMetadataRetriever openMetadataRetriever(Context context, Uri uri) { + public static MediaMetadataRetriever openMetadataRetriever(@NonNull Context context, @NonNull Uri uri) { MediaMetadataRetriever retriever = new MediaMetadataRetriever(); try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -414,8 +460,28 @@ public class StorageUtils { } retriever.setDataSource(context, uri); } catch (Exception e) { - Log.w(LOG_TAG, "failed to open MediaMetadataRetriever for uri=" + uri, e); + Log.e(LOG_TAG, "failed to open MediaMetadataRetriever for uri=" + uri, e); } return retriever; } + + public static class PathSegments { + String fullPath; // should match "volumePath + relativeDir + filename" + String volumePath; // with trailing "/" + String relativeDir; // with trailing "/" + String filename; // null for directories + + PathSegments(@NonNull Context context, @NonNull String fullPath) { + this.fullPath = fullPath; + volumePath = StorageUtils.getVolumePath(context, fullPath).orElse(null); + if (volumePath == null) return; + + int lastSeparatorIndex = fullPath.lastIndexOf(File.separator) + 1; + int volumePathLength = volumePath.length(); + if (lastSeparatorIndex > volumePathLength) { + filename = fullPath.substring(lastSeparatorIndex); + relativeDir = fullPath.substring(volumePathLength, lastSeparatorIndex); + } + } + } } diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index ab4672036..8d189505e 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -88,7 +88,7 @@ class AndroidAppService { } } - static Future share(Set entries) async { + static Future share(Iterable entries) async { // loosen mime type to a generic one, so we can share with badly defined apps // e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats final urisByMimeType = groupBy(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); diff --git a/lib/services/android_file_service.dart b/lib/services/android_file_service.dart index 078e38cca..2eda39d07 100644 --- a/lib/services/android_file_service.dart +++ b/lib/services/android_file_service.dart @@ -18,16 +18,18 @@ class AndroidFileService { return []; } - static Future requireVolumeAccessDialog(String path) async { + // returns a list of directories, + // each directory is a map with "volumePath", "volumeDescription", "relativeDir" + static Future> getInaccessibleDirectories(Iterable dirPaths) async { try { - final result = await platform.invokeMethod('requireVolumeAccessDialog', { - 'path': path, + final result = await platform.invokeMethod('getInaccessibleDirectories', { + 'dirPaths': dirPaths.toList(), }); - return result as bool; + return (result as List).cast(); } on PlatformException catch (e) { - debugPrint('requireVolumeAccessDialog failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + debugPrint('getInaccessibleDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); } - return false; + return null; } // returns whether user granted access to volume root at `volumePath` diff --git a/lib/widgets/common/action_delegates/permission_aware.dart b/lib/widgets/common/action_delegates/permission_aware.dart index d22d93d52..9b1f6f886 100644 --- a/lib/widgets/common/action_delegates/permission_aware.dart +++ b/lib/widgets/common/action_delegates/permission_aware.dart @@ -1,34 +1,30 @@ 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:flutter/material.dart'; -import 'package:tuple/tuple.dart'; mixin PermissionAwareMixin { Future checkStoragePermission(BuildContext context, Iterable entries) { - return checkStoragePermissionForPaths(context, entries.where((e) => e.path != null).map((e) => e.path)); + return checkStoragePermissionForAlbums(context, entries.where((e) => e.path != null).map((e) => e.directory).toSet()); } - Future checkStoragePermissionForPaths(BuildContext context, Iterable paths) async { - final volumes = paths.map(androidFileUtils.getStorageVolume).toSet(); - final ungrantedVolumes = (await Future.wait>( - volumes.map( - (volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then( - (granted) => Tuple2(volume, granted), - ), - ), - )) - .where((t) => t.item2) - .map((t) => t.item1) - .toList(); - while (ungrantedVolumes.isNotEmpty) { - final volume = ungrantedVolumes.first; + Future checkStoragePermissionForAlbums(BuildContext context, Set albumPaths) async { + while (true) { + final dirs = await AndroidFileService.getInaccessibleDirectories(albumPaths); + if (dirs == null) return false; + if (dirs.isEmpty) return true; + + final dir = dirs.first; + final volumePath = dir['volumePath'] as String; + final volumeDescription = dir['volumeDescription'] as String; + final relativeDir = dir['relativeDir'] as String; + final dirDisplayName = relativeDir.isEmpty ? 'root' : '“$relativeDir”'; + final confirmed = await showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: const Text('Storage Volume Access'), - content: Text('Please select the root directory of “${volume.description}” in the next screen, so that this app can access it and complete your request.'), + content: Text('Please select the $dirDisplayName directory of “$volumeDescription” in the next screen, so that this app can access it and complete your request.'), actions: [ FlatButton( onPressed: () => Navigator.pop(context), @@ -45,15 +41,11 @@ mixin PermissionAwareMixin { // 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 { + final granted = await AndroidFileService.requestVolumeAccess(volumePath); + if (!granted) { // abort if the user denies access from the native dialog return false; } } - return true; } } diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart index c2ecf39fd..e4f51b21e 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -100,7 +100,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { ), ); if (destinationAlbum == null || destinationAlbum.isEmpty) return; - if (!await checkStoragePermissionForPaths(context, [destinationAlbum])) return; + if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; final selection = collection.selection.toList(); if (!await checkStoragePermission(context, selection)) return; diff --git a/lib/widgets/common/app_bar_subtitle.dart b/lib/widgets/common/app_bar_subtitle.dart index 0b1f31ce0..66fc29dea 100644 --- a/lib/widgets/common/app_bar_subtitle.dart +++ b/lib/widgets/common/app_bar_subtitle.dart @@ -34,8 +34,8 @@ class SourceStateAwareAppBarTitle extends StatelessWidget { child: sourceState == SourceState.ready ? const SizedBox.shrink() : SourceStateSubtitle( - source: source, - ), + source: source, + ), ); }, ), @@ -70,24 +70,24 @@ class SourceStateSubtitle extends StatelessWidget { return subtitle == null ? const SizedBox.shrink() : Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(subtitle, style: subtitleStyle), - StreamBuilder( - stream: source.progressStream, - builder: (context, snapshot) { - if (snapshot.hasError || !snapshot.hasData) return const SizedBox.shrink(); - final progress = snapshot.data; - return Padding( - padding: const EdgeInsetsDirectional.only(start: 8), - child: Text( - '${progress.done}/${progress.total}', - style: subtitleStyle.copyWith(color: Colors.white30), + mainAxisSize: MainAxisSize.min, + children: [ + Text(subtitle, style: subtitleStyle), + StreamBuilder( + stream: source.progressStream, + builder: (context, snapshot) { + if (snapshot.hasError || !snapshot.hasData) return const SizedBox.shrink(); + final progress = snapshot.data; + return Padding( + padding: const EdgeInsetsDirectional.only(start: 8), + child: Text( + '${progress.done}/${progress.total}', + style: subtitleStyle.copyWith(color: Colors.white30), + ), + ); + }, ), - ); - }, - ), - ], - ); + ], + ); } } diff --git a/lib/widgets/debug_page.dart b/lib/widgets/debug_page.dart index c18f8f8a3..dee6a0873 100644 --- a/lib/widgets/debug_page.dart +++ b/lib/widgets/debug_page.dart @@ -7,7 +7,6 @@ import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/android_app_service.dart'; -import 'package:aves/services/android_file_service.dart'; import 'package:aves/services/image_file_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/file_utils.dart'; @@ -17,7 +16,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:outline_material_icons/outline_material_icons.dart'; -import 'package:tuple/tuple.dart'; class DebugPage extends StatefulWidget { final CollectionSource source; @@ -35,7 +33,6 @@ class DebugPageState extends State { Future> _dbMetadataLoader; Future> _dbAddressLoader; Future> _dbFavouritesLoader; - Future>> _volumePermissionLoader; Future _envLoader; List get entries => widget.source.rawEntries; @@ -44,13 +41,6 @@ class DebugPageState extends State { void initState() { super.initState(); _startDbReport(); - _volumePermissionLoader = Future.wait>( - androidFileUtils.storageVolumes.map( - (volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then( - (value) => Tuple2(volume.path, !value), - ), - ), - ); _envLoader = AndroidAppService.getEnv(); } @@ -299,31 +289,17 @@ class DebugPageState extends State { return ListView( padding: const EdgeInsets.all(16), children: [ - FutureBuilder( - future: _volumePermissionLoader, - builder: (context, AsyncSnapshot>> snapshot) { - if (snapshot.hasError) return Text(snapshot.error.toString()); - if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); - final permissions = snapshot.data; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...androidFileUtils.storageVolumes.expand((v) => [ - 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(), - ]) - ], - ); - }, - ), + ...androidFileUtils.storageVolumes.expand((v) => [ + Text(v.path), + InfoRowGroup({ + 'description': '${v.description}', + 'isEmulated': '${v.isEmulated}', + 'isPrimary': '${v.isPrimary}', + 'isRemovable': '${v.isRemovable}', + 'state': '${v.state}', + }), + const Divider(), + ]) ], ); } diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/fullscreen/info/location_section.dart index c0da8ccc8..c7c4cbbc3 100644 --- a/lib/widgets/fullscreen/info/location_section.dart +++ b/lib/widgets/fullscreen/info/location_section.dart @@ -1,7 +1,7 @@ -import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings.dart'; +import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/utils/geo_utils.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart'; diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats.dart index 7decb89bb..e7ae91d4a 100644 --- a/lib/widgets/stats/stats.dart +++ b/lib/widgets/stats/stats.dart @@ -1,10 +1,10 @@ import 'dart:math'; -import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/album/empty.dart';