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.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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());
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue