API 30: handle access at directory level, request max but can process with min
This commit is contained in:
parent
e11769ce77
commit
d368fbe65c
18 changed files with 380 additions and 283 deletions
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
@ -134,7 +134,7 @@ public class StorageUtils {
|
|||
Log.e(LOG_TAG, "insomnia", e);
|
||||
}
|
||||
}
|
||||
} while(!validFiles);
|
||||
} while (!validFiles);
|
||||
for (File file : files) {
|
||||
String applicationSpecificAbsolutePath = file.getAbsolutePath();
|
||||
String emulatedRootPath = applicationSpecificAbsolutePath.substring(0, applicationSpecificAbsolutePath.indexOf("Android/data"));
|
||||
|
@ -225,43 +225,86 @@ public class StorageUtils {
|
|||
* Volume tree URIs
|
||||
*/
|
||||
|
||||
// serialized map from storage volume paths to their document tree URIs, from the Documents Provider
|
||||
// e.g. "/storage/12A9-8B42" -> "content://com.android.externalstorage.documents/tree/12A9-8B42%3A"
|
||||
private static final String PREF_VOLUME_TREE_URIS = "volume_tree_uris";
|
||||
|
||||
public static void setVolumeTreeUri(Activity activity, String volumePath, String treeUri) {
|
||||
Map<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 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) {
|
||||
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);
|
||||
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());
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
Log.w(LOG_TAG, "failed to read volume tree URIs from preferences", e);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
Log.e(LOG_TAG, "failed to find volume UUID for anyPath=" + anyPath);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static Optional<String> getVolumeTreeUriForPath(Activity activity, String anyPath) {
|
||||
return StorageUtils.getVolumePath(activity, anyPath).map(volumePath -> getVolumeTreeUris(activity).get(volumePath));
|
||||
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 {
|
||||
StorageVolume volume = sm.getStorageVolume(new File(volumePath));
|
||||
if (volume != null && uuid.equalsIgnoreCase(volume.getUuid())) {
|
||||
return Optional.of(volumePath);
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to find volume path for UUID=" + uuid);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// e.g.
|
||||
// /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A
|
||||
// /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures
|
||||
static Optional<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()));
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -34,8 +34,8 @@ class SourceStateAwareAppBarTitle extends StatelessWidget {
|
|||
child: sourceState == SourceState.ready
|
||||
? const SizedBox.shrink()
|
||||
: SourceStateSubtitle(
|
||||
source: source,
|
||||
),
|
||||
source: source,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -70,24 +70,24 @@ class SourceStateSubtitle extends StatelessWidget {
|
|||
return subtitle == null
|
||||
? const SizedBox.shrink()
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(subtitle, style: subtitleStyle),
|
||||
StreamBuilder<ProgressEvent>(
|
||||
stream: source.progressStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError || !snapshot.hasData) return const SizedBox.shrink();
|
||||
final progress = snapshot.data;
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 8),
|
||||
child: Text(
|
||||
'${progress.done}/${progress.total}',
|
||||
style: subtitleStyle.copyWith(color: Colors.white30),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(subtitle, style: subtitleStyle),
|
||||
StreamBuilder<ProgressEvent>(
|
||||
stream: source.progressStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError || !snapshot.hasData) return const SizedBox.shrink();
|
||||
final progress = snapshot.data;
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 8),
|
||||
child: Text(
|
||||
'${progress.done}/${progress.total}',
|
||||
style: subtitleStyle.copyWith(color: Colors.white30),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -299,31 +289,17 @@ class DebugPageState extends State<DebugPage> {
|
|||
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),
|
||||
InfoRowGroup({
|
||||
'description': '${v.description}',
|
||||
'isEmulated': '${v.isEmulated}',
|
||||
'isPrimary': '${v.isPrimary}',
|
||||
'isRemovable': '${v.isRemovable}',
|
||||
'state': '${v.state}',
|
||||
'permission': '${permissions.firstWhere((t) => t.item1 == v.path, orElse: () => null)?.item2 ?? false}',
|
||||
}),
|
||||
const Divider(),
|
||||
])
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
...androidFileUtils.storageVolumes.expand((v) => [
|
||||
Text(v.path),
|
||||
InfoRowGroup({
|
||||
'description': '${v.description}',
|
||||
'isEmulated': '${v.isEmulated}',
|
||||
'isPrimary': '${v.isPrimary}',
|
||||
'isRemovable': '${v.isRemovable}',
|
||||
'state': '${v.state}',
|
||||
}),
|
||||
const Divider(),
|
||||
])
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
Loading…
Reference in a new issue