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.ImageOpStreamHandler;
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler; import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler;
import deckers.thibault.aves.channel.streams.StorageAccessStreamHandler; 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.PermissionManager;
import deckers.thibault.aves.utils.Utils; import deckers.thibault.aves.utils.Utils;
import io.flutter.embedding.android.FlutterActivity; import io.flutter.embedding.android.FlutterActivity;
@ -98,14 +96,13 @@ public class MainActivity extends FlutterActivity {
@Override @Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) { 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) { if (resultCode != RESULT_OK || data.getData() == null) {
PermissionManager.onPermissionResult(requestCode, false); PermissionManager.onPermissionResult(this, requestCode, false, null);
return; return;
} }
Uri treeUri = data.getData(); Uri treeUri = data.getData();
Env.setSdCardDocumentUri(this, treeUri.toString());
// save access permissions across reboots // save access permissions across reboots
final int takeFlags = data.getFlags() final int takeFlags = data.getFlags()
@ -114,7 +111,7 @@ public class MainActivity extends FlutterActivity {
getContentResolver().takePersistableUriPermission(treeUri, takeFlags); getContentResolver().takePersistableUriPermission(treeUri, takeFlags);
// resume pending action // 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.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.MetadataHelper; import deckers.thibault.aves.utils.MetadataHelper;
import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.StorageUtils; 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_GENERIC_LANG = "";
private static final String XMP_SPECIFIC_LANG = "en-US"; 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) // Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
// Examples: // Examples:
// "+37.5090+127.0243/" (Samsung) // "+37.5090+127.0243/" (Samsung)
@ -171,7 +196,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
Map<String, String> dirMap = new HashMap<>(); Map<String, String> dirMap = new HashMap<>();
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, Uri.parse(uri)); MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, Uri.parse(uri));
try { 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(); Integer key = kv.getKey();
String value = retriever.extractMetadata(key); String value = retriever.extractMetadata(key);
if (value != null) { if (value != null) {

View file

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

View file

@ -32,7 +32,6 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import deckers.thibault.aves.model.AvesImageEntry; import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.MetadataHelper; import deckers.thibault.aves.utils.MetadataHelper;
import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.PermissionManager; import deckers.thibault.aves.utils.PermissionManager;
@ -78,14 +77,11 @@ public abstract class ImageProvider {
return; return;
} }
if (Env.requireAccessPermission(oldPath)) { if (PermissionManager.requireVolumeAccessDialog(activity, oldPath)) {
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity);
if (sdCardTreeUri == null) {
Runnable runnable = () -> rename(activity, oldPath, oldMediaUri, mimeType, newFilename, callback); 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; return;
} }
}
DocumentFileCompat df = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri); DocumentFileCompat df = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri);
try { 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) { 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) { switch (mimeType) {
case MimeTypes.JPEG: case MimeTypes.JPEG:
rotateJpeg(activity, path, uri, clockwise, callback); rotateJpeg(activity, path, uri, clockwise, callback);
@ -155,14 +157,6 @@ public abstract class ImageProvider {
return; 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; int newOrientationCode;
try { try {
ExifInterface exif = new ExifInterface(editablePath); ExifInterface exif = new ExifInterface(editablePath);
@ -231,15 +225,13 @@ public abstract class ImageProvider {
return; return;
} }
if (Env.requireAccessPermission(path)) { Bitmap originalImage;
if (PermissionManager.getSdCardTreeUri(activity) == null) { try {
Runnable runnable = () -> rotate(activity, path, uri, mimeType, clockwise, callback); originalImage = BitmapFactory.decodeStream(StorageUtils.openInputStream(activity, uri));
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showSdCardAccessDialog(activity, runnable)); } catch (FileNotFoundException e) {
callback.onFailure(e);
return; return;
} }
}
Bitmap originalImage = BitmapFactory.decodeFile(path);
if (originalImage == null) { if (originalImage == null) {
callback.onFailure(new Exception("failed to decode image at path=" + path)); callback.onFailure(new Exception("failed to decode image at path=" + path));
return; return;

View file

@ -34,7 +34,6 @@ import java.util.stream.Stream;
import deckers.thibault.aves.model.AvesImageEntry; import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.model.SourceImageEntry; import deckers.thibault.aves.model.SourceImageEntry;
import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.PermissionManager; import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.StorageUtils; 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) { public ListenableFuture<Object> delete(final Activity activity, final String path, final Uri mediaUri) {
SettableFuture<Object> future = SettableFuture.create(); SettableFuture<Object> future = SettableFuture.create();
if (Env.requireAccessPermission(path)) { if (StorageUtils.requireAccessPermission(path)) {
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity); if (PermissionManager.getVolumeTreeUri(activity, path) == null) {
if (sdCardTreeUri == null) {
Runnable runnable = () -> { Runnable runnable = () -> {
try { try {
future.set(delete(activity, path, mediaUri).get()); future.set(delete(activity, path, mediaUri).get());
@ -227,7 +225,7 @@ public class MediaStoreImageProvider extends ImageProvider {
future.setException(e); future.setException(e);
} }
}; };
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showSdCardAccessDialog(activity, runnable)); new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, path, runnable));
return future; 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.provider.DocumentsContract;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi; import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityCompat;
import androidx.core.util.Pair;
import java.io.File; import java.io.File;
import java.util.Optional; import java.util.Optional;
@ -24,30 +25,40 @@ import java.util.stream.Stream;
public class PermissionManager { public class PermissionManager {
private static final String LOG_TAG = Utils.createLogTag(PermissionManager.class); private static final String LOG_TAG = Utils.createLogTag(PermissionManager.class);
// permission request code to pending runnable public static final int VOLUME_ROOT_PERMISSION_REQUEST_CODE = 1;
private static ConcurrentHashMap<Integer, Pair<Runnable, Runnable>> pendingPermissionMap = new ConcurrentHashMap<>();
// check access permission to SD card directory & return its content URI if available // permission request code to pending runnable
public static Uri getSdCardTreeUri(Activity activity) { private static ConcurrentHashMap<Integer, PendingPermissionHandler> pendingPermissionMap = new ConcurrentHashMap<>();
final String sdCardDocumentUri = Env.getSdCardDocumentUri(activity);
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() Optional<UriPermission> uriPermissionOptional = activity.getContentResolver().getPersistedUriPermissions().stream()
.filter(uriPermission -> uriPermission.getUri().toString().equals(sdCardDocumentUri)) .filter(uriPermission -> uriPermission.getUri().toString().equals(volumeTreeUri))
.findFirst(); .findFirst();
return uriPermissionOptional.map(UriPermission::getUri).orElse(null); return uriPermissionOptional.map(UriPermission::getUri).orElse(null);
} }
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) @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) new AlertDialog.Builder(activity)
.setTitle("SD Card Access") .setTitle("Storage Volume 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.") .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, null, pendingRunnable, null)) .setPositiveButton(android.R.string.ok, (dialog, button) -> requestVolumeAccess(activity, volumePath, pendingRunnable, null))
.show(); .show();
} }
public static void requestVolumeAccess(Activity activity, String volumePath, Runnable onGranted, Runnable onDenied) { 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); 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; Intent intent = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && volumePath != 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); 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); Log.d(LOG_TAG, "onPermissionResult with requestCode=" + requestCode + ", granted=" + granted);
Pair<Runnable, Runnable> runnables = pendingPermissionMap.remove(requestCode);
if (runnables == null) return; PendingPermissionHandler handler = pendingPermissionMap.remove(requestCode);
Runnable runnable = granted ? runnables.first : runnables.second; if (handler == null) return;
StorageUtils.setVolumeTreeUri(activity, handler.volumePath, treeUri.toString());
Runnable runnable = granted ? handler.onGranted : handler.onDenied;
if (runnable == null) return; if (runnable == null) return;
runnable.run(); runnable.run();
} }
@ -105,4 +119,16 @@ public class PermissionManager {
uuid + ":" 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.app.Activity;
import android.content.ContentResolver; import android.content.ContentResolver;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences;
import android.media.MediaMetadataRetriever; import android.media.MediaMetadataRetriever;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
@ -20,6 +21,9 @@ import com.commonsware.cwac.document.DocumentFileCompat;
import com.google.common.base.Splitter; import com.google.common.base.Splitter;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File; import java.io.File;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
@ -27,44 +31,69 @@ import java.io.InputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator; import java.util.Iterator;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
public class StorageUtils { public class StorageUtils {
private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class); private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class);
private static boolean isMediaStoreContentUri(Uri uri) { /**
// a URI's authority is [userinfo@]host[:port] * Volume paths
// 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());
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 { public static String[] getVolumePaths(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (mStorageVolumePaths == null) {
// we get a permission denial if we require original from a provider other than the media store mStorageVolumePaths = findVolumePaths(context);
if (isMediaStoreContentUri(uri)) {
uri = MediaStore.setRequireOriginal(uri);
} }
} return mStorageVolumePaths;
return context.getContentResolver().openInputStream(uri);
} }
public static MediaMetadataRetriever openMetadataRetriever(Context context, Uri uri) { public static Optional<String> getVolumePath(Context context, @NonNull String anyPath) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever(); return Arrays.stream(getVolumePaths(context)).filter(anyPath::startsWith).findFirst();
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);
} }
@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); if (relativePath == null) return null;
} catch (Exception e) {
Log.w(LOG_TAG, "failed to open MediaMetadataRetriever for uri=" + uri, e); 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) * @return paths to all available SD-Cards in the system (include emulated)
*/ */
@SuppressLint("ObsoleteSdkInt") @SuppressLint("ObsoleteSdkInt")
public static String[] getStorageVolumeRoots(Context context) { private static String[] findVolumePaths(Context context) {
// Final set of paths // Final set of paths
final Set<String> rv = new HashSet<>(); 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) { * Volume tree URIs
for (DocumentFileCompat doc : documentFile.listFiles()) { */
if (displayName.equalsIgnoreCase(doc.getName())) {
return doc; // 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";
return null;
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) { private static Map<String, String> getVolumeTreeUris(Activity activity) {
if (rootTreeUri == null || storageVolumeRoots == null || path == null) { Map<String, String> map = new HashMap<>();
return Optional.empty();
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); public static Optional<String> getVolumeTreeUriForPath(Activity activity, String anyPath) {
if (documentFile == null) { return StorageUtils.getVolumePath(activity, anyPath).map(volumePath -> getVolumeTreeUris(activity).get(volumePath));
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();
} }
/**
* Document files
*/
@Nullable @Nullable
public static DocumentFileCompat getDocumentFile(@NonNull Activity activity, @NonNull String path, @NonNull Uri mediaUri) { public static DocumentFileCompat getDocumentFile(@NonNull Activity activity, @NonNull String anyPath, @NonNull Uri mediaUri) {
if (Env.requireAccessPermission(path)) { if (requireAccessPermission(anyPath)) {
// need a document URI (not a media content URI) to open a `DocumentFile` output stream // need a document URI (not a media content URI) to open a `DocumentFile` output stream
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// cleanest API to get it // cleanest API to get it
@ -229,31 +262,29 @@ public class StorageUtils {
} }
} }
// fallback for older APIs // fallback for older APIs
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity); Uri volumeTreeUri = PermissionManager.getVolumeTreeUri(activity, anyPath);
String[] storageVolumeRoots = Env.getStorageVolumeRoots(activity); Optional<DocumentFileCompat> docFile = getDocumentFileFromVolumeTree(activity, volumeTreeUri, anyPath);
Optional<DocumentFileCompat> docFile = StorageUtils.getSdCardDocumentFile(activity, sdCardTreeUri, storageVolumeRoots, path);
return docFile.orElse(null); return docFile.orElse(null);
} }
// good old `File` // 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 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 // returns null if directory does not exist and could not be created
public static DocumentFileCompat createDirectoryIfAbsent(@NonNull Activity activity, @NonNull String directoryPath) { public static DocumentFileCompat createDirectoryIfAbsent(@NonNull Activity activity, @NonNull String directoryPath) {
if (Env.requireAccessPermission(directoryPath)) { if (requireAccessPermission(directoryPath)) {
Uri rootTreeUri = PermissionManager.getSdCardTreeUri(activity); Uri rootTreeUri = PermissionManager.getVolumeTreeUri(activity, directoryPath);
DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(activity, rootTreeUri); DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(activity, rootTreeUri);
if (parentFile == null) return null; if (parentFile == null) return null;
String[] storageVolumeRoots = Env.getStorageVolumeRoots(activity);
if (!directoryPath.endsWith(File.separator)) { if (!directoryPath.endsWith(File.separator)) {
directoryPath += File.separator; directoryPath += File.separator;
} }
Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, directoryPath); Iterator<String> pathIterator = getPathStepIterator(activity, directoryPath);
while (pathIterator.hasNext()) { while (pathIterator != null && pathIterator.hasNext()) {
String dirName = pathIterator.next(); String dirName = pathIterator.next();
DocumentFileCompat dirFile = findFileIgnoreCase(parentFile, dirName); DocumentFileCompat dirFile = findDocumentFileIgnoreCase(parentFile, dirName);
if (dirFile == null || !dirFile.exists()) { if (dirFile == null || !dirFile.exists()) {
try { try {
dirFile = parentFile.createDirectory(dirName); dirFile = parentFile.createDirectory(dirName);
@ -293,4 +324,77 @@ public class StorageUtils {
} }
return null; 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;
}
} }