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

View file

@ -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<String> 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<Map<String, Object>> getStorageVolumes() {
List<Map<String, Object>> 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<String, Object> 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());

View file

@ -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<String, Object> 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<String, Object>) arguments;
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());
provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() {
provider.moveMultiple(context, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() {
@Override
public void onSuccess(Map<String, Object> 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);

View file

@ -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<Integer, Integer> 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<String, Object> argMap = (Map<String, Object>) arguments;
this.knownEntries = (Map<Integer, Integer>) 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();
}
}

View file

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

View file

@ -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<String, Object> map) {
public SourceImageEntry(@NonNull Map<String, Object> 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;

View file

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

View file

@ -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<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();
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<AvesImageEntry> 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<AvesImageEntry> 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<Map<String, Object>> 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<String, Object> 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<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) {
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)
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<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();
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<String, Object> 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;
}
}

View file

@ -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<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) {
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<String> getGrantedDirForPath(@NonNull Context context, @NonNull String anyPath) {
return getGrantedDirs(context).stream().filter(anyPath::startsWith).findFirst();
}
public static List<Map<String, String>> getInaccessibleDirectories(@NonNull Context context, @NonNull List<String> dirPaths) {
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 {
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;

View file

@ -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<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();
}
@Nullable
private static Iterator<String> getPathStepIterator(Context context, @NonNull String anyPath) {
Optional<String> volumePathOpt = getVolumePath(context, anyPath);
if (!volumePathOpt.isPresent()) return null;
private static Iterator<String> 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;
@ -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<String, String> 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 Optional<String> 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());
}
}
}
Log.e(LOG_TAG, "failed to find volume UUID for anyPath=" + anyPath);
return Optional.empty();
}
private static Map<String, String> getVolumeTreeUris(Activity activity) {
Map<String, String> map = new HashMap<>();
SharedPreferences preferences = activity.getPreferences(Context.MODE_PRIVATE);
String json = preferences.getString(PREF_VOLUME_TREE_URIS, new JSONObject().toString());
if (json != null) {
private static Optional<String> 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 {
JSONObject jsonObject = new JSONObject(json);
Iterator<String> iterator = jsonObject.keys();
while (iterator.hasNext()) {
String k = iterator.next();
String v = (String) jsonObject.get(k);
map.put(k, v);
StorageVolume volume = sm.getStorageVolume(new File(volumePath));
if (volume != null && uuid.equalsIgnoreCase(volume.getUuid())) {
return Optional.of(volumePath);
}
} catch (JSONException e) {
Log.w(LOG_TAG, "failed to read volume tree URIs from preferences", e);
} catch (IllegalArgumentException 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) {
return StorageUtils.getVolumePath(activity, anyPath).map(volumePath -> getVolumeTreeUris(activity).get(volumePath));
// 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<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
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<DocumentFileCompat> 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<String> pathIterator = getPathStepIterator(activity, directoryPath);
Iterator<String> 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<DocumentFileCompat> getDocumentFileFromVolumeTree(Context context, Uri rootTreeUri, String path) {
if (rootTreeUri == null || path == null) {
return Optional.empty();
}
private static Optional<DocumentFileCompat> 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<String> pathIterator = getPathStepIterator(context, path);
Iterator<String> 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);
}
}
}
}

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
// 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()));

View file

@ -18,16 +18,18 @@ class AndroidFileService {
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 {
final result = await platform.invokeMethod('requireVolumeAccessDialog', <String, dynamic>{
'path': path,
final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{
'dirPaths': dirPaths.toList(),
});
return result as bool;
return (result as List).cast<Map>();
} 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`

View file

@ -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<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 {
final volumes = paths.map(androidFileUtils.getStorageVolume).toSet();
final ungrantedVolumes = (await Future.wait<Tuple2<StorageVolume, bool>>(
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<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> 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<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Storage Volume Access'),
content: Text('Please select the root directory of “${volume.description}” in the next screen, so that this app can access it and complete your request.'),
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;
}
}

View file

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

View file

@ -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<DebugPage> {
Future<List<CatalogMetadata>> _dbMetadataLoader;
Future<List<AddressDetails>> _dbAddressLoader;
Future<List<FavouriteRow>> _dbFavouritesLoader;
Future<List<Tuple2<String, bool>>> _volumePermissionLoader;
Future<Map> _envLoader;
List<ImageEntry> get entries => widget.source.rawEntries;
@ -44,13 +41,6 @@ class DebugPageState extends State<DebugPage> {
void initState() {
super.initState();
_startDbReport();
_volumePermissionLoader = Future.wait<Tuple2<String, bool>>(
androidFileUtils.storageVolumes.map(
(volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then(
(value) => Tuple2(volume.path, !value),
),
),
);
_envLoader = AndroidAppService.getEnv();
}
@ -298,15 +288,6 @@ class DebugPageState extends State<DebugPage> {
Widget _buildStorageTabView() {
return ListView(
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: [
...androidFileUtils.storageVolumes.expand((v) => [
Text(v.path),
@ -316,16 +297,11 @@ class DebugPageState extends State<DebugPage> {
'isPrimary': '${v.isPrimary}',
'isRemovable': '${v.isRemovable}',
'state': '${v.state}',
'permission': '${permissions.firstWhere((t) => t.item1 == v.path, orElse: () => null)?.item2 ?? false}',
}),
const Divider(),
])
],
);
},
),
],
);
}
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/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';

View file

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