storage access: handle permissions to multiple volumes
This commit is contained in:
parent
4add4fd5d5
commit
4f30f5427e
10 changed files with 273 additions and 263 deletions
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
@ -59,13 +59,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,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,14 +77,11 @@ public abstract class ImageProvider {
|
|||
return;
|
||||
}
|
||||
|
||||
if (Env.requireAccessPermission(oldPath)) {
|
||||
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity);
|
||||
if (sdCardTreeUri == null) {
|
||||
if (PermissionManager.requireVolumeAccessDialog(activity, oldPath)) {
|
||||
Runnable runnable = () -> rename(activity, oldPath, oldMediaUri, mimeType, newFilename, callback);
|
||||
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showSdCardAccessDialog(activity, runnable));
|
||||
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, oldPath, runnable));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
DocumentFileCompat df = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri);
|
||||
try {
|
||||
|
@ -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));
|
||||
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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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,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<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();
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
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);
|
||||
}
|
||||
retriever.setDataSource(context, uri);
|
||||
} catch (Exception e) {
|
||||
Log.w(LOG_TAG, "failed to open MediaMetadataRetriever for uri=" + uri, e);
|
||||
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 retriever;
|
||||
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;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
/**
|
||||
* Volume tree URIs
|
||||
*/
|
||||
|
||||
// serialized map from storage volume paths to their document tree URIs, from the Documents Provider
|
||||
// e.g. "/storage/12A9-8B42" -> "content://com.android.externalstorage.documents/tree/12A9-8B42%3A"
|
||||
private static final String PREF_VOLUME_TREE_URIS = "volume_tree_uris";
|
||||
|
||||
public static void setVolumeTreeUri(Activity activity, String volumePath, String treeUri) {
|
||||
Map<String, String> map = getVolumeTreeUris(activity);
|
||||
map.put(volumePath, treeUri);
|
||||
|
||||
SharedPreferences.Editor editor = activity.getPreferences(Context.MODE_PRIVATE).edit();
|
||||
String json = new JSONObject(map).toString();
|
||||
editor.putString(PREF_VOLUME_TREE_URIS, json);
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
private static Optional<DocumentFileCompat> getSdCardDocumentFile(Context context, Uri rootTreeUri, String[] storageVolumeRoots, String path) {
|
||||
if (rootTreeUri == null || storageVolumeRoots == null || path == null) {
|
||||
return Optional.empty();
|
||||
private static Map<String, String> getVolumeTreeUris(Activity activity) {
|
||||
Map<String, String> map = new HashMap<>();
|
||||
|
||||
SharedPreferences preferences = activity.getPreferences(Context.MODE_PRIVATE);
|
||||
String json = preferences.getString(PREF_VOLUME_TREE_URIS, new JSONObject().toString());
|
||||
if (json != null) {
|
||||
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 map;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue