From 4f30f5427ef05060fe848f5ef03d7b8dafda9b06 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 5 Jul 2020 14:58:05 +0900 Subject: [PATCH] storage access: handle permissions to multiple volumes --- .../deckers/thibault/aves/MainActivity.java | 9 +- .../aves/channel/calls/MetadataHandler.java | 29 +- .../aves/channel/calls/StorageHandler.java | 9 +- .../aves/model/provider/ImageProvider.java | 40 ++- .../provider/MediaStoreImageProvider.java | 8 +- .../thibault/aves/utils/Constants.java | 38 --- .../java/deckers/thibault/aves/utils/Env.java | 56 ---- .../thibault/aves/utils/PathSegments.java | 37 --- .../aves/utils/PermissionManager.java | 60 +++-- .../thibault/aves/utils/StorageUtils.java | 250 +++++++++++++----- 10 files changed, 273 insertions(+), 263 deletions(-) delete mode 100644 android/app/src/main/java/deckers/thibault/aves/utils/Constants.java delete mode 100644 android/app/src/main/java/deckers/thibault/aves/utils/Env.java delete mode 100644 android/app/src/main/java/deckers/thibault/aves/utils/PathSegments.java diff --git a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java index a47af8e50..4a845e44f 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -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; @@ -98,14 +96,13 @@ public class MainActivity extends FlutterActivity { @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 +111,7 @@ public class MainActivity extends FlutterActivity { getContentResolver().takePersistableUriPermission(treeUri, takeFlags); // resume pending action - PermissionManager.onPermissionResult(requestCode, true); + PermissionManager.onPermissionResult(this, requestCode, true, treeUri); } } } diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java index 8822f67d3..10b1c35fd 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java @@ -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 VIDEO_MEDIA_METADATA_KEYS = new HashMap() { + { + 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 dirMap = new HashMap<>(); MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, Uri.parse(uri)); try { - for (Map.Entry kv : Constants.MEDIA_METADATA_KEYS.entrySet()) { + for (Map.Entry kv : VIDEO_MEDIA_METADATA_KEYS.entrySet()) { Integer key = kv.getKey(); String value = retriever.extractMetadata(key); if (value != null) { diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java index 6d8da6137..f7df319a3 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java @@ -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; @@ -59,13 +59,12 @@ public class StorageHandler implements MethodChannel.MethodCallHandler { List> 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 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()); diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index 09407b85c..1b4029fd7 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -32,7 +32,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 +77,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); @@ -127,6 +123,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 +157,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); @@ -231,15 +225,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; diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index 077c169c0..037f1606b 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -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 delete(final Activity activity, final String path, final Uri mediaUri) { SettableFuture 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; } diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java b/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java deleted file mode 100644 index f99926817..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java +++ /dev/null @@ -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 MEDIA_METADATA_KEYS = new HashMap() { - { - 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? - } - }; -} diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/Env.java b/android/app/src/main/java/deckers/thibault/aves/utils/Env.java deleted file mode 100644 index 2bad2cbf4..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/utils/Env.java +++ /dev/null @@ -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; - } -} diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/PathSegments.java b/android/app/src/main/java/deckers/thibault/aves/utils/PathSegments.java deleted file mode 100644 index 2567c2f31..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/utils/PathSegments.java +++ /dev/null @@ -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; - } -} diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java index 841eda204..000e086cc 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java @@ -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> 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 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 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,14 +76,17 @@ 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 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(); } @@ -105,4 +119,16 @@ public class PermissionManager { 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; + } + } } diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java index b56714265..46f5488f5 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java @@ -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 getVolumePath(Context context, @NonNull String anyPath) { + return Arrays.stream(getVolumePaths(context)).filter(anyPath::startsWith).findFirst(); + } + + @Nullable + private static Iterator getPathStepIterator(Context context, @NonNull String anyPath) { + Optional 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 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 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 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 getVolumeTreeUris(Activity activity) { + Map 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 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 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 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 getPathStepIterator(String[] storageVolumeRoots, String path) { - PathSegments pathSegments = new PathSegments(path, storageVolumeRoots); - ArrayList 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 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 docFile = StorageUtils.getSdCardDocumentFile(activity, sdCardTreeUri, storageVolumeRoots, path); + Uri volumeTreeUri = PermissionManager.getVolumeTreeUri(activity, anyPath); + Optional 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 pathIterator = getPathStepIterator(storageVolumeRoots, directoryPath); - while (pathIterator.hasNext()) { + Iterator 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 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 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; + } }