Merge branch 'develop'
This commit is contained in:
commit
617487963c
17 changed files with 385 additions and 364 deletions
|
@ -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'
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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?
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>(
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue