storage access: handle permissions to multiple volumes

This commit is contained in:
Thibault Deckers 2020-07-05 14:58:05 +09:00
parent 4add4fd5d5
commit 4f30f5427e
10 changed files with 273 additions and 263 deletions

View file

@ -18,8 +18,6 @@ import deckers.thibault.aves.channel.streams.ImageByteStreamHandler;
import deckers.thibault.aves.channel.streams.ImageOpStreamHandler;
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler;
import deckers.thibault.aves.channel.streams.StorageAccessStreamHandler;
import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.Utils;
import io.flutter.embedding.android.FlutterActivity;
@ -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);
}
}
}

View file

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

View file

@ -14,8 +14,8 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.StorageUtils;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
@ -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());

View file

@ -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;

View file

@ -34,7 +34,6 @@ import java.util.stream.Stream;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.model.SourceImageEntry;
import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.StorageUtils;
@ -217,9 +216,8 @@ public class MediaStoreImageProvider extends ImageProvider {
public ListenableFuture<Object> delete(final Activity activity, final String path, final Uri mediaUri) {
SettableFuture<Object> future = SettableFuture.create();
if (Env.requireAccessPermission(path)) {
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity);
if (sdCardTreeUri == null) {
if (StorageUtils.requireAccessPermission(path)) {
if (PermissionManager.getVolumeTreeUri(activity, path) == null) {
Runnable runnable = () -> {
try {
future.set(delete(activity, path, mediaUri).get());
@ -227,7 +225,7 @@ public class MediaStoreImageProvider extends ImageProvider {
future.setException(e);
}
};
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showSdCardAccessDialog(activity, runnable));
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, path, runnable));
return future;
}

View file

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

View file

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

View file

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

View file

@ -11,10 +11,11 @@ import android.os.storage.StorageVolume;
import android.provider.DocumentsContract;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AlertDialog;
import androidx.core.app.ActivityCompat;
import androidx.core.util.Pair;
import java.io.File;
import java.util.Optional;
@ -24,30 +25,40 @@ import java.util.stream.Stream;
public class PermissionManager {
private static final String LOG_TAG = Utils.createLogTag(PermissionManager.class);
// permission request code to pending runnable
private static ConcurrentHashMap<Integer, Pair<Runnable, Runnable>> pendingPermissionMap = new ConcurrentHashMap<>();
public static final int VOLUME_ROOT_PERMISSION_REQUEST_CODE = 1;
// check access permission to SD card directory & return its content URI if available
public static Uri getSdCardTreeUri(Activity activity) {
final String sdCardDocumentUri = Env.getSdCardDocumentUri(activity);
// permission request code to pending runnable
private static ConcurrentHashMap<Integer, PendingPermissionHandler> pendingPermissionMap = new ConcurrentHashMap<>();
public static boolean requireVolumeAccessDialog(Activity activity, @NonNull String anyPath) {
return StorageUtils.requireAccessPermission(anyPath) && getVolumeTreeUri(activity, anyPath) == null;
}
// check access permission to volume root directory & return its tree URI if available
@Nullable
public static Uri getVolumeTreeUri(Activity activity, @NonNull String anyPath) {
String volumeTreeUri = StorageUtils.getVolumeTreeUriForPath(activity, anyPath).orElse(null);
Optional<UriPermission> uriPermissionOptional = activity.getContentResolver().getPersistedUriPermissions().stream()
.filter(uriPermission -> uriPermission.getUri().toString().equals(sdCardDocumentUri))
.filter(uriPermission -> uriPermission.getUri().toString().equals(volumeTreeUri))
.findFirst();
return uriPermissionOptional.map(UriPermission::getUri).orElse(null);
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public static void showSdCardAccessDialog(final Activity activity, final Runnable pendingRunnable) {
public static void showVolumeAccessDialog(final Activity activity, @NonNull String anyPath, final Runnable pendingRunnable) {
String volumePath = StorageUtils.getVolumePath(activity, anyPath).orElse(null);
// TODO TLAD show volume name/ID in the message
new AlertDialog.Builder(activity)
.setTitle("SD Card Access")
.setMessage("Please select the root directory of the SD card in the next screen, so that this app has permission to access it and complete your request.")
.setPositiveButton(android.R.string.ok, (dialog, button) -> requestVolumeAccess(activity, null, pendingRunnable, null))
.setTitle("Storage Volume Access")
.setMessage("Please select the root directory of the storage volume in the next screen, so that this app has permission to access it and complete your request.")
.setPositiveButton(android.R.string.ok, (dialog, button) -> requestVolumeAccess(activity, volumePath, pendingRunnable, null))
.show();
}
public static void requestVolumeAccess(Activity activity, String volumePath, Runnable onGranted, Runnable onDenied) {
Log.i(LOG_TAG, "request user to select and grant access permission to volume=" + volumePath);
pendingPermissionMap.put(Constants.SD_CARD_PERMISSION_REQUEST_CODE, Pair.create(onGranted, onDenied));
pendingPermissionMap.put(VOLUME_ROOT_PERMISSION_REQUEST_CODE, new PendingPermissionHandler(volumePath, onGranted, onDenied));
Intent intent = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && volumePath != null) {
@ -65,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;
}
}
}

View file

@ -4,6 +4,7 @@ import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.Context;
import android.content.SharedPreferences;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.os.Build;
@ -20,6 +21,9 @@ import com.commonsware.cwac.document.DocumentFileCompat;
import com.google.common.base.Splitter;
import com.google.common.collect.Lists;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
@ -27,44 +31,69 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
public class StorageUtils {
private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class);
private static boolean isMediaStoreContentUri(Uri uri) {
// a URI's authority is [userinfo@]host[:port]
// but we only want the host when comparing to Media Store's "authority"
return uri != null && ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme()) && MediaStore.AUTHORITY.equalsIgnoreCase(uri.getHost());
/**
* Volume paths
*/
private static String[] mStorageVolumePaths;
private static String mPrimaryVolumePath;
private static String getPrimaryVolumePath() {
if (mPrimaryVolumePath == null) {
mPrimaryVolumePath = findPrimaryVolumePath();
}
return mPrimaryVolumePath;
}
public static InputStream openInputStream(Context context, Uri uri) throws FileNotFoundException {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// we get a permission denial if we require original from a provider other than the media store
if (isMediaStoreContentUri(uri)) {
uri = MediaStore.setRequireOriginal(uri);
public static String[] getVolumePaths(Context context) {
if (mStorageVolumePaths == null) {
mStorageVolumePaths = findVolumePaths(context);
}
}
return context.getContentResolver().openInputStream(uri);
return mStorageVolumePaths;
}
public static MediaMetadataRetriever openMetadataRetriever(Context context, Uri uri) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// we get a permission denial if we require original from a provider other than the media store
if (isMediaStoreContentUri(uri)) {
uri = MediaStore.setRequireOriginal(uri);
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;
}
}