API 30: handle access at directory level, request max but can process with min

This commit is contained in:
Thibault Deckers 2020-07-26 01:12:22 +09:00
parent e11769ce77
commit d368fbe65c
18 changed files with 380 additions and 283 deletions

View file

@ -100,7 +100,7 @@ public class MainActivity extends FlutterActivity {
protected void onActivityResult(int requestCode, int resultCode, Intent data) { protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == PermissionManager.VOLUME_ROOT_PERMISSION_REQUEST_CODE) { if (requestCode == PermissionManager.VOLUME_ROOT_PERMISSION_REQUEST_CODE) {
if (resultCode != RESULT_OK || data.getData() == null) { if (resultCode != RESULT_OK || data.getData() == null) {
PermissionManager.onPermissionResult(this, requestCode, null); PermissionManager.onPermissionResult(requestCode, null);
return; return;
} }
@ -113,7 +113,7 @@ public class MainActivity extends FlutterActivity {
getContentResolver().takePersistableUriPermission(treeUri, takeFlags); getContentResolver().takePersistableUriPermission(treeUri, takeFlags);
// resume pending action // resume pending action
PermissionManager.onPermissionResult(this, requestCode, treeUri); PermissionManager.onPermissionResult(requestCode, treeUri);
} }
} }
} }

View file

