Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2020-07-06 09:26:03 +09:00
commit 617487963c
17 changed files with 385 additions and 364 deletions

View file

@ -51,12 +51,12 @@ android {
manifestPlaceholders = [googleApiKey:keystoreProperties['googleApiKey']]
}
// compileOptions {
// // enable support for Java 8 language APIs (stream, optional, etc.)
// coreLibraryDesugaringEnabled true
// sourceCompatibility JavaVersion.VERSION_1_8
// targetCompatibility JavaVersion.VERSION_1_8
// }
compileOptions {
// enable support for Java 8 language APIs (stream, optional, etc.)
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
signingConfigs {
release {
@ -105,7 +105,7 @@ repositories {
dependencies {
// enable support for Java 8 language APIs (stream, optional, etc.)
// coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.5'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9'
implementation "androidx.exifinterface:exifinterface:1.2.0"
implementation 'com.commonsware.cwac:document:0.4.1'

View file

@ -18,8 +18,6 @@ import deckers.thibault.aves.channel.streams.ImageByteStreamHandler;
import deckers.thibault.aves.channel.streams.ImageOpStreamHandler;
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler;
import deckers.thibault.aves.channel.streams.StorageAccessStreamHandler;
import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.Utils;
import io.flutter.embedding.android.FlutterActivity;
@ -61,9 +59,10 @@ public class MainActivity extends FlutterActivity {
intentDataMap = null;
String resultUri = call.argument("uri");
if (resultUri != null) {
Intent data = new Intent();
data.setData(Uri.parse(resultUri));
setResult(RESULT_OK, data);
Intent intent = new Intent();
intent.setData(Uri.parse(resultUri));
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
setResult(RESULT_OK, intent);
} else {
setResult(RESULT_CANCELED);
}
@ -92,20 +91,20 @@ public class MainActivity extends FlutterActivity {
case Intent.ACTION_PICK:
intentDataMap = new HashMap<>();
intentDataMap.put("action", "pick");
intentDataMap.put("mimeType", intent.getType());
break;
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == Constants.SD_CARD_PERMISSION_REQUEST_CODE) {
if (requestCode == PermissionManager.VOLUME_ROOT_PERMISSION_REQUEST_CODE) {
if (resultCode != RESULT_OK || data.getData() == null) {
PermissionManager.onPermissionResult(requestCode, false);
PermissionManager.onPermissionResult(this, requestCode, false, null);
return;
}
Uri treeUri = data.getData();
Env.setSdCardDocumentUri(this, treeUri.toString());
// save access permissions across reboots
final int takeFlags = data.getFlags()
@ -114,7 +113,7 @@ public class MainActivity extends FlutterActivity {
getContentResolver().takePersistableUriPermission(treeUri, takeFlags);
// resume pending action
PermissionManager.onPermissionResult(requestCode, true);
PermissionManager.onPermissionResult(this, requestCode, true, treeUri);
}
}
}

View file

@ -38,7 +38,6 @@ import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.MetadataHelper;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.StorageUtils;
@ -75,6 +74,32 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
private static final String XMP_GENERIC_LANG = "";
private static final String XMP_SPECIFIC_LANG = "en-US";
// video metadata keys, from android.media.MediaMetadataRetriever
private static final Map<Integer, String> VIDEO_MEDIA_METADATA_KEYS = new HashMap<Integer, String>() {
{
put(MediaMetadataRetriever.METADATA_KEY_ALBUM, "Album");
put(MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST, "Album Artist");
put(MediaMetadataRetriever.METADATA_KEY_ARTIST, "Artist");
put(MediaMetadataRetriever.METADATA_KEY_AUTHOR, "Author");
put(MediaMetadataRetriever.METADATA_KEY_BITRATE, "Bitrate");
put(MediaMetadataRetriever.METADATA_KEY_COMPOSER, "Composer");
put(MediaMetadataRetriever.METADATA_KEY_DATE, "Date");
put(MediaMetadataRetriever.METADATA_KEY_GENRE, "Content Type");
put(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO, "Has Audio");
put(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO, "Has Video");
put(MediaMetadataRetriever.METADATA_KEY_LOCATION, "Location");
put(MediaMetadataRetriever.METADATA_KEY_MIMETYPE, "MIME Type");
put(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS, "Number of Tracks");
put(MediaMetadataRetriever.METADATA_KEY_TITLE, "Title");
put(MediaMetadataRetriever.METADATA_KEY_WRITER, "Writer");
put(MediaMetadataRetriever.METADATA_KEY_YEAR, "Year");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
put(MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT, "Frame Count");
}
// TODO TLAD comment? category?
}
};
// Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
// Examples:
// "+37.5090+127.0243/" (Samsung)
@ -171,7 +196,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
Map<String, String> dirMap = new HashMap<>();
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, Uri.parse(uri));
try {
for (Map.Entry<Integer, String> kv : Constants.MEDIA_METADATA_KEYS.entrySet()) {
for (Map.Entry<Integer, String> kv : VIDEO_MEDIA_METADATA_KEYS.entrySet()) {
Integer key = kv.getKey();
String value = retriever.extractMetadata(key);
if (value != null) {

View file

@ -14,8 +14,8 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.StorageUtils;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
@ -42,10 +42,13 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
result.success(volumes);
break;
}
case "hasGrantedPermissionToVolumeRoot": {
case "requireVolumeAccessDialog": {
String path = call.argument("path");
boolean granted = PermissionManager.hasGrantedPermissionToVolumeRoot(activity, path);
result.success(granted);
if (path == null) {
result.success(true);
} else {
result.success(PermissionManager.requireVolumeAccessDialog(activity, path));
}
break;
}
default:
@ -59,13 +62,12 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
List<Map<String, Object>> volumes = new ArrayList<>();
StorageManager sm = activity.getSystemService(StorageManager.class);
if (sm != null) {
for (String path : Env.getStorageVolumeRoots(activity)) {
for (String volumePath : StorageUtils.getVolumePaths(activity)) {
try {
File file = new File(path);
StorageVolume volume = sm.getStorageVolume(file);
StorageVolume volume = sm.getStorageVolume(new File(volumePath));
if (volume != null) {
Map<String, Object> volumeMap = new HashMap<>();
volumeMap.put("path", path);
volumeMap.put("path", volumePath);
volumeMap.put("description", volume.getDescription(activity));
volumeMap.put("isPrimary", volume.isPrimary());
volumeMap.put("isRemovable", volume.isRemovable());

View file

@ -32,7 +32,7 @@ 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.hasGrantedPermissionToVolumeRoot(activity, volumePath));
Runnable onGranted = () -> success(!PermissionManager.requireVolumeAccessDialog(activity, volumePath));
Runnable onDenied = () -> success(false);
PermissionManager.requestVolumeAccess(activity, volumePath, onGranted, onDenied);
}

View file

@ -2,6 +2,7 @@ package deckers.thibault.aves.model.provider;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
@ -32,7 +33,6 @@ import java.util.List;
import java.util.Map;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.MetadataHelper;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.PermissionManager;
@ -78,13 +78,10 @@ public abstract class ImageProvider {
return;
}
if (Env.requireAccessPermission(oldPath)) {
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity);
if (sdCardTreeUri == null) {
Runnable runnable = () -> rename(activity, oldPath, oldMediaUri, mimeType, newFilename, callback);
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showSdCardAccessDialog(activity, runnable));
return;
}
if (PermissionManager.requireVolumeAccessDialog(activity, oldPath)) {
Runnable runnable = () -> rename(activity, oldPath, oldMediaUri, mimeType, newFilename, callback);
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, oldPath, runnable));
return;
}
DocumentFileCompat df = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri);
@ -103,23 +100,33 @@ public abstract class ImageProvider {
MediaScannerConnection.scanFile(activity, new String[]{newFile.getPath()}, new String[]{mimeType}, (newPath, newUri) -> {
Log.d(LOG_TAG, "onScanCompleted with newPath=" + newPath + ", newUri=" + newUri);
if (newUri != null) {
// we retrieve updated fields as the renamed file became a new entry in the Media Store
String[] projection = {MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.TITLE};
try {
Cursor cursor = activity.getContentResolver().query(newUri, projection, null, null, null);
if (cursor != null) {
if (cursor.moveToNext()) {
long contentId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID));
newFields.put("uri", newUri.toString());
newFields.put("contentId", contentId);
newFields.put("path", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)));
newFields.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE)));
// newURI is a file media URI (e.g. "content://media/12a9-8b42/file/62872")
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
long contentId = ContentUris.parseId(newUri);
Uri contentUri = null;
if (mimeType.startsWith(MimeTypes.IMAGE)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId);
} else if (mimeType.startsWith(MimeTypes.VIDEO)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId);
}
if (contentUri != null) {
// we retrieve updated fields as the renamed file became a new entry in the Media Store
String[] projection = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.TITLE};
try {
Cursor cursor = activity.getContentResolver().query(contentUri, projection, null, null, null);
if (cursor != null) {
if (cursor.moveToNext()) {
newFields.put("uri", contentUri.toString());
newFields.put("contentId", contentId);
newFields.put("path", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)));
newFields.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE)));
}
cursor.close();
}
cursor.close();
} catch (Exception e) {
callback.onFailure(e);
return;
}
} catch (Exception e) {
callback.onFailure(e);
return;
}
}
callback.onSuccess(newFields);
@ -127,6 +134,12 @@ public abstract class ImageProvider {
}
public void rotate(final Activity activity, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
if (PermissionManager.requireVolumeAccessDialog(activity, path)) {
Runnable runnable = () -> rotate(activity, path, uri, mimeType, clockwise, callback);
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, path, runnable));
return;
}
switch (mimeType) {
case MimeTypes.JPEG:
rotateJpeg(activity, path, uri, clockwise, callback);
@ -155,14 +168,6 @@ public abstract class ImageProvider {
return;
}
if (Env.requireAccessPermission(path)) {
if (PermissionManager.getSdCardTreeUri(activity) == null) {
Runnable runnable = () -> rotate(activity, path, uri, mimeType, clockwise, callback);
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showSdCardAccessDialog(activity, runnable));
return;
}
}
int newOrientationCode;
try {
ExifInterface exif = new ExifInterface(editablePath);
@ -195,24 +200,26 @@ public abstract class ImageProvider {
Map<String, Object> newFields = new HashMap<>();
newFields.put("orientationDegrees", orientationDegrees);
ContentResolver contentResolver = activity.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) {
values.put(MediaStore.MediaColumns.IS_PENDING, 1);
contentResolver.update(uri, values, null, null);
values.clear();
values.put(MediaStore.MediaColumns.IS_PENDING, 0);
}
// uses MediaStore.Images.Media instead of MediaStore.MediaColumns for APIs < Q
values.put(MediaStore.Images.Media.ORIENTATION, orientationDegrees);
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));
} else {
Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
callback.onSuccess(newFields);
}
// ContentResolver contentResolver = activity.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) {
// values.put(MediaStore.MediaColumns.IS_PENDING, 1);
// // TODO TLAD catch RecoverableSecurityException
// contentResolver.update(uri, values, null, null);
// values.clear();
// values.put(MediaStore.MediaColumns.IS_PENDING, 0);
// }
// // uses MediaStore.Images.Media instead of MediaStore.MediaColumns for APIs < Q
// values.put(MediaStore.Images.Media.ORIENTATION, orientationDegrees);
// // 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));
// } 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) {
@ -231,15 +238,13 @@ public abstract class ImageProvider {
return;
}
if (Env.requireAccessPermission(path)) {
if (PermissionManager.getSdCardTreeUri(activity) == null) {
Runnable runnable = () -> rotate(activity, path, uri, mimeType, clockwise, callback);
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showSdCardAccessDialog(activity, runnable));
return;
}
Bitmap originalImage;
try {
originalImage = BitmapFactory.decodeStream(StorageUtils.openInputStream(activity, uri));
} catch (FileNotFoundException e) {
callback.onFailure(e);
return;
}
Bitmap originalImage = BitmapFactory.decodeFile(path);
if (originalImage == null) {
callback.onFailure(new Exception("failed to decode image at path=" + path));
return;
@ -267,24 +272,26 @@ public abstract class ImageProvider {
newFields.put("width", rotatedWidth);
newFields.put("height", rotatedHeight);
ContentResolver contentResolver = activity.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) {
values.put(MediaStore.MediaColumns.IS_PENDING, 1);
contentResolver.update(uri, values, null, null);
values.clear();
values.put(MediaStore.MediaColumns.IS_PENDING, 0);
}
values.put(MediaStore.MediaColumns.WIDTH, rotatedWidth);
values.put(MediaStore.MediaColumns.HEIGHT, rotatedHeight);
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));
} else {
Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
callback.onSuccess(newFields);
}
// ContentResolver contentResolver = activity.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) {
// values.put(MediaStore.MediaColumns.IS_PENDING, 1);
// // TODO TLAD catch RecoverableSecurityException
// contentResolver.update(uri, values, null, null);
// values.clear();
// values.put(MediaStore.MediaColumns.IS_PENDING, 0);
// }
// values.put(MediaStore.MediaColumns.WIDTH, rotatedWidth);
// values.put(MediaStore.MediaColumns.HEIGHT, rotatedHeight);
// // 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));
// } else {
// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
// callback.onSuccess(newFields);
// }
}
public interface ImageOpCallback {

View file

@ -34,7 +34,6 @@ import java.util.stream.Stream;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.model.SourceImageEntry;
import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.StorageUtils;
@ -217,9 +216,8 @@ public class MediaStoreImageProvider extends ImageProvider {
public ListenableFuture<Object> delete(final Activity activity, final String path, final Uri mediaUri) {
SettableFuture<Object> future = SettableFuture.create();
if (Env.requireAccessPermission(path)) {
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity);
if (sdCardTreeUri == null) {
if (StorageUtils.requireAccessPermission(path)) {
if (PermissionManager.getVolumeTreeUri(activity, path) == null) {
Runnable runnable = () -> {
try {
future.set(delete(activity, path, mediaUri).get());
@ -227,7 +225,7 @@ public class MediaStoreImageProvider extends ImageProvider {
future.setException(e);
}
};
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showSdCardAccessDialog(activity, runnable));
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, path, runnable));
return future;
}
@ -278,6 +276,12 @@ public class MediaStoreImageProvider extends ImageProvider {
@Override
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List<AvesImageEntry> entries, @NonNull ImageOpCallback callback) {
if (PermissionManager.requireVolumeAccessDialog(activity, destinationDir)) {
Runnable runnable = () -> moveMultiple(activity, copy, destinationDir, entries, callback);
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, destinationDir, runnable));
return;
}
DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(activity, destinationDir);
if (destinationDirDocFile == null) {
callback.onFailure(new Exception("failed to create directory at path=" + destinationDir));
@ -332,6 +336,7 @@ public class MediaStoreImageProvider extends ImageProvider {
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DATA, destinationPath);
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
// TODO TLAD when not using legacy storage (~Q, R+), provide relative path (assess first whether the root is the "Pictures" folder or the root)
// contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, "");
// contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "");
Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? MediaStore.Video.Media.getContentUri(volumeName) : MediaStore.Images.Media.getContentUri(volumeName);
@ -339,7 +344,7 @@ public class MediaStoreImageProvider extends ImageProvider {
if (destinationUri == null) {
future.setException(new Exception("failed to insert row to content resolver"));
} else {
DocumentFileCompat source = DocumentFileCompat.fromFile(new File(sourcePath));
DocumentFileCompat source = DocumentFileCompat.fromSingleUri(activity, sourceUri);
DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri);
source.copyTo(destination);
@ -410,6 +415,7 @@ public class MediaStoreImageProvider extends ImageProvider {
MediaScannerConnection.scanFile(activity, new String[]{destinationPath}, new String[]{mimeType}, (newPath, newUri) -> {
Map<String, Object> newFields = new HashMap<>();
if (newUri != null) {
// TODO TLAD check whether newURI is a file media URI (cf case in `rename`)
// we retrieve updated fields as the moved file became a new entry in the Media Store
String[] projection = {MediaStore.MediaColumns._ID};
try {

View file

@ -1,38 +0,0 @@
package deckers.thibault.aves.utils;
import android.media.MediaMetadataRetriever;
import android.os.Build;
import java.util.HashMap;
import java.util.Map;
public class Constants {
public static final int SD_CARD_PERMISSION_REQUEST_CODE = 1;
// video metadata keys, from android.media.MediaMetadataRetriever
public static final Map<Integer, String> MEDIA_METADATA_KEYS = new HashMap<Integer, String>() {
{
put(MediaMetadataRetriever.METADATA_KEY_ALBUM, "Album");
put(MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST, "Album Artist");
put(MediaMetadataRetriever.METADATA_KEY_ARTIST, "Artist");
put(MediaMetadataRetriever.METADATA_KEY_AUTHOR, "Author");
put(MediaMetadataRetriever.METADATA_KEY_BITRATE, "Bitrate");
put(MediaMetadataRetriever.METADATA_KEY_COMPOSER, "Composer");
put(MediaMetadataRetriever.METADATA_KEY_DATE, "Date");
put(MediaMetadataRetriever.METADATA_KEY_GENRE, "Content Type");
put(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO, "Has Audio");
put(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO, "Has Video");
put(MediaMetadataRetriever.METADATA_KEY_LOCATION, "Location");
put(MediaMetadataRetriever.METADATA_KEY_MIMETYPE, "MIME Type");
put(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS, "Number of Tracks");
put(MediaMetadataRetriever.METADATA_KEY_TITLE, "Title");
put(MediaMetadataRetriever.METADATA_KEY_WRITER, "Writer");
put(MediaMetadataRetriever.METADATA_KEY_YEAR, "Year");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
put(MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT, "Frame Count");
}
// TODO TLAD comment? category?
}
};
}

View file

@ -1,56 +0,0 @@
package deckers.thibault.aves.utils;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Environment;
import androidx.annotation.NonNull;
public class Env {
private static String[] mStorageVolumeRoots;
private static String mExternalStorage;
// SD card path as a content URI from the Documents Provider
// e.g. content://com.android.externalstorage.documents/tree/12A9-8B42%3A
private static String mSdCardDocumentUri;
private static final String PREF_SD_CARD_DOCUMENT_URI = "sd_card_document_uri";
public static void setSdCardDocumentUri(final Activity activity, String SdCardDocumentUri) {
mSdCardDocumentUri = SdCardDocumentUri;
SharedPreferences.Editor preferences = activity.getPreferences(Context.MODE_PRIVATE).edit();
preferences.putString(PREF_SD_CARD_DOCUMENT_URI, mSdCardDocumentUri);
preferences.apply();
}
public static String getSdCardDocumentUri(final Activity activity) {
if (mSdCardDocumentUri == null) {
SharedPreferences preferences = activity.getPreferences(Context.MODE_PRIVATE);
mSdCardDocumentUri = preferences.getString(PREF_SD_CARD_DOCUMENT_URI, null);
}
return mSdCardDocumentUri;
}
public static String[] getStorageVolumeRoots(final Activity activity) {
if (mStorageVolumeRoots == null) {
mStorageVolumeRoots = StorageUtils.getStorageVolumeRoots(activity);
}
return mStorageVolumeRoots;
}
private static String getExternalStorage() {
if (mExternalStorage == null) {
mExternalStorage = Environment.getExternalStorageDirectory().getAbsolutePath();
if (!mExternalStorage.endsWith("/")) {
mExternalStorage += "/";
}
}
return mExternalStorage;
}
public static boolean requireAccessPermission(@NonNull String path) {
boolean onPrimaryVolume = path.startsWith(getExternalStorage());
// TODO TLAD on Android R, we should require access permission even on primary
return !onPrimaryVolume;
}
}

View file

@ -1,37 +0,0 @@
package deckers.thibault.aves.utils;
import androidx.annotation.NonNull;
import java.io.File;
public class PathSegments {
private String storage;
private String relativePath;
private String filename;
public PathSegments(@NonNull String path, @NonNull String[] storageVolumePaths) {
for (int i = 0; i < storageVolumePaths.length && storage == null; i++) {
if (path.startsWith(storageVolumePaths[i])) {
storage = storageVolumePaths[i];
}
}
int lastSeparatorIndex = path.lastIndexOf(File.separator) + 1;
if (lastSeparatorIndex > storage.length()) {
filename = path.substring(lastSeparatorIndex);
relativePath = path.substring(storage.length(), lastSeparatorIndex);
}
}
public String getStorage() {
return storage;
}
public String getRelativePath() {
return relativePath;
}
public String getFilename() {
return filename;
}
}

View file

@ -11,10 +11,11 @@ import android.os.storage.StorageVolume;
import android.provider.DocumentsContract;
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 androidx.core.util.Pair;
import java.io.File;
import java.util.Optional;
@ -24,30 +25,40 @@ import java.util.stream.Stream;
public class PermissionManager {
private static final String LOG_TAG = Utils.createLogTag(PermissionManager.class);
// permission request code to pending runnable
private static ConcurrentHashMap<Integer, Pair<Runnable, Runnable>> pendingPermissionMap = new ConcurrentHashMap<>();
public static final int VOLUME_ROOT_PERMISSION_REQUEST_CODE = 1;
// check access permission to SD card directory & return its content URI if available
public static Uri getSdCardTreeUri(Activity activity) {
final String sdCardDocumentUri = Env.getSdCardDocumentUri(activity);
// 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(sdCardDocumentUri))
.filter(uriPermission -> uriPermission.getUri().toString().equals(volumeTreeUri))
.findFirst();
return uriPermissionOptional.map(UriPermission::getUri).orElse(null);
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public static void showSdCardAccessDialog(final Activity activity, final Runnable pendingRunnable) {
public static void showVolumeAccessDialog(final Activity activity, @NonNull String anyPath, final Runnable pendingRunnable) {
String volumePath = StorageUtils.getVolumePath(activity, anyPath).orElse(null);
// TODO TLAD show volume name/ID in the message
new AlertDialog.Builder(activity)
.setTitle("SD Card Access")
.setMessage("Please select the root directory of the SD card in the next screen, so that this app has permission to access it and complete your request.")
.setPositiveButton(android.R.string.ok, (dialog, button) -> requestVolumeAccess(activity, null, pendingRunnable, null))
.setTitle("Storage Volume Access")
.setMessage("Please select the root directory of the storage volume in the next screen, so that this app has permission to access it and complete your request.")
.setPositiveButton(android.R.string.ok, (dialog, button) -> requestVolumeAccess(activity, volumePath, pendingRunnable, null))
.show();
}
public static void requestVolumeAccess(Activity activity, String volumePath, Runnable onGranted, Runnable onDenied) {
Log.i(LOG_TAG, "request user to select and grant access permission to volume=" + volumePath);
pendingPermissionMap.put(Constants.SD_CARD_PERMISSION_REQUEST_CODE, Pair.create(onGranted, onDenied));
pendingPermissionMap.put(VOLUME_ROOT_PERMISSION_REQUEST_CODE, new PendingPermissionHandler(volumePath, onGranted, onDenied));
Intent intent = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && volumePath != null) {
@ -65,44 +76,37 @@ public class PermissionManager {
intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
}
ActivityCompat.startActivityForResult(activity, intent, Constants.SD_CARD_PERMISSION_REQUEST_CODE, null);
ActivityCompat.startActivityForResult(activity, intent, VOLUME_ROOT_PERMISSION_REQUEST_CODE, null);
}
public static void onPermissionResult(int requestCode, boolean granted) {
public static void onPermissionResult(Activity activity, int requestCode, boolean granted, Uri treeUri) {
Log.d(LOG_TAG, "onPermissionResult with requestCode=" + requestCode + ", granted=" + granted);
Pair<Runnable, Runnable> runnables = pendingPermissionMap.remove(requestCode);
if (runnables == null) return;
Runnable runnable = granted ? runnables.first : runnables.second;
PendingPermissionHandler handler = pendingPermissionMap.remove(requestCode);
if (handler == null) return;
StorageUtils.setVolumeTreeUri(activity, handler.volumePath, treeUri.toString());
Runnable runnable = granted ? handler.onGranted : handler.onDenied;
if (runnable == null) return;
runnable.run();
}
public static boolean hasGrantedPermissionToVolumeRoot(Context context, String path) {
boolean canAccess = false;
Stream<Uri> permittedUris = context.getContentResolver().getPersistedUriPermissions().stream().map(UriPermission::getUri);
// e.g. content://com.android.externalstorage.documents/tree/12A9-8B42%3A
StorageManager sm = context.getSystemService(StorageManager.class);
if (sm != null) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
StorageVolume volume = sm.getStorageVolume(new File(path));
if (volume != null) {
// primary storage doesn't have a UUID
String uuid = volume.isPrimary() ? "primary" : volume.getUuid();
Uri targetVolumeTreeUri = getVolumeTreeUriFromUuid(uuid);
canAccess = permittedUris.anyMatch(uri -> uri.equals(targetVolumeTreeUri));
}
} else {
// TODO TLAD find alternative for Android <N
canAccess = true;
}
}
return canAccess;
}
private static Uri getVolumeTreeUriFromUuid(String uuid) {
return DocumentsContract.buildTreeDocumentUri(
"com.android.externalstorage.documents",
uuid + ":"
);
}
static class PendingPermissionHandler {
String volumePath;
Runnable onGranted;
Runnable onDenied;
PendingPermissionHandler(String volumePath, Runnable onGranted, Runnable onDenied) {
this.volumePath = volumePath;
this.onGranted = onGranted;
this.onDenied = onDenied;
}
}
}

View file

@ -4,6 +4,7 @@ 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;
@ -20,6 +21,9 @@ 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;
@ -27,44 +31,69 @@ 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.Map;
import java.util.Optional;
import java.util.Set;
public class StorageUtils {
private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class);
private static boolean isMediaStoreContentUri(Uri uri) {
// a URI's authority is [userinfo@]host[:port]
// but we only want the host when comparing to Media Store's "authority"
return uri != null && ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme()) && MediaStore.AUTHORITY.equalsIgnoreCase(uri.getHost());
/**
* Volume paths
*/
private static String[] mStorageVolumePaths;
private static String mPrimaryVolumePath;
private static String getPrimaryVolumePath() {
if (mPrimaryVolumePath == null) {
mPrimaryVolumePath = findPrimaryVolumePath();
}
return mPrimaryVolumePath;
}
public static InputStream openInputStream(Context context, 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)) {
uri = MediaStore.setRequireOriginal(uri);
}
public static String[] getVolumePaths(Context context) {
if (mStorageVolumePaths == null) {
mStorageVolumePaths = findVolumePaths(context);
}
return context.getContentResolver().openInputStream(uri);
return mStorageVolumePaths;
}
public static MediaMetadataRetriever openMetadataRetriever(Context context, Uri uri) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
try {
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)) {
uri = MediaStore.setRequireOriginal(uri);
}
}
retriever.setDataSource(context, uri);
} catch (Exception e) {
Log.w(LOG_TAG, "failed to open MediaMetadataRetriever for uri=" + uri, e);
public static Optional<String> getVolumePath(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;
String relativePath = null, filename = null;
int lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1;
int volumePathLength = volumePathOpt.get().length();
if (lastSeparatorIndex > volumePathLength) {
filename = anyPath.substring(lastSeparatorIndex);
relativePath = anyPath.substring(volumePathLength, lastSeparatorIndex);
}
return retriever;
if (relativePath == null) return null;
ArrayList<String> pathSteps = Lists.newArrayList(Splitter.on(File.separatorChar)
.trimResults().omitEmptyStrings().split(relativePath));
if (filename.length() > 0) {
pathSteps.add(filename);
}
return pathSteps.iterator();
}
private static String findPrimaryVolumePath() {
String primaryVolumePath = Environment.getExternalStorageDirectory().getAbsolutePath();
if (!primaryVolumePath.endsWith("/")) {
primaryVolumePath += "/";
}
return primaryVolumePath;
}
/**
@ -77,7 +106,7 @@ public class StorageUtils {
* @return paths to all available SD-Cards in the system (include emulated)
*/
@SuppressLint("ObsoleteSdkInt")
public static String[] getStorageVolumeRoots(Context context) {
private static String[] findVolumePaths(Context context) {
// Final set of paths
final Set<String> rv = new HashSet<>();
@ -174,52 +203,56 @@ public class StorageUtils {
};
}
// variation on `DocumentFileCompat.findFile()` to allow case insensitive search
static private DocumentFileCompat findFileIgnoreCase(DocumentFileCompat documentFile, String displayName) {
for (DocumentFileCompat doc : documentFile.listFiles()) {
if (displayName.equalsIgnoreCase(doc.getName())) {
return doc;
/**
* 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);
}
} catch (JSONException e) {
Log.w(LOG_TAG, "failed to read volume tree URIs from preferences", e);
}
}
return null;
return map;
}
private static Optional<DocumentFileCompat> getSdCardDocumentFile(Context context, Uri rootTreeUri, String[] storageVolumeRoots, String path) {
if (rootTreeUri == null || storageVolumeRoots == null || path == null) {
return Optional.empty();
}
DocumentFileCompat documentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri);
if (documentFile == null) {
return Optional.empty();
}
// follow the entry path down the document tree
Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, path);
while (pathIterator.hasNext()) {
documentFile = findFileIgnoreCase(documentFile, pathIterator.next());
if (documentFile == null) {
return Optional.empty();
}
}
return Optional.of(documentFile);
}
private static Iterator<String> getPathStepIterator(String[] storageVolumeRoots, String path) {
PathSegments pathSegments = new PathSegments(path, storageVolumeRoots);
ArrayList<String> pathSteps = Lists.newArrayList(Splitter.on(File.separatorChar)
.trimResults().omitEmptyStrings().split(pathSegments.getRelativePath()));
String filename = pathSegments.getFilename();
if (filename != null && filename.length() > 0) {
pathSteps.add(filename);
}
return pathSteps.iterator();
public static Optional<String> getVolumeTreeUriForPath(Activity activity, String anyPath) {
return StorageUtils.getVolumePath(activity, anyPath).map(volumePath -> getVolumeTreeUris(activity).get(volumePath));
}
/**
* Document files
*/
@Nullable
public static DocumentFileCompat getDocumentFile(@NonNull Activity activity, @NonNull String path, @NonNull Uri mediaUri) {
if (Env.requireAccessPermission(path)) {
public static DocumentFileCompat getDocumentFile(@NonNull Activity activity, @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
@ -229,31 +262,29 @@ public class StorageUtils {
}
}
// fallback for older APIs
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity);
String[] storageVolumeRoots = Env.getStorageVolumeRoots(activity);
Optional<DocumentFileCompat> docFile = StorageUtils.getSdCardDocumentFile(activity, sdCardTreeUri, storageVolumeRoots, path);
Uri volumeTreeUri = PermissionManager.getVolumeTreeUri(activity, anyPath);
Optional<DocumentFileCompat> docFile = getDocumentFileFromVolumeTree(activity, volumeTreeUri, anyPath);
return docFile.orElse(null);
}
// good old `File`
return DocumentFileCompat.fromFile(new File(path));
return DocumentFileCompat.fromFile(new File(anyPath));
}
// 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 (Env.requireAccessPermission(directoryPath)) {
Uri rootTreeUri = PermissionManager.getSdCardTreeUri(activity);
if (requireAccessPermission(directoryPath)) {
Uri rootTreeUri = PermissionManager.getVolumeTreeUri(activity, directoryPath);
DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(activity, rootTreeUri);
if (parentFile == null) return null;
String[] storageVolumeRoots = Env.getStorageVolumeRoots(activity);
if (!directoryPath.endsWith(File.separator)) {
directoryPath += File.separator;
}
Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, directoryPath);
while (pathIterator.hasNext()) {
Iterator<String> pathIterator = getPathStepIterator(activity, directoryPath);
while (pathIterator != null && pathIterator.hasNext()) {
String dirName = pathIterator.next();
DocumentFileCompat dirFile = findFileIgnoreCase(parentFile, dirName);
DocumentFileCompat dirFile = findDocumentFileIgnoreCase(parentFile, dirName);
if (dirFile == null || !dirFile.exists()) {
try {
dirFile = parentFile.createDirectory(dirName);
@ -293,4 +324,77 @@ public class StorageUtils {
}
return null;
}
private static Optional<DocumentFileCompat> getDocumentFileFromVolumeTree(Context context, Uri rootTreeUri, String path) {
if (rootTreeUri == null || path == null) {
return Optional.empty();
}
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);
while (pathIterator != null && pathIterator.hasNext()) {
documentFile = findDocumentFileIgnoreCase(documentFile, pathIterator.next());
if (documentFile == null) {
return Optional.empty();
}
}
return Optional.of(documentFile);
}
// variation on `DocumentFileCompat.findFile()` to allow case insensitive search
private static DocumentFileCompat findDocumentFileIgnoreCase(DocumentFileCompat documentFile, String displayName) {
for (DocumentFileCompat doc : documentFile.listFiles()) {
if (displayName.equalsIgnoreCase(doc.getName())) {
return doc;
}
}
return null;
}
/**
* Misc
*/
public static boolean requireAccessPermission(@NonNull String anyPath) {
boolean onPrimaryVolume = anyPath.startsWith(getPrimaryVolumePath());
// TODO TLAD on Android R, we should require access permission even on primary
return !onPrimaryVolume;
}
private static boolean isMediaStoreContentUri(Uri uri) {
// a URI's authority is [userinfo@]host[:port]
// but we only want the host when comparing to Media Store's "authority"
return uri != null && ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme()) && MediaStore.AUTHORITY.equalsIgnoreCase(uri.getHost());
}
public static InputStream openInputStream(Context context, 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)) {
uri = MediaStore.setRequireOriginal(uri);
}
}
return context.getContentResolver().openInputStream(uri);
}
public static MediaMetadataRetriever openMetadataRetriever(Context context, Uri uri) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
try {
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)) {
uri = MediaStore.setRequireOriginal(uri);
}
}
retriever.setDataSource(context, uri);
} catch (Exception e) {
Log.w(LOG_TAG, "failed to open MediaMetadataRetriever for uri=" + uri, e);
}
return retriever;
}
}

View file

@ -18,14 +18,14 @@ class AndroidFileService {
return [];
}
static Future<bool> hasGrantedPermissionToVolumeRoot(String path) async {
static Future<bool> requireVolumeAccessDialog(String path) async {
try {
final result = await platform.invokeMethod('hasGrantedPermissionToVolumeRoot', <String, dynamic>{
final result = await platform.invokeMethod('requireVolumeAccessDialog', <String, dynamic>{
'path': path,
});
return result as bool;
} on PlatformException catch (e) {
debugPrint('hasGrantedPermissionToVolumeRoot failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
debugPrint('requireVolumeAccessDialog failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
}
return false;
}

View file

@ -10,16 +10,17 @@ mixin PermissionAwareMixin {
}
Future<bool> checkStoragePermissionForPaths(BuildContext context, Iterable<String> paths) async {
final volumes = paths.map((path) => androidFileUtils.getStorageVolume(path)).toSet();
final removableVolumes = volumes.where((v) => v.isRemovable);
final volumePermissions = await Future.wait<Tuple2<StorageVolume, bool>>(
removableVolumes.map(
(volume) => AndroidFileService.hasGrantedPermissionToVolumeRoot(volume.path).then(
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),
),
),
);
final ungrantedVolumes = volumePermissions.where((t) => !t.item2).map((t) => t.item1).toList();
))
.where((t) => t.item2)
.map((t) => t.item1)
.toList();
while (ungrantedVolumes.isNotEmpty) {
final volume = ungrantedVolumes.first;
final confirmed = await showDialog<bool>(

View file

@ -46,8 +46,8 @@ class DebugPageState extends State<DebugPage> {
_startDbReport();
_volumePermissionLoader = Future.wait<Tuple2<String, bool>>(
androidFileUtils.storageVolumes.map(
(volume) => AndroidFileService.hasGrantedPermissionToVolumeRoot(volume.path).then(
(value) => Tuple2(volume.path, value),
(volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then(
(value) => Tuple2(volume.path, !value),
),
),
);

View file

@ -65,6 +65,10 @@ class _HomePageState extends State<HomePage> {
break;
case 'pick':
AvesApp.mode = AppMode.pick;
// TODO TLAD apply pick mimetype(s)
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
String pickMimeTypes = intentData['mimeType'];
debugPrint('pick mimeType=' + pickMimeTypes);
break;
}
}

View file

@ -11,7 +11,7 @@ description: A new Flutter application.
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.0.6+7
version: 1.0.7+8
# video_player (as of v0.10.8+2, backed by ExoPlayer):
# - does not support content URIs (by default, but trivial by fork)