Merge branch 'develop'
This commit is contained in:
commit
617487963c
17 changed files with 385 additions and 364 deletions
|
@ -51,12 +51,12 @@ android {
|
||||||
manifestPlaceholders = [googleApiKey:keystoreProperties['googleApiKey']]
|
manifestPlaceholders = [googleApiKey:keystoreProperties['googleApiKey']]
|
||||||
}
|
}
|
||||||
|
|
||||||
// compileOptions {
|
compileOptions {
|
||||||
// // enable support for Java 8 language APIs (stream, optional, etc.)
|
// enable support for Java 8 language APIs (stream, optional, etc.)
|
||||||
// coreLibraryDesugaringEnabled true
|
coreLibraryDesugaringEnabled true
|
||||||
// sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
// targetCompatibility JavaVersion.VERSION_1_8
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
// }
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
release {
|
release {
|
||||||
|
@ -105,7 +105,7 @@ repositories {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// enable support for Java 8 language APIs (stream, optional, etc.)
|
// enable support for Java 8 language APIs (stream, optional, etc.)
|
||||||
// coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.5'
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9'
|
||||||
|
|
||||||
implementation "androidx.exifinterface:exifinterface:1.2.0"
|
implementation "androidx.exifinterface:exifinterface:1.2.0"
|
||||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||||
|
|
|
@ -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;
|
||||||
|
@ -61,9 +59,10 @@ public class MainActivity extends FlutterActivity {
|
||||||
intentDataMap = null;
|
intentDataMap = null;
|
||||||
String resultUri = call.argument("uri");
|
String resultUri = call.argument("uri");
|
||||||
if (resultUri != null) {
|
if (resultUri != null) {
|
||||||
Intent data = new Intent();
|
Intent intent = new Intent();
|
||||||
data.setData(Uri.parse(resultUri));
|
intent.setData(Uri.parse(resultUri));
|
||||||
setResult(RESULT_OK, data);
|
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||||
|
setResult(RESULT_OK, intent);
|
||||||
} else {
|
} else {
|
||||||
setResult(RESULT_CANCELED);
|
setResult(RESULT_CANCELED);
|
||||||
}
|
}
|
||||||
|
@ -92,20 +91,20 @@ public class MainActivity extends FlutterActivity {
|
||||||
case Intent.ACTION_PICK:
|
case Intent.ACTION_PICK:
|
||||||
intentDataMap = new HashMap<>();
|
intentDataMap = new HashMap<>();
|
||||||
intentDataMap.put("action", "pick");
|
intentDataMap.put("action", "pick");
|
||||||
|
intentDataMap.put("mimeType", intent.getType());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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 +113,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;
|
||||||
|
|
||||||
|
@ -42,10 +42,13 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
|
||||||
result.success(volumes);
|
result.success(volumes);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "hasGrantedPermissionToVolumeRoot": {
|
case "requireVolumeAccessDialog": {
|
||||||
String path = call.argument("path");
|
String path = call.argument("path");
|
||||||
boolean granted = PermissionManager.hasGrantedPermissionToVolumeRoot(activity, path);
|
if (path == null) {
|
||||||
result.success(granted);
|
result.success(true);
|
||||||
|
} else {
|
||||||
|
result.success(PermissionManager.requireVolumeAccessDialog(activity, path));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
@ -59,13 +62,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,7 @@ public class StorageAccessStreamHandler implements EventChannel.StreamHandler {
|
||||||
public void onListen(Object o, final EventChannel.EventSink eventSink) {
|
public void onListen(Object o, final EventChannel.EventSink eventSink) {
|
||||||
this.eventSink = eventSink;
|
this.eventSink = eventSink;
|
||||||
this.handler = new Handler(Looper.getMainLooper());
|
this.handler = new Handler(Looper.getMainLooper());
|
||||||
Runnable onGranted = () -> success(PermissionManager.hasGrantedPermissionToVolumeRoot(activity, volumePath));
|
Runnable onGranted = () -> success(!PermissionManager.requireVolumeAccessDialog(activity, volumePath));
|
||||||
Runnable onDenied = () -> success(false);
|
Runnable onDenied = () -> success(false);
|
||||||
PermissionManager.requestVolumeAccess(activity, volumePath, onGranted, onDenied);
|
PermissionManager.requestVolumeAccess(activity, volumePath, onGranted, onDenied);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package deckers.thibault.aves.model.provider;
|
||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.content.ContentResolver;
|
import android.content.ContentResolver;
|
||||||
|
import android.content.ContentUris;
|
||||||
import android.content.ContentValues;
|
import android.content.ContentValues;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
|
@ -32,7 +33,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,13 +78,10 @@ public abstract class ImageProvider {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Env.requireAccessPermission(oldPath)) {
|
if (PermissionManager.requireVolumeAccessDialog(activity, oldPath)) {
|
||||||
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity);
|
Runnable runnable = () -> rename(activity, oldPath, oldMediaUri, mimeType, newFilename, callback);
|
||||||
if (sdCardTreeUri == null) {
|
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, oldPath, runnable));
|
||||||
Runnable runnable = () -> rename(activity, oldPath, oldMediaUri, mimeType, newFilename, callback);
|
return;
|
||||||
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showSdCardAccessDialog(activity, runnable));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DocumentFileCompat df = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri);
|
DocumentFileCompat df = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri);
|
||||||
|
@ -103,23 +100,33 @@ public abstract class ImageProvider {
|
||||||
MediaScannerConnection.scanFile(activity, new String[]{newFile.getPath()}, new String[]{mimeType}, (newPath, newUri) -> {
|
MediaScannerConnection.scanFile(activity, new String[]{newFile.getPath()}, new String[]{mimeType}, (newPath, newUri) -> {
|
||||||
Log.d(LOG_TAG, "onScanCompleted with newPath=" + newPath + ", newUri=" + newUri);
|
Log.d(LOG_TAG, "onScanCompleted with newPath=" + newPath + ", newUri=" + newUri);
|
||||||
if (newUri != null) {
|
if (newUri != null) {
|
||||||
// we retrieve updated fields as the renamed file became a new entry in the Media Store
|
// newURI is a file media URI (e.g. "content://media/12a9-8b42/file/62872")
|
||||||
String[] projection = {MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.TITLE};
|
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
|
||||||
try {
|
long contentId = ContentUris.parseId(newUri);
|
||||||
Cursor cursor = activity.getContentResolver().query(newUri, projection, null, null, null);
|
Uri contentUri = null;
|
||||||
if (cursor != null) {
|
if (mimeType.startsWith(MimeTypes.IMAGE)) {
|
||||||
if (cursor.moveToNext()) {
|
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId);
|
||||||
long contentId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID));
|
} else if (mimeType.startsWith(MimeTypes.VIDEO)) {
|
||||||
newFields.put("uri", newUri.toString());
|
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId);
|
||||||
newFields.put("contentId", contentId);
|
}
|
||||||
newFields.put("path", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)));
|
if (contentUri != null) {
|
||||||
newFields.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE)));
|
// we retrieve updated fields as the renamed file became a new entry in the Media Store
|
||||||
|
String[] projection = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.TITLE};
|
||||||
|
try {
|
||||||
|
Cursor cursor = activity.getContentResolver().query(contentUri, projection, null, null, null);
|
||||||
|
if (cursor != null) {
|
||||||
|
if (cursor.moveToNext()) {
|
||||||
|
newFields.put("uri", contentUri.toString());
|
||||||
|
newFields.put("contentId", contentId);
|
||||||
|
newFields.put("path", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)));
|
||||||
|
newFields.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE)));
|
||||||
|
}
|
||||||
|
cursor.close();
|
||||||
}
|
}
|
||||||
cursor.close();
|
} catch (Exception e) {
|
||||||
|
callback.onFailure(e);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
|
||||||
callback.onFailure(e);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
callback.onSuccess(newFields);
|
callback.onSuccess(newFields);
|
||||||
|
@ -127,6 +134,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 +168,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);
|
||||||
|
@ -195,24 +200,26 @@ public abstract class ImageProvider {
|
||||||
Map<String, Object> newFields = new HashMap<>();
|
Map<String, Object> newFields = new HashMap<>();
|
||||||
newFields.put("orientationDegrees", orientationDegrees);
|
newFields.put("orientationDegrees", orientationDegrees);
|
||||||
|
|
||||||
ContentResolver contentResolver = activity.getContentResolver();
|
// ContentResolver contentResolver = activity.getContentResolver();
|
||||||
ContentValues values = new ContentValues();
|
// ContentValues values = new ContentValues();
|
||||||
// from Android Q, media store update needs to be flagged IS_PENDING first
|
// // from Android Q, media store update needs to be flagged IS_PENDING first
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
values.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
// values.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
||||||
contentResolver.update(uri, values, null, null);
|
// // TODO TLAD catch RecoverableSecurityException
|
||||||
values.clear();
|
// contentResolver.update(uri, values, null, null);
|
||||||
values.put(MediaStore.MediaColumns.IS_PENDING, 0);
|
// values.clear();
|
||||||
}
|
// values.put(MediaStore.MediaColumns.IS_PENDING, 0);
|
||||||
// uses MediaStore.Images.Media instead of MediaStore.MediaColumns for APIs < Q
|
// }
|
||||||
values.put(MediaStore.Images.Media.ORIENTATION, orientationDegrees);
|
// // uses MediaStore.Images.Media instead of MediaStore.MediaColumns for APIs < Q
|
||||||
int updatedRowCount = contentResolver.update(uri, values, null, null);
|
// values.put(MediaStore.Images.Media.ORIENTATION, orientationDegrees);
|
||||||
if (updatedRowCount > 0) {
|
// // TODO TLAD catch RecoverableSecurityException
|
||||||
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
|
// int updatedRowCount = contentResolver.update(uri, values, null, null);
|
||||||
} else {
|
// if (updatedRowCount > 0) {
|
||||||
Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
|
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
|
||||||
callback.onSuccess(newFields);
|
// } else {
|
||||||
}
|
// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
|
||||||
|
// callback.onSuccess(newFields);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void rotatePng(final Activity activity, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) {
|
private void rotatePng(final Activity activity, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) {
|
||||||
|
@ -231,15 +238,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) {
|
||||||
return;
|
callback.onFailure(e);
|
||||||
}
|
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;
|
||||||
|
@ -267,24 +272,26 @@ public abstract class ImageProvider {
|
||||||
newFields.put("width", rotatedWidth);
|
newFields.put("width", rotatedWidth);
|
||||||
newFields.put("height", rotatedHeight);
|
newFields.put("height", rotatedHeight);
|
||||||
|
|
||||||
ContentResolver contentResolver = activity.getContentResolver();
|
// ContentResolver contentResolver = activity.getContentResolver();
|
||||||
ContentValues values = new ContentValues();
|
// ContentValues values = new ContentValues();
|
||||||
// from Android Q, media store update needs to be flagged IS_PENDING first
|
// // from Android Q, media store update needs to be flagged IS_PENDING first
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
values.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
// values.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
||||||
contentResolver.update(uri, values, null, null);
|
// // TODO TLAD catch RecoverableSecurityException
|
||||||
values.clear();
|
// contentResolver.update(uri, values, null, null);
|
||||||
values.put(MediaStore.MediaColumns.IS_PENDING, 0);
|
// values.clear();
|
||||||
}
|
// values.put(MediaStore.MediaColumns.IS_PENDING, 0);
|
||||||
values.put(MediaStore.MediaColumns.WIDTH, rotatedWidth);
|
// }
|
||||||
values.put(MediaStore.MediaColumns.HEIGHT, rotatedHeight);
|
// values.put(MediaStore.MediaColumns.WIDTH, rotatedWidth);
|
||||||
int updatedRowCount = contentResolver.update(uri, values, null, null);
|
// values.put(MediaStore.MediaColumns.HEIGHT, rotatedHeight);
|
||||||
if (updatedRowCount > 0) {
|
// // TODO TLAD catch RecoverableSecurityException
|
||||||
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
|
// int updatedRowCount = contentResolver.update(uri, values, null, null);
|
||||||
} else {
|
// if (updatedRowCount > 0) {
|
||||||
Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
|
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
|
||||||
callback.onSuccess(newFields);
|
// } else {
|
||||||
}
|
// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
|
||||||
|
// callback.onSuccess(newFields);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface ImageOpCallback {
|
public interface ImageOpCallback {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -278,6 +276,12 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List<AvesImageEntry> entries, @NonNull ImageOpCallback callback) {
|
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List<AvesImageEntry> entries, @NonNull ImageOpCallback callback) {
|
||||||
|
if (PermissionManager.requireVolumeAccessDialog(activity, destinationDir)) {
|
||||||
|
Runnable runnable = () -> moveMultiple(activity, copy, destinationDir, entries, callback);
|
||||||
|
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, destinationDir, runnable));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(activity, destinationDir);
|
DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(activity, destinationDir);
|
||||||
if (destinationDirDocFile == null) {
|
if (destinationDirDocFile == null) {
|
||||||
callback.onFailure(new Exception("failed to create directory at path=" + destinationDir));
|
callback.onFailure(new Exception("failed to create directory at path=" + destinationDir));
|
||||||
|
@ -332,6 +336,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
ContentValues contentValues = new ContentValues();
|
ContentValues contentValues = new ContentValues();
|
||||||
contentValues.put(MediaStore.MediaColumns.DATA, destinationPath);
|
contentValues.put(MediaStore.MediaColumns.DATA, destinationPath);
|
||||||
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
|
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
|
||||||
|
// TODO TLAD when not using legacy storage (~Q, R+), provide relative path (assess first whether the root is the "Pictures" folder or the root)
|
||||||
// contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, "");
|
// contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, "");
|
||||||
// contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "");
|
// contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "");
|
||||||
Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? MediaStore.Video.Media.getContentUri(volumeName) : MediaStore.Images.Media.getContentUri(volumeName);
|
Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? MediaStore.Video.Media.getContentUri(volumeName) : MediaStore.Images.Media.getContentUri(volumeName);
|
||||||
|
@ -339,7 +344,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
if (destinationUri == null) {
|
if (destinationUri == null) {
|
||||||
future.setException(new Exception("failed to insert row to content resolver"));
|
future.setException(new Exception("failed to insert row to content resolver"));
|
||||||
} else {
|
} else {
|
||||||
DocumentFileCompat source = DocumentFileCompat.fromFile(new File(sourcePath));
|
DocumentFileCompat source = DocumentFileCompat.fromSingleUri(activity, sourceUri);
|
||||||
DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri);
|
DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri);
|
||||||
source.copyTo(destination);
|
source.copyTo(destination);
|
||||||
|
|
||||||
|
@ -410,6 +415,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
MediaScannerConnection.scanFile(activity, new String[]{destinationPath}, new String[]{mimeType}, (newPath, newUri) -> {
|
MediaScannerConnection.scanFile(activity, new String[]{destinationPath}, new String[]{mimeType}, (newPath, newUri) -> {
|
||||||
Map<String, Object> newFields = new HashMap<>();
|
Map<String, Object> newFields = new HashMap<>();
|
||||||
if (newUri != null) {
|
if (newUri != null) {
|
||||||
|
// TODO TLAD check whether newURI is a file media URI (cf case in `rename`)
|
||||||
// we retrieve updated fields as the moved file became a new entry in the Media Store
|
// we retrieve updated fields as the moved file became a new entry in the Media Store
|
||||||
String[] projection = {MediaStore.MediaColumns._ID};
|
String[] projection = {MediaStore.MediaColumns._ID};
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -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,44 +76,37 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean hasGrantedPermissionToVolumeRoot(Context context, String path) {
|
|
||||||
boolean canAccess = false;
|
|
||||||
Stream<Uri> permittedUris = context.getContentResolver().getPersistedUriPermissions().stream().map(UriPermission::getUri);
|
|
||||||
// e.g. content://com.android.externalstorage.documents/tree/12A9-8B42%3A
|
|
||||||
StorageManager sm = context.getSystemService(StorageManager.class);
|
|
||||||
if (sm != null) {
|
|
||||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
|
|
||||||
StorageVolume volume = sm.getStorageVolume(new File(path));
|
|
||||||
if (volume != null) {
|
|
||||||
// primary storage doesn't have a UUID
|
|
||||||
String uuid = volume.isPrimary() ? "primary" : volume.getUuid();
|
|
||||||
Uri targetVolumeTreeUri = getVolumeTreeUriFromUuid(uuid);
|
|
||||||
canAccess = permittedUris.anyMatch(uri -> uri.equals(targetVolumeTreeUri));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// TODO TLAD find alternative for Android <N
|
|
||||||
canAccess = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return canAccess;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Uri getVolumeTreeUriFromUuid(String uuid) {
|
private static Uri getVolumeTreeUriFromUuid(String uuid) {
|
||||||
return DocumentsContract.buildTreeDocumentUri(
|
return DocumentsContract.buildTreeDocumentUri(
|
||||||
"com.android.externalstorage.documents",
|
"com.android.externalstorage.documents",
|
||||||
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 context.getContentResolver().openInputStream(uri);
|
return mStorageVolumePaths;
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
@Nullable
|
||||||
if (isMediaStoreContentUri(uri)) {
|
private static Iterator<String> getPathStepIterator(Context context, @NonNull String anyPath) {
|
||||||
uri = MediaStore.setRequireOriginal(uri);
|
Optional<String> volumePathOpt = getVolumePath(context, anyPath);
|
||||||
}
|
if (!volumePathOpt.isPresent()) return null;
|
||||||
}
|
|
||||||
retriever.setDataSource(context, uri);
|
String relativePath = null, filename = null;
|
||||||
} catch (Exception e) {
|
int lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1;
|
||||||
Log.w(LOG_TAG, "failed to open MediaMetadataRetriever for uri=" + uri, e);
|
int volumePathLength = volumePathOpt.get().length();
|
||||||
|
if (lastSeparatorIndex > volumePathLength) {
|
||||||
|
filename = anyPath.substring(lastSeparatorIndex);
|
||||||
|
relativePath = anyPath.substring(volumePathLength, lastSeparatorIndex);
|
||||||
}
|
}
|
||||||
return retriever;
|
if (relativePath == null) return null;
|
||||||
|
|
||||||
|
ArrayList<String> pathSteps = Lists.newArrayList(Splitter.on(File.separatorChar)
|
||||||
|
.trimResults().omitEmptyStrings().split(relativePath));
|
||||||
|
if (filename.length() > 0) {
|
||||||
|
pathSteps.add(filename);
|
||||||
|
}
|
||||||
|
return pathSteps.iterator();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String findPrimaryVolumePath() {
|
||||||
|
String primaryVolumePath = Environment.getExternalStorageDirectory().getAbsolutePath();
|
||||||
|
if (!primaryVolumePath.endsWith("/")) {
|
||||||
|
primaryVolumePath += "/";
|
||||||
|
}
|
||||||
|
return primaryVolumePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -77,7 +106,7 @@ public class StorageUtils {
|
||||||
* @return paths to all available SD-Cards in the system (include emulated)
|
* @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";
|
||||||
|
|
||||||
|
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 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 null;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Optional<DocumentFileCompat> getSdCardDocumentFile(Context context, Uri rootTreeUri, String[] storageVolumeRoots, String path) {
|
public static Optional<String> getVolumeTreeUriForPath(Activity activity, String anyPath) {
|
||||||
if (rootTreeUri == null || storageVolumeRoots == null || path == null) {
|
return StorageUtils.getVolumePath(activity, anyPath).map(volumePath -> getVolumeTreeUris(activity).get(volumePath));
|
||||||
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(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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,14 +18,14 @@ class AndroidFileService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> hasGrantedPermissionToVolumeRoot(String path) async {
|
static Future<bool> requireVolumeAccessDialog(String path) async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('hasGrantedPermissionToVolumeRoot', <String, dynamic>{
|
final result = await platform.invokeMethod('requireVolumeAccessDialog', <String, dynamic>{
|
||||||
'path': path,
|
'path': path,
|
||||||
});
|
});
|
||||||
return result as bool;
|
return result as bool;
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
debugPrint('hasGrantedPermissionToVolumeRoot failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
debugPrint('requireVolumeAccessDialog failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,16 +10,17 @@ mixin PermissionAwareMixin {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> checkStoragePermissionForPaths(BuildContext context, Iterable<String> paths) async {
|
Future<bool> checkStoragePermissionForPaths(BuildContext context, Iterable<String> paths) async {
|
||||||
final volumes = paths.map((path) => androidFileUtils.getStorageVolume(path)).toSet();
|
final volumes = paths.map(androidFileUtils.getStorageVolume).toSet();
|
||||||
final removableVolumes = volumes.where((v) => v.isRemovable);
|
final ungrantedVolumes = (await Future.wait<Tuple2<StorageVolume, bool>>(
|
||||||
final volumePermissions = await Future.wait<Tuple2<StorageVolume, bool>>(
|
volumes.map(
|
||||||
removableVolumes.map(
|
(volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then(
|
||||||
(volume) => AndroidFileService.hasGrantedPermissionToVolumeRoot(volume.path).then(
|
|
||||||
(granted) => Tuple2(volume, granted),
|
(granted) => Tuple2(volume, granted),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
))
|
||||||
final ungrantedVolumes = volumePermissions.where((t) => !t.item2).map((t) => t.item1).toList();
|
.where((t) => t.item2)
|
||||||
|
.map((t) => t.item1)
|
||||||
|
.toList();
|
||||||
while (ungrantedVolumes.isNotEmpty) {
|
while (ungrantedVolumes.isNotEmpty) {
|
||||||
final volume = ungrantedVolumes.first;
|
final volume = ungrantedVolumes.first;
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
|
|
|
@ -46,8 +46,8 @@ class DebugPageState extends State<DebugPage> {
|
||||||
_startDbReport();
|
_startDbReport();
|
||||||
_volumePermissionLoader = Future.wait<Tuple2<String, bool>>(
|
_volumePermissionLoader = Future.wait<Tuple2<String, bool>>(
|
||||||
androidFileUtils.storageVolumes.map(
|
androidFileUtils.storageVolumes.map(
|
||||||
(volume) => AndroidFileService.hasGrantedPermissionToVolumeRoot(volume.path).then(
|
(volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then(
|
||||||
(value) => Tuple2(volume.path, value),
|
(value) => Tuple2(volume.path, !value),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -65,6 +65,10 @@ class _HomePageState extends State<HomePage> {
|
||||||
break;
|
break;
|
||||||
case 'pick':
|
case 'pick':
|
||||||
AvesApp.mode = AppMode.pick;
|
AvesApp.mode = AppMode.pick;
|
||||||
|
// TODO TLAD apply pick mimetype(s)
|
||||||
|
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
|
||||||
|
String pickMimeTypes = intentData['mimeType'];
|
||||||
|
debugPrint('pick mimeType=' + pickMimeTypes);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ description: A new Flutter application.
|
||||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||||
# Read more about iOS versioning at
|
# Read more about iOS versioning at
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
version: 1.0.6+7
|
version: 1.0.7+8
|
||||||
|
|
||||||
# video_player (as of v0.10.8+2, backed by ExoPlayer):
|
# video_player (as of v0.10.8+2, backed by ExoPlayer):
|
||||||
# - does not support content URIs (by default, but trivial by fork)
|
# - does not support content URIs (by default, but trivial by fork)
|
||||||
|
|
Loading…
Reference in a new issue