@ -1,6 +1,6 @@
package deckers.thibault.aves.channel.calls; package deckers.thibault.aves.channel.calls;
import android.app.Activity; import android.content.Context;
import android.os.Build; import android.os.Build;
import android.os.storage.StorageManager; import android.os.storage.StorageManager;
import android.os.storage.StorageVolume; import android.os.storage.StorageVolume;
@ -22,10 +22,10 @@ import io.flutter.plugin.common.MethodChannel;
public class StorageHandler implements MethodChannel.MethodCallHandler { public class StorageHandler implements MethodChannel.MethodCallHandler {
public static final String CHANNEL = "deckers.thibault/aves/storage"; public static final String CHANNEL = "deckers.thibault/aves/storage";
private Activity activity; private Context context;
public StorageHandler(Activity activity) { public StorageHandler(Context context) {
this.activity = activity; this.context = context;
} }
@Override @Override
@ -42,12 +42,12 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
result.success(volumes); result.success(volumes);
break; break;
} }
case "requireVolumeAccessDialog": { case "getInaccessibleDirectories": {
String path = call.argument("path"); List<String> dirPaths = call.argument("dirPaths");
if (path == null) { if (dirPaths == null) {
result.success(true); result.error("getInaccessibleDirectories-args", "failed because of missing arguments", null);
} else { } else {
result.success(PermissionManager.requireVolumeAccessDialog(activity, path)); result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths));
} }
break; break;
} }
@ -60,15 +60,15 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
@RequiresApi(api = Build.VERSION_CODES.N) @RequiresApi(api = Build.VERSION_CODES.N)
private List<Map<String, Object>> getStorageVolumes() { private List<Map<String, Object>> getStorageVolumes() {
List<Map<String, Object>> volumes = new ArrayList<>(); List<Map<String, Object>> volumes = new ArrayList<>();
StorageManager sm = activity.getSystemService(StorageManager.class); StorageManager sm = context.getSystemService(StorageManager.class);
if (sm != null) { if (sm != null) {
for (String volumePath : StorageUtils.getVolumePaths(activity)) { for (String volumePath : StorageUtils.getVolumePaths(context)) {
try { try {
StorageVolume volume = sm.getStorageVolume(new File(volumePath)); StorageVolume volume = sm.getStorageVolume(new File(volumePath));
if (volume != null) { if (volume != null) {
Map<String, Object> volumeMap = new HashMap<>(); Map<String, Object> volumeMap = new HashMap<>();
volumeMap.put("path", volumePath); volumeMap.put("path", volumePath);
volumeMap.put("description", volume.getDescription(activity)); volumeMap.put("description", volume.getDescription(context));
volumeMap.put("isPrimary", volume.isPrimary()); volumeMap.put("isPrimary", volume.isPrimary());
volumeMap.put("isRemovable", volume.isRemovable()); volumeMap.put("isRemovable", volume.isRemovable());
volumeMap.put("isEmulated", volume.isEmulated()); volumeMap.put("isEmulated", volume.isEmulated());

View file

@ -1,6 +1,6 @@
package deckers.thibault.aves.channel.streams; package deckers.thibault.aves.channel.streams;
import android.app.Activity; import android.content.Context;
import android.net.Uri; import android.net.Uri;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
@ -25,7 +25,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
public static final String CHANNEL = "deckers.thibault/aves/imageopstream"; public static final String CHANNEL = "deckers.thibault/aves/imageopstream";
private Activity activity; private Context context;
private EventChannel.EventSink eventSink; private EventChannel.EventSink eventSink;
private Handler handler; private Handler handler;
private Map<String, Object> argMap; private Map<String, Object> argMap;
@ -33,8 +33,8 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
private String op; private String op;
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public ImageOpStreamHandler(Activity activity, Object arguments) { public ImageOpStreamHandler(Context context, Object arguments) {
this.activity = activity; this.context = context;
if (arguments instanceof Map) { if (arguments instanceof Map) {
argMap = (Map<String, Object>) arguments; argMap = (Map<String, Object>) arguments;
this.op = (String) argMap.get("op"); this.op = (String) argMap.get("op");
@ -100,7 +100,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
} }
List<AvesImageEntry> entries = entryMapList.stream().map(AvesImageEntry::new).collect(Collectors.toList()); List<AvesImageEntry> 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 @Override
public void onSuccess(Map<String, Object> fields) { public void onSuccess(Map<String, Object> fields) {
success(fields); success(fields);
@ -138,7 +138,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
put("uri", uriString); put("uri", uriString);
}}; }};
try { try {
provider.delete(activity, path, uri).get(); provider.delete(context, path, uri).get();
result.put("success", true); result.put("success", true);
} catch (ExecutionException | InterruptedException e) { } catch (ExecutionException | InterruptedException e) {
Log.w(LOG_TAG, "failed to delete entry with path=" + path, e); Log.w(LOG_TAG, "failed to delete entry with path=" + path, e);

View file

@ -1,6 +1,6 @@
package deckers.thibault.aves.channel.streams; package deckers.thibault.aves.channel.streams;
import android.app.Activity; import android.content.Context;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
@ -12,14 +12,14 @@ import io.flutter.plugin.common.EventChannel;
public class MediaStoreStreamHandler implements EventChannel.StreamHandler { public class MediaStoreStreamHandler implements EventChannel.StreamHandler {
public static final String CHANNEL = "deckers.thibault/aves/mediastorestream"; public static final String CHANNEL = "deckers.thibault/aves/mediastorestream";
private Activity activity; private Context context;
private EventChannel.EventSink eventSink; private EventChannel.EventSink eventSink;
private Handler handler; private Handler handler;
private Map<Integer, Integer> knownEntries; private Map<Integer, Integer> knownEntries;
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public MediaStoreStreamHandler(Activity activity, Object arguments) { public MediaStoreStreamHandler(Context context, Object arguments) {
this.activity = activity; this.context = context;
if (arguments instanceof Map) { if (arguments instanceof Map) {
Map<String, Object> argMap = (Map<String, Object>) arguments; Map<String, Object> argMap = (Map<String, Object>) arguments;
this.knownEntries = (Map<Integer, Integer>) argMap.get("knownEntries"); this.knownEntries = (Map<Integer, Integer>) argMap.get("knownEntries");
@ -47,7 +47,7 @@ public class MediaStoreStreamHandler implements EventChannel.StreamHandler {
} }
void fetchAll() { void fetchAll() {
new MediaStoreImageProvider().fetchAll(activity, knownEntries, this::success); // 350ms new MediaStoreImageProvider().fetchAll(context, knownEntries, this::success); // 350ms
endOfStream(); endOfStream();
} }
} }

View file

@ -32,8 +32,8 @@ public class StorageAccessStreamHandler implements EventChannel.StreamHandler {
public void onListen(Object o, final EventChannel.EventSink eventSink) { public void onListen(Object o, final EventChannel.EventSink eventSink) {
this.eventSink = eventSink; this.eventSink = eventSink;
this.handler = new Handler(Looper.getMainLooper()); this.handler = new Handler(Looper.getMainLooper());
Runnable onGranted = () -> success(!PermissionManager.requireVolumeAccessDialog(activity, path)); Runnable onGranted = () -> success(true); // user gave access to a directory, with no guarantee that it matches the specified `path`
Runnable onDenied = () -> success(false); Runnable onDenied = () -> success(false); // user cancelled
PermissionManager.requestVolumeAccess(activity, path, onGranted, onDenied); PermissionManager.requestVolumeAccess(activity, path, onGranted, onDenied);
} }

View file

@ -8,6 +8,7 @@ import android.media.MediaMetadataRetriever;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import com.drew.imaging.ImageMetadataReader; import com.drew.imaging.ImageMetadataReader;
@ -53,7 +54,7 @@ public class SourceImageEntry {
public SourceImageEntry() { public SourceImageEntry() {
} }
public SourceImageEntry(Map<String, Object> map) { public SourceImageEntry(@NonNull Map<String, Object> map) {
this.uri = Uri.parse((String) map.get("uri")); this.uri = Uri.parse((String) map.get("uri"));
this.path = (String) map.get("path"); this.path = (String) map.get("path");
this.sourceMimeType = (String) map.get("sourceMimeType"); this.sourceMimeType = (String) map.get("sourceMimeType");
@ -121,7 +122,7 @@ public class SourceImageEntry {
// expects entry with: uri, mimeType // expects entry with: uri, mimeType
// finds: width, height, orientation/rotation, date, title, duration // finds: width, height, orientation/rotation, date, title, duration
public SourceImageEntry fillPreCatalogMetadata(Context context) { public SourceImageEntry fillPreCatalogMetadata(@NonNull Context context) {
fillByMediaMetadataRetriever(context); fillByMediaMetadataRetriever(context);
if (hasSize() && (!isVideo() || hasDuration())) return this; if (hasSize() && (!isVideo() || hasDuration())) return this;
fillByMetadataExtractor(context); fillByMetadataExtractor(context);
@ -132,7 +133,7 @@ public class SourceImageEntry {
// expects entry with: uri, mimeType // expects entry with: uri, mimeType
// finds: width, height, orientation/rotation, date, title, duration // 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); MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri);
try { try {
String width = null, height = null, rotation = null, durationMillis = null; String width = null, height = null, rotation = null, durationMillis = null;
@ -182,7 +183,7 @@ public class SourceImageEntry {
// expects entry with: uri, mimeType // expects entry with: uri, mimeType
// finds: width, height, orientation, date // finds: width, height, orientation, date
private void fillByMetadataExtractor(Context context) { private void fillByMetadataExtractor(@NonNull Context context) {
if (isSvg()) return; if (isSvg()) return;
try (InputStream is = StorageUtils.openInputStream(context, uri)) { try (InputStream is = StorageUtils.openInputStream(context, uri)) {
@ -244,7 +245,7 @@ public class SourceImageEntry {
// expects entry with: uri // expects entry with: uri
// finds: width, height // finds: width, height
private void fillByBitmapDecode(Context context) { private void fillByBitmapDecode(@NonNull Context context) {
if (isSvg()) return; if (isSvg()) return;
try (InputStream is = StorageUtils.openInputStream(context, uri)) { try (InputStream is = StorageUtils.openInputStream(context, uri)) {
@ -260,7 +261,7 @@ public class SourceImageEntry {
// convenience method // convenience method
private static Long toLong(Object o) { private static Long toLong(@Nullable Object o) {
if (o == null) return null; if (o == null) return null;
if (o instanceof Integer) return Long.valueOf((Integer) o); if (o instanceof Integer) return Long.valueOf((Integer) o);
return (long) o; return (long) o;

View file

@ -1,6 +1,5 @@
package deckers.thibault.aves.model.provider; package deckers.thibault.aves.model.provider;
import android.app.Activity;
import android.content.ContentUris; import android.content.ContentUris;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
@ -49,15 +48,15 @@ public abstract class ImageProvider {
callback.onFailure(new UnsupportedOperationException()); callback.onFailure(new UnsupportedOperationException());
} }
public ListenableFuture<Object> delete(final Activity activity, final String path, final Uri uri) { public ListenableFuture<Object> delete(final Context context, final String path, final Uri uri) {
return Futures.immediateFailedFuture(new UnsupportedOperationException()); return Futures.immediateFailedFuture(new UnsupportedOperationException());
} }
public void moveMultiple(final Activity activity, final Boolean copy, final String destinationDir, final List<AvesImageEntry> entries, @NonNull final ImageOpCallback callback) { public void moveMultiple(final Context context, final Boolean copy, final String destinationDir, final List<AvesImageEntry> entries, @NonNull final ImageOpCallback callback) {
callback.onFailure(new UnsupportedOperationException()); 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) { if (oldPath == null) {
callback.onFailure(new IllegalArgumentException("entry does not have a path, uri=" + oldMediaUri)); callback.onFailure(new IllegalArgumentException("entry does not have a path, uri=" + oldMediaUri));
return; return;
@ -71,7 +70,7 @@ public abstract class ImageProvider {
return; return;
} }
DocumentFileCompat df = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri); DocumentFileCompat df = StorageUtils.getDocumentFile(context, oldPath, oldMediaUri);
try { try {
boolean renamed = df != null && df.renameTo(newFilename); boolean renamed = df != null && df.renameTo(newFilename);
if (!renamed) { if (!renamed) {
@ -83,27 +82,27 @@ public abstract class ImageProvider {
return; return;
} }
MediaScannerConnection.scanFile(activity, new String[]{oldPath}, new String[]{mimeType}, null); MediaScannerConnection.scanFile(context, new String[]{oldPath}, new String[]{mimeType}, null);
scanNewPath(activity, newFile.getPath(), mimeType, callback); 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) { switch (mimeType) {
case MimeTypes.JPEG: case MimeTypes.JPEG:
rotateJpeg(activity, path, uri, clockwise, callback); rotateJpeg(context, path, uri, clockwise, callback);
break; break;
case MimeTypes.PNG: case MimeTypes.PNG:
rotatePng(activity, path, uri, clockwise, callback); rotatePng(context, path, uri, clockwise, callback);
break; break;
default: default:
callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType)); 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 String mimeType = MimeTypes.JPEG;
final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(activity, path, uri); final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(context, path, uri);
if (originalDocumentFile == null) { if (originalDocumentFile == null) {
callback.onFailure(new Exception("failed to get document file for path=" + path + ", uri=" + uri)); callback.onFailure(new Exception("failed to get document file for path=" + path + ", uri=" + uri));
return; return;
@ -148,7 +147,7 @@ public abstract class ImageProvider {
Map<String, Object> newFields = new HashMap<>(); Map<String, Object> newFields = new HashMap<>();
newFields.put("orientationDegrees", orientationDegrees); newFields.put("orientationDegrees", orientationDegrees);
// ContentResolver contentResolver = activity.getContentResolver(); // ContentResolver contentResolver = context.getContentResolver();
// ContentValues values = new ContentValues(); // ContentValues values = new ContentValues();
// // from Android Q, media store update needs to be flagged IS_PENDING first // // from Android Q, media store update needs to be flagged IS_PENDING first
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -163,17 +162,17 @@ public abstract class ImageProvider {
// // TODO TLAD catch RecoverableSecurityException // // TODO TLAD catch RecoverableSecurityException
// int updatedRowCount = contentResolver.update(uri, values, null, null); // int updatedRowCount = contentResolver.update(uri, values, null, null);
// if (updatedRowCount > 0) { // 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 { // } else {
// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri); // Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
// callback.onSuccess(newFields); // 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 String mimeType = MimeTypes.PNG;
final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(activity, path, uri); final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(context, path, uri);
if (originalDocumentFile == null) { if (originalDocumentFile == null) {
callback.onFailure(new Exception("failed to get document file for path=" + path + ", uri=" + uri)); callback.onFailure(new Exception("failed to get document file for path=" + path + ", uri=" + uri));
return; return;
@ -188,7 +187,7 @@ public abstract class ImageProvider {
Bitmap originalImage; Bitmap originalImage;
try { try {
originalImage = BitmapFactory.decodeStream(StorageUtils.openInputStream(activity, uri)); originalImage = BitmapFactory.decodeStream(StorageUtils.openInputStream(context, uri));
} catch (FileNotFoundException e) { } catch (FileNotFoundException e) {
callback.onFailure(e); callback.onFailure(e);
return; return;
@ -220,7 +219,7 @@ public abstract class ImageProvider {
newFields.put("width", rotatedWidth); newFields.put("width", rotatedWidth);
newFields.put("height", rotatedHeight); newFields.put("height", rotatedHeight);
// ContentResolver contentResolver = activity.getContentResolver(); // ContentResolver contentResolver = context.getContentResolver();
// ContentValues values = new ContentValues(); // ContentValues values = new ContentValues();
// // from Android Q, media store update needs to be flagged IS_PENDING first // // from Android Q, media store update needs to be flagged IS_PENDING first
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -235,15 +234,15 @@ public abstract class ImageProvider {
// // TODO TLAD catch RecoverableSecurityException // // TODO TLAD catch RecoverableSecurityException
// int updatedRowCount = contentResolver.update(uri, values, null, null); // int updatedRowCount = contentResolver.update(uri, values, null, null);
// if (updatedRowCount > 0) { // 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 { // } else {
// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri); // Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
// callback.onSuccess(newFields); // callback.onSuccess(newFields);
// } // }
} }
protected void scanNewPath(final Activity activity, final String path, final String mimeType, final ImageOpCallback callback) { protected void scanNewPath(final Context context, final String path, final String mimeType, final ImageOpCallback callback) {
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (newPath, newUri) -> { MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (newPath, newUri) -> {
Log.d(LOG_TAG, "scanNewPath onScanCompleted with newPath=" + newPath + ", newUri=" + newUri); Log.d(LOG_TAG, "scanNewPath onScanCompleted with newPath=" + newPath + ", newUri=" + newUri);
long contentId = 0; 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 // we retrieve updated fields as the renamed file became a new entry in the Media Store
String[] projection = {MediaStore.MediaColumns.TITLE}; String[] projection = {MediaStore.MediaColumns.TITLE};
try { 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 != null) {
if (cursor.moveToNext()) { if (cursor.moveToNext()) {
newFields.put("uri", contentUri.toString()); newFields.put("uri", contentUri.toString());

View file

@ -1,7 +1,6 @@
package deckers.thibault.aves.model.provider; package deckers.thibault.aves.model.provider;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ContentUris; import android.content.ContentUris;
import android.content.ContentValues; import android.content.ContentValues;
import android.content.Context; import android.content.Context;
@ -210,14 +209,14 @@ public class MediaStoreImageProvider extends ImageProvider {
} }
@Override @Override
public ListenableFuture<Object> delete(final Activity activity, final String path, final Uri mediaUri) { public ListenableFuture<Object> delete(final Context context, final String path, final Uri mediaUri) {
SettableFuture<Object> future = SettableFuture.create(); SettableFuture<Object> future = SettableFuture.create();
if (StorageUtils.requireAccessPermission(path)) { if (StorageUtils.requireAccessPermission(path)) {
// if the file is on SD card, calling the content resolver delete() removes the entry from the Media Store // 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 // but it doesn't delete the file, even if the app has the permission
try { try {
DocumentFileCompat df = StorageUtils.getDocumentFile(activity, path, mediaUri); DocumentFileCompat df = StorageUtils.getDocumentFile(context, path, mediaUri);
if (df != null && df.delete()) { if (df != null && df.delete()) {
future.set(null); future.set(null);
} else { } else {
@ -230,7 +229,7 @@ public class MediaStoreImageProvider extends ImageProvider {
} }
try { try {
if (activity.getContentResolver().delete(mediaUri, null, null) > 0) { if (context.getContentResolver().delete(mediaUri, null, null) > 0) {
future.set(null); future.set(null);
} else { } else {
future.setException(new Exception("failed to delete row from content provider")); future.setException(new Exception("failed to delete row from content provider"));
@ -242,11 +241,11 @@ public class MediaStoreImageProvider extends ImageProvider {
return future; return future;
} }
private String getVolumeName(final Activity activity, String path) { private String getVolumeNameForMediaStore(@NonNull Context context, @NonNull String anyPath) {
String volumeName = "external"; String volumeName = "external";
StorageManager sm = activity.getSystemService(StorageManager.class); StorageManager sm = context.getSystemService(StorageManager.class);
if (sm != null) { if (sm != null) {
StorageVolume volume = sm.getStorageVolume(new File(path)); StorageVolume volume = sm.getStorageVolume(new File(anyPath));
if (volume != null && !volume.isPrimary()) { if (volume != null && !volume.isPrimary()) {
String uuid = volume.getUuid(); String uuid = volume.getUuid();
if (uuid != null) { if (uuid != null) {
@ -260,14 +259,14 @@ public class MediaStoreImageProvider extends ImageProvider {
} }
@Override @Override
public void moveMultiple(final Activity activity, final Boolean copy, final String destinationDir, final List<AvesImageEntry> entries, @NonNull final ImageOpCallback callback) { public void moveMultiple(final Context context, final Boolean copy, final String destinationDir, final List<AvesImageEntry> entries, @NonNull final ImageOpCallback callback) {
DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(activity, destinationDir); DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(context, destinationDir);
if (destinationDirDocFile == null) { if (destinationDirDocFile == null) {
callback.onFailure(new Exception("failed to create directory at path=" + destinationDir)); callback.onFailure(new Exception("failed to create directory at path=" + destinationDir));
return; return;
} }
MediaStoreMoveDestination destination = new MediaStoreMoveDestination(activity, destinationDir); MediaStoreMoveDestination destination = new MediaStoreMoveDestination(context, destinationDir);
if (destination.volumePath == null) { if (destination.volumePath == null) {
callback.onFailure(new Exception("failed to set up destination volume path for path=" + destinationDir)); callback.onFailure(new Exception("failed to set up destination volume path for path=" + destinationDir));
return; return;
@ -282,14 +281,14 @@ public class MediaStoreImageProvider extends ImageProvider {
put("uri", sourceUri.toString()); put("uri", sourceUri.toString());
}}; }};
// TODO TLAD check if there is any downside to use tree document files with scoped storage on API 30+ // on API 30 we cannot get access granted directly to a volume root from its document tree,
// when testing scoped storage on API 29, it seems less constraining to use tree document files than to rely on the Media Store // but it is still less constraining to use tree document files than to rely on the Media Store
try { try {
ListenableFuture<Map<String, Object>> newFieldsFuture; ListenableFuture<Map<String, Object>> newFieldsFuture;
// if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { // 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 { // } else {
newFieldsFuture = moveSingleByTreeDocAndScan(activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy); newFieldsFuture = moveSingleByTreeDocAndScan(context, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy);
// } // }
Map<String, Object> newFields = newFieldsFuture.get(); Map<String, Object> newFields = newFieldsFuture.get();
result.put("success", true); result.put("success", true);
@ -309,7 +308,7 @@ public class MediaStoreImageProvider extends ImageProvider {
// - there is no documentation regarding support for usage with removable storage // - 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 // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
@RequiresApi(api = Build.VERSION_CODES.Q) @RequiresApi(api = Build.VERSION_CODES.Q)
private ListenableFuture<Map<String, Object>> moveSingleByMediaStoreInsert(final Activity activity, final String sourcePath, final Uri sourceUri, private ListenableFuture<Map<String, Object>> moveSingleByMediaStoreInsert(final Context context, final String sourcePath, final Uri sourceUri,
final MediaStoreMoveDestination destination, final String mimeType, final boolean copy) { final MediaStoreMoveDestination destination, final String mimeType, final boolean copy) {
SettableFuture<Map<String, Object>> future = SettableFuture.create(); SettableFuture<Map<String, Object>> 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) // 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.RELATIVE_PATH, destination.relativePath);
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName); contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName);
String volumeName = destination.volumeNameForMediaStore;
Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ?
MediaStore.Video.Media.getContentUri(destination.volumeName) : MediaStore.Video.Media.getContentUri(volumeName) :
MediaStore.Images.Media.getContentUri(destination.volumeName); MediaStore.Images.Media.getContentUri(volumeName);
Uri destinationUri = activity.getContentResolver().insert(tableUrl, contentValues); Uri destinationUri = context.getContentResolver().insert(tableUrl, contentValues);
if (destinationUri == null) { if (destinationUri == null) {
future.setException(new Exception("failed to insert row to content resolver")); future.setException(new Exception("failed to insert row to content resolver"));
} else { } else {
DocumentFileCompat sourceFile = DocumentFileCompat.fromSingleUri(activity, sourceUri); DocumentFileCompat sourceFile = DocumentFileCompat.fromSingleUri(context, sourceUri);
DocumentFileCompat destinationFile = DocumentFileCompat.fromSingleUri(activity, destinationUri); DocumentFileCompat destinationFile = DocumentFileCompat.fromSingleUri(context, destinationUri);
sourceFile.copyTo(destinationFile); sourceFile.copyTo(destinationFile);
boolean deletedSource = false; boolean deletedSource = false;
if (!copy) { if (!copy) {
// delete original entry // delete original entry
try { try {
delete(activity, sourcePath, sourceUri).get(); delete(context, sourcePath, sourceUri).get();
deletedSource = true; deletedSource = true;
} catch (ExecutionException | InterruptedException e) { } catch (ExecutionException | InterruptedException e) {
Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, 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 can create an item via `DocumentFile.createFile()`, but:
// - we need to scan the file to get the Media Store content URI // - we need to scan the file to get the Media Store content URI
// - the underlying document provider controls the new file name // - the underlying document provider controls the new file name
private ListenableFuture<Map<String, Object>> moveSingleByTreeDocAndScan(final Activity activity, final String sourcePath, final Uri sourceUri, final String destinationDir, final DocumentFileCompat destinationDirDocFile, final String mimeType, boolean copy) { private ListenableFuture<Map<String, Object>> moveSingleByTreeDocAndScan(final Context context, final String sourcePath, final Uri sourceUri, final String destinationDir, final DocumentFileCompat destinationDirDocFile, final String mimeType, boolean copy) {
SettableFuture<Map<String, Object>> future = SettableFuture.create(); SettableFuture<Map<String, Object>> future = SettableFuture.create();
try { try {
@ -375,12 +375,12 @@ public class MediaStoreImageProvider extends ImageProvider {
// through a document URI, not a tree URI // through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
DocumentFileCompat destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension); 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.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
// `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument" // `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument"
// when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri` // 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); source.copyTo(destinationDocFile);
// the source file name and the created document file name can be different when: // 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) { if (!copy) {
// delete original entry // delete original entry
try { try {
delete(activity, sourcePath, sourceUri).get(); delete(context, sourcePath, sourceUri).get();
deletedSource = true; deletedSource = true;
} catch (ExecutionException | InterruptedException e) { } catch (ExecutionException | InterruptedException e) {
Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, 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; boolean finalDeletedSource = deletedSource;
scanNewPath(activity, destinationFullPath, mimeType, new ImageProvider.ImageOpCallback() { scanNewPath(context, destinationFullPath, mimeType, new ImageProvider.ImageOpCallback() {
@Override @Override
public void onSuccess(Map<String, Object> newFields) { public void onSuccess(Map<String, Object> newFields) {
newFields.put("deletedSource", finalDeletedSource); newFields.put("deletedSource", finalDeletedSource);
@ -430,15 +430,15 @@ public class MediaStoreImageProvider extends ImageProvider {
} }
class MediaStoreMoveDestination { class MediaStoreMoveDestination {
final String volumeName; final String volumeNameForMediaStore;
final String volumePath; final String volumePath;
final String relativePath; final String relativePath;
final String fullPath; final String fullPath;
MediaStoreMoveDestination(Activity activity, String destinationDir) { MediaStoreMoveDestination(@NonNull Context context, @NonNull String destinationDir) {
fullPath = destinationDir; fullPath = destinationDir;
volumeName = getVolumeName(activity, destinationDir); volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir);
volumePath = StorageUtils.getVolumePath(activity, destinationDir).orElse(null); volumePath = StorageUtils.getVolumePath(context, destinationDir).orElse(null);
relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null; relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null;
} }
} }

View file

@ -1,6 +1,7 @@
package deckers.thibault.aves.utils; package deckers.thibault.aves.utils;
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;
@ -11,12 +12,19 @@ import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AlertDialog;
import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityCompat;
import com.google.common.base.Splitter;
import java.io.File; 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.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
public class PermissionManager { public class PermissionManager {
@ -27,20 +35,6 @@ public class PermissionManager {
// permission request code to pending runnable // permission request code to pending runnable
private static ConcurrentHashMap<Integer, PendingPermissionHandler> pendingPermissionMap = new ConcurrentHashMap<>(); private static ConcurrentHashMap<Integer, PendingPermissionHandler> 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<UriPermission> 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) { 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); 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)); 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); 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); Log.d(LOG_TAG, "onPermissionResult with requestCode=" + requestCode + ", treeUri=" + treeUri);
boolean granted = treeUri != null; boolean granted = treeUri != null;
PendingPermissionHandler handler = pendingPermissionMap.remove(requestCode); PendingPermissionHandler handler = pendingPermissionMap.remove(requestCode);
if (handler == null) return; 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; Runnable runnable = granted ? handler.onGranted : handler.onDenied;
if (runnable == null) return; if (runnable == null) return;
runnable.run(); runnable.run();
} }
private static boolean isTreeUriPath(String path, Uri treeUri) { public static Optional<String> getGrantedDirForPath(@NonNull Context context, @NonNull String anyPath) {
// TODO TLAD check requestedPath match treeUri return getGrantedDirs(context).stream().filter(anyPath::startsWith).findFirst();
// 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); public static List<Map<String, String>> getInaccessibleDirectories(@NonNull Context context, @NonNull List<String> dirPaths) {
return true; Set<String> grantedDirs = getGrantedDirs(context);
// find set of inaccessible directories for each volume
Map<String, Set<String>> 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<String> 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<String> 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<Map<String, String>> inaccessibleDirs = new ArrayList<>();
StorageManager sm = context.getSystemService(StorageManager.class);
if (sm != null) {
for (Map.Entry<String, Set<String>> 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<String, String> 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<String> getGrantedDirs(Context context) {
HashSet<String> accessibleDirs = new HashSet<>();
// find paths matching URIs granted by the user
for (UriPermission uriPermission : context.getContentResolver().getPersistedUriPermissions()) {
Optional<String> 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 { static class PendingPermissionHandler {
final String path; final String path;
final Runnable onGranted; final Runnable onGranted; // user gave access to a directory, with no guarantee that it matches the specified `path`
final Runnable onDenied; final Runnable onDenied; // user cancelled
PendingPermissionHandler(@NonNull String path, @NonNull Runnable onGranted, @NonNull Runnable onDenied) { PendingPermissionHandler(@NonNull String path, @NonNull Runnable onGranted, @NonNull Runnable onDenied) {
this.path = path; this.path = path;

View file

@ -1,14 +1,15 @@
package deckers.thibault.aves.utils; package deckers.thibault.aves.utils;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ContentResolver; import android.content.ContentResolver;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences;
import android.media.MediaMetadataRetriever; import android.media.MediaMetadataRetriever;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Environment; import android.os.Environment;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import android.provider.DocumentsContract;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
@ -21,9 +22,6 @@ import com.commonsware.cwac.document.DocumentFileCompat;
import com.google.common.base.Splitter; import com.google.common.base.Splitter;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File; import java.io.File;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
@ -31,13 +29,13 @@ import java.io.InputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class StorageUtils { public class StorageUtils {
private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class); private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class);
@ -52,35 +50,37 @@ public class StorageUtils {
// primary volume path, with trailing "/" // primary volume path, with trailing "/"
private static String mPrimaryVolumePath; private static String mPrimaryVolumePath;
private static String getPrimaryVolumePath() { public static String getPrimaryVolumePath() {
if (mPrimaryVolumePath == null) { if (mPrimaryVolumePath == null) {
mPrimaryVolumePath = findPrimaryVolumePath(); mPrimaryVolumePath = findPrimaryVolumePath();
} }
return mPrimaryVolumePath; return mPrimaryVolumePath;
} }
public static String[] getVolumePaths(Context context) { public static String[] getVolumePaths(@NonNull Context context) {
if (mStorageVolumePaths == null) { if (mStorageVolumePaths == null) {
mStorageVolumePaths = findVolumePaths(context); mStorageVolumePaths = findVolumePaths(context);
} }
return mStorageVolumePaths; return mStorageVolumePaths;
} }
public static Optional<String> getVolumePath(Context context, @NonNull String anyPath) { public static Optional<String> getVolumePath(@NonNull Context context, @NonNull String anyPath) {
return Arrays.stream(getVolumePaths(context)).filter(anyPath::startsWith).findFirst(); return Arrays.stream(getVolumePaths(context)).filter(anyPath::startsWith).findFirst();
} }
@Nullable @Nullable
private static Iterator<String> getPathStepIterator(Context context, @NonNull String anyPath) { private static Iterator<String> getPathStepIterator(@NonNull Context context, @NonNull String anyPath, @Nullable String root) {
Optional<String> volumePathOpt = getVolumePath(context, anyPath); if (root == null) {
if (!volumePathOpt.isPresent()) return null; root = getVolumePath(context, anyPath).orElse(null);
if (root == null) return null;
}
String relativePath = null, filename = null; String relativePath = null, filename = null;
int lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1; int lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1;
int volumePathLength = volumePathOpt.get().length(); int rootLength = root.length();
if (lastSeparatorIndex > volumePathLength) { if (lastSeparatorIndex > rootLength) {
filename = anyPath.substring(lastSeparatorIndex); filename = anyPath.substring(lastSeparatorIndex);
relativePath = anyPath.substring(volumePathLength, lastSeparatorIndex); relativePath = anyPath.substring(rootLength, lastSeparatorIndex);
} }
if (relativePath == null) return null; if (relativePath == null) return null;
@ -134,7 +134,7 @@ public class StorageUtils {
Log.e(LOG_TAG, "insomnia", e); Log.e(LOG_TAG, "insomnia", e);
} }
} }
} while(!validFiles); } while (!validFiles);
for (File file : files) { for (File file : files) {
String applicationSpecificAbsolutePath = file.getAbsolutePath(); String applicationSpecificAbsolutePath = file.getAbsolutePath();
String emulatedRootPath = applicationSpecificAbsolutePath.substring(0, applicationSpecificAbsolutePath.indexOf("Android/data")); String emulatedRootPath = applicationSpecificAbsolutePath.substring(0, applicationSpecificAbsolutePath.indexOf("Android/data"));
@ -225,43 +225,86 @@ public class StorageUtils {
* Volume tree URIs * Volume tree URIs
*/ */
// serialized map from storage volume paths to their document tree URIs, from the Documents Provider private static Optional<String> getVolumeUuidForTreeUri(@NonNull Context context, @NonNull String anyPath) {
// e.g. "/storage/12A9-8B42" -> "content://com.android.externalstorage.documents/tree/12A9-8B42%3A" StorageManager sm = context.getSystemService(StorageManager.class);
private static final String PREF_VOLUME_TREE_URIS = "volume_tree_uris"; if (sm != null) {
StorageVolume volume = sm.getStorageVolume(new File(anyPath));
public static void setVolumeTreeUri(Activity activity, String volumePath, String treeUri) { if (volume != null) {
Map<String, String> map = getVolumeTreeUris(activity); if (volume.isPrimary()) {
map.put(volumePath, treeUri); return Optional.of("primary");
}
SharedPreferences.Editor editor = activity.getPreferences(Context.MODE_PRIVATE).edit(); String uuid = volume.getUuid();
String json = new JSONObject(map).toString(); if (uuid != null) {
editor.putString(PREF_VOLUME_TREE_URIS, json); return Optional.of(uuid.toUpperCase());
editor.apply(); }
}
}
Log.e(LOG_TAG, "failed to find volume UUID for anyPath=" + anyPath);
return Optional.empty();
} }
private static Map<String, String> getVolumeTreeUris(Activity activity) { private static Optional<String> getVolumePathFromTreeUriUuid(@NonNull Context context, @NonNull String uuid) {
Map<String, String> map = new HashMap<>(); if (uuid.equals("primary")) {
return Optional.of(getPrimaryVolumePath());
SharedPreferences preferences = activity.getPreferences(Context.MODE_PRIVATE); }
String json = preferences.getString(PREF_VOLUME_TREE_URIS, new JSONObject().toString()); StorageManager sm = context.getSystemService(StorageManager.class);
if (json != null) { if (sm != null) {
for (String volumePath : StorageUtils.getVolumePaths(context)) {
try { try {
JSONObject jsonObject = new JSONObject(json); StorageVolume volume = sm.getStorageVolume(new File(volumePath));
Iterator<String> iterator = jsonObject.keys(); if (volume != null && uuid.equalsIgnoreCase(volume.getUuid())) {
while (iterator.hasNext()) { return Optional.of(volumePath);
String k = iterator.next();
String v = (String) jsonObject.get(k);
map.put(k, v);
} }
} catch (JSONException e) { } catch (IllegalArgumentException e) {
Log.w(LOG_TAG, "failed to read volume tree URIs from preferences", e); // ignore
} }
} }
return map; }
Log.e(LOG_TAG, "failed to find volume path for UUID=" + uuid);
return Optional.empty();
} }
public static Optional<String> getVolumeTreeUriForPath(Activity activity, String anyPath) { // e.g.
return StorageUtils.getVolumePath(activity, anyPath).map(volumePath -> getVolumeTreeUris(activity).get(volumePath)); // /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<Uri> convertDirPathToTreeUri(@NonNull Context context, @NonNull String dirPath) {
Optional<String> 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<String> 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<String> 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 @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)) { if (requireAccessPermission(anyPath)) {
// need a document URI (not a media content URI) to open a `DocumentFile` output stream // need a document URI (not a media content URI) to open a `DocumentFile` output stream
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// cleanest API to get it // cleanest API to get it
Uri docUri = MediaStore.getDocumentUri(activity, mediaUri); Uri docUri = MediaStore.getDocumentUri(context, mediaUri);
if (docUri != null) { if (docUri != null) {
return DocumentFileCompat.fromSingleUri(activity, docUri); return DocumentFileCompat.fromSingleUri(context, docUri);
} }
} }
// fallback for older APIs // fallback for older APIs
Uri volumeTreeUri = PermissionManager.getVolumeTreeUri(activity, anyPath); return getVolumePath(context, anyPath)
Optional<DocumentFileCompat> docFile = getDocumentFileFromVolumeTree(activity, volumeTreeUri, anyPath); .flatMap(volumePath -> convertDirPathToTreeUri(context, volumePath)
return docFile.orElse(null); .flatMap(treeUri -> getDocumentFileFromVolumeTree(context, treeUri, anyPath)))
.orElse(null);
} }
// good old `File` // good old `File`
return DocumentFileCompat.fromFile(new File(anyPath)); 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 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 // returns null if directory does not exist and could not be created
public static DocumentFileCompat createDirectoryIfAbsent(@NonNull Activity activity, @NonNull String directoryPath) { public static DocumentFileCompat createDirectoryIfAbsent(@NonNull Context context, @NonNull String dirPath) {
if (requireAccessPermission(directoryPath)) { if (!dirPath.endsWith(File.separator)) {
Uri rootTreeUri = PermissionManager.getVolumeTreeUri(activity, directoryPath); dirPath += File.separator;
DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(activity, rootTreeUri); }
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 (parentFile == null) return null;
if (!directoryPath.endsWith(File.separator)) { Iterator<String> pathIterator = getPathStepIterator(context, dirPath, grantedDir);
directoryPath += File.separator;
}
Iterator<String> pathIterator = getPathStepIterator(activity, directoryPath);
while (pathIterator != null && pathIterator.hasNext()) { while (pathIterator != null && pathIterator.hasNext()) {
String dirName = pathIterator.next(); String dirName = pathIterator.next();
DocumentFileCompat dirFile = findDocumentFileIgnoreCase(parentFile, dirName); DocumentFileCompat dirFile = findDocumentFileIgnoreCase(parentFile, dirName);
@ -319,10 +369,10 @@ public class StorageUtils {
} }
return parentFile; return parentFile;
} else { } else {
File directory = new File(directoryPath); File directory = new File(dirPath);
if (!directory.exists()) { if (!directory.exists()) {
if (!directory.mkdirs()) { 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; return null;
} }
} }
@ -338,23 +388,19 @@ public class StorageUtils {
temp.deleteOnExit(); temp.deleteOnExit();
return temp.getPath(); return temp.getPath();
} catch (IOException e) { } 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; return null;
} }
private static Optional<DocumentFileCompat> getDocumentFileFromVolumeTree(Context context, Uri rootTreeUri, String path) { private static Optional<DocumentFileCompat> getDocumentFileFromVolumeTree(Context context, @NonNull Uri rootTreeUri, @NonNull String anyPath) {
if (rootTreeUri == null || path == null) {
return Optional.empty();
}
DocumentFileCompat documentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri); DocumentFileCompat documentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri);
if (documentFile == null) { if (documentFile == null) {
return Optional.empty(); return Optional.empty();
} }
// follow the entry path down the document tree // follow the entry path down the document tree
Iterator<String> pathIterator = getPathStepIterator(context, path); Iterator<String> pathIterator = getPathStepIterator(context, anyPath, null);
while (pathIterator != null && pathIterator.hasNext()) { while (pathIterator != null && pathIterator.hasNext()) {
documentFile = findDocumentFileIgnoreCase(documentFile, pathIterator.next()); documentFile = findDocumentFileIgnoreCase(documentFile, pathIterator.next());
if (documentFile == null) { if (documentFile == null) {
@ -393,7 +439,7 @@ public class StorageUtils {
return uri != null && ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme()) && MediaStore.AUTHORITY.equalsIgnoreCase(uri.getHost()); 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) { 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 // we get a permission denial if we require original from a provider other than the media store
if (isMediaStoreContentUri(uri)) { if (isMediaStoreContentUri(uri)) {
@ -403,7 +449,7 @@ public class StorageUtils {
return context.getContentResolver().openInputStream(uri); 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(); MediaMetadataRetriever retriever = new MediaMetadataRetriever();
try { try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -414,8 +460,28 @@ public class StorageUtils {
} }
retriever.setDataSource(context, uri); retriever.setDataSource(context, uri);
} catch (Exception e) { } 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; 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);
}
}
}
} }

View file

@ -88,7 +88,7 @@ class AndroidAppService {
} }
} }
static Future<void> share(Set<ImageEntry> entries) async { static Future<void> share(Iterable<ImageEntry> entries) async {
// loosen mime type to a generic one, so we can share with badly defined apps // 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 // e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats
final urisByMimeType = groupBy<ImageEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); final urisByMimeType = groupBy<ImageEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));

View file

@ -18,16 +18,18 @@ class AndroidFileService {
return []; return [];
} }
static Future<bool> requireVolumeAccessDialog(String path) async { // returns a list of directories,
// each directory is a map with "volumePath", "volumeDescription", "relativeDir"
static Future<List<Map>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
try { try {
final result = await platform.invokeMethod('requireVolumeAccessDialog', <String, dynamic>{ final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{
'path': path, 'dirPaths': dirPaths.toList(),
}); });
return result as bool; return (result as List).cast<Map>();
} on PlatformException catch (e) { } 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` // returns whether user granted access to volume root at `volumePath`

View file

@ -1,34 +1,30 @@
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/services/android_file_service.dart'; import 'package:aves/services/android_file_service.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:tuple/tuple.dart';
mixin PermissionAwareMixin { mixin PermissionAwareMixin {
Future<bool> checkStoragePermission(BuildContext context, Iterable<ImageEntry> entries) { Future<bool> checkStoragePermission(BuildContext context, Iterable<ImageEntry> 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<bool> checkStoragePermissionForPaths(BuildContext context, Iterable<String> paths) async { Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async {
final volumes = paths.map(androidFileUtils.getStorageVolume).toSet(); while (true) {
final ungrantedVolumes = (await Future.wait<Tuple2<StorageVolume, bool>>( final dirs = await AndroidFileService.getInaccessibleDirectories(albumPaths);
volumes.map( if (dirs == null) return false;
(volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then( if (dirs.isEmpty) return true;
(granted) => Tuple2(volume, granted),
), final dir = dirs.first;
), final volumePath = dir['volumePath'] as String;
)) final volumeDescription = dir['volumeDescription'] as String;
.where((t) => t.item2) final relativeDir = dir['relativeDir'] as String;
.map((t) => t.item1) final dirDisplayName = relativeDir.isEmpty ? 'root' : '$relativeDir';
.toList();
while (ungrantedVolumes.isNotEmpty) {
final volume = ungrantedVolumes.first;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return AlertDialog( return AlertDialog(
title: const Text('Storage Volume Access'), 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: [ actions: [
FlatButton( FlatButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@ -45,15 +41,11 @@ mixin PermissionAwareMixin {
// abort if the user cancels in Flutter // abort if the user cancels in Flutter
if (confirmed == null || !confirmed) return false; if (confirmed == null || !confirmed) return false;
final granted = await AndroidFileService.requestVolumeAccess(volume.path); final granted = await AndroidFileService.requestVolumeAccess(volumePath);
debugPrint('$runtimeType _checkStoragePermission with volume=${volume.path} got granted=$granted'); if (!granted) {
if (granted) {
ungrantedVolumes.remove(volume);
} else {
// abort if the user denies access from the native dialog // abort if the user denies access from the native dialog
return false; return false;
} }
} }
return true;
} }
} }

View file

@ -100,7 +100,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
), ),
); );
if (destinationAlbum == null || destinationAlbum.isEmpty) return; if (destinationAlbum == null || destinationAlbum.isEmpty) return;
if (!await checkStoragePermissionForPaths(context, [destinationAlbum])) return; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
final selection = collection.selection.toList(); final selection = collection.selection.toList();
if (!await checkStoragePermission(context, selection)) return; if (!await checkStoragePermission(context, selection)) return;

View file

@ -7,7 +7,6 @@ import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.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/services/image_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';
@ -17,7 +16,6 @@ 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:outline_material_icons/outline_material_icons.dart'; import 'package:outline_material_icons/outline_material_icons.dart';
import 'package:tuple/tuple.dart';
class DebugPage extends StatefulWidget { class DebugPage extends StatefulWidget {
final CollectionSource source; final CollectionSource source;
@ -35,7 +33,6 @@ 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;
Future<Map> _envLoader; Future<Map> _envLoader;
List<ImageEntry> get entries => widget.source.rawEntries; List<ImageEntry> get entries => widget.source.rawEntries;
@ -44,13 +41,6 @@ class DebugPageState extends State<DebugPage> {
void initState() { void initState() {
super.initState(); super.initState();
_startDbReport(); _startDbReport();
_volumePermissionLoader = Future.wait<Tuple2<String, bool>>(
androidFileUtils.storageVolumes.map(
(volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then(
(value) => Tuple2(volume.path, !value),
),
),
);
_envLoader = AndroidAppService.getEnv(); _envLoader = AndroidAppService.getEnv();
} }
@ -298,15 +288,6 @@ class DebugPageState extends State<DebugPage> {
Widget _buildStorageTabView() { Widget _buildStorageTabView() {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [
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: [ children: [
...androidFileUtils.storageVolumes.expand((v) => [ ...androidFileUtils.storageVolumes.expand((v) => [
Text(v.path), Text(v.path),
@ -316,16 +297,11 @@ class DebugPageState extends State<DebugPage> {
'isPrimary': '${v.isPrimary}', 'isPrimary': '${v.isPrimary}',
'isRemovable': '${v.isRemovable}', 'isRemovable': '${v.isRemovable}',
'state': '${v.state}', 'state': '${v.state}',
'permission': '${permissions.firstWhere((t) => t.item1 == v.path, orElse: () => null)?.item2 ?? false}',
}), }),
const Divider(), const Divider(),
]) ])
], ],
); );
},
),
],
);
} }
Widget _buildEnvTabView() { Widget _buildEnvTabView() {

View file

@ -1,7 +1,7 @@
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.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/services/android_app_service.dart';
import 'package:aves/utils/geo_utils.dart'; import 'package:aves/utils/geo_utils.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart';

View file

@ -1,10 +1,10 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/image_entry.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/color_utils.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/album/empty.dart'; import 'package:aves/widgets/album/empty.dart';