storage access: misc fixes for Android R style storage
This commit is contained in:
parent
4f30f5427e
commit
e79ffbdb89
9 changed files with 102 additions and 97 deletions
|
@ -51,12 +51,12 @@ android {
|
|||
manifestPlaceholders = [googleApiKey:keystoreProperties['googleApiKey']]
|
||||
}
|
||||
|
||||
// compileOptions {
|
||||
// // enable support for Java 8 language APIs (stream, optional, etc.)
|
||||
// coreLibraryDesugaringEnabled true
|
||||
// sourceCompatibility JavaVersion.VERSION_1_8
|
||||
// targetCompatibility JavaVersion.VERSION_1_8
|
||||
// }
|
||||
compileOptions {
|
||||
// enable support for Java 8 language APIs (stream, optional, etc.)
|
||||
coreLibraryDesugaringEnabled true
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
|
@ -105,7 +105,7 @@ repositories {
|
|||
|
||||
dependencies {
|
||||
// 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 'com.commonsware.cwac:document:0.4.1'
|
||||
|
|
|
@ -42,10 +42,13 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
|
|||
result.success(volumes);
|
||||
break;
|
||||
}
|
||||
case "hasGrantedPermissionToVolumeRoot": {
|
||||
case "requireVolumeAccessDialog": {
|
||||
String path = call.argument("path");
|
||||
boolean granted = PermissionManager.hasGrantedPermissionToVolumeRoot(activity, path);
|
||||
result.success(granted);
|
||||
if (path == null) {
|
||||
result.success(true);
|
||||
} else {
|
||||
result.success(PermissionManager.requireVolumeAccessDialog(activity, path));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
|
|
@ -32,7 +32,7 @@ public class StorageAccessStreamHandler implements EventChannel.StreamHandler {
|
|||
public void onListen(Object o, final EventChannel.EventSink eventSink) {
|
||||
this.eventSink = eventSink;
|
||||
this.handler = new Handler(Looper.getMainLooper());
|
||||
Runnable onGranted = () -> success(PermissionManager.hasGrantedPermissionToVolumeRoot(activity, volumePath));
|
||||
Runnable onGranted = () -> success(!PermissionManager.requireVolumeAccessDialog(activity, volumePath));
|
||||
Runnable onDenied = () -> success(false);
|
||||
PermissionManager.requestVolumeAccess(activity, volumePath, onGranted, onDenied);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package deckers.thibault.aves.model.provider;
|
|||
|
||||
import android.app.Activity;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentUris;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
@ -99,23 +100,33 @@ public abstract class ImageProvider {
|
|||
MediaScannerConnection.scanFile(activity, new String[]{newFile.getPath()}, new String[]{mimeType}, (newPath, newUri) -> {
|
||||
Log.d(LOG_TAG, "onScanCompleted with newPath=" + newPath + ", newUri=" + newUri);
|
||||
if (newUri != null) {
|
||||
// we retrieve updated fields as the renamed file became a new entry in the Media Store
|
||||
String[] projection = {MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.TITLE};
|
||||
try {
|
||||
Cursor cursor = activity.getContentResolver().query(newUri, projection, null, null, null);
|
||||
if (cursor != null) {
|
||||
if (cursor.moveToNext()) {
|
||||
long contentId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID));
|
||||
newFields.put("uri", newUri.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)));
|
||||
// newURI is a file media URI (e.g. "content://media/12a9-8b42/file/62872")
|
||||
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
|
||||
long contentId = ContentUris.parseId(newUri);
|
||||
Uri contentUri = null;
|
||||
if (mimeType.startsWith(MimeTypes.IMAGE)) {
|
||||
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId);
|
||||
} else if (mimeType.startsWith(MimeTypes.VIDEO)) {
|
||||
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId);
|
||||
}
|
||||
if (contentUri != null) {
|
||||
// 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);
|
||||
|
@ -189,24 +200,26 @@ public abstract class ImageProvider {
|
|||
Map<String, Object> newFields = new HashMap<>();
|
||||
newFields.put("orientationDegrees", orientationDegrees);
|
||||
|
||||
ContentResolver contentResolver = activity.getContentResolver();
|
||||
ContentValues values = new ContentValues();
|
||||
// from Android Q, media store update needs to be flagged IS_PENDING first
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
values.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
||||
contentResolver.update(uri, values, null, null);
|
||||
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);
|
||||
int updatedRowCount = contentResolver.update(uri, values, null, null);
|
||||
if (updatedRowCount > 0) {
|
||||
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
|
||||
} else {
|
||||
Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
|
||||
callback.onSuccess(newFields);
|
||||
}
|
||||
// ContentResolver contentResolver = activity.getContentResolver();
|
||||
// ContentValues values = new ContentValues();
|
||||
// // from Android Q, media store update needs to be flagged IS_PENDING first
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// values.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
||||
// // TODO TLAD catch RecoverableSecurityException
|
||||
// contentResolver.update(uri, values, null, null);
|
||||
// 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);
|
||||
// // TODO TLAD catch RecoverableSecurityException
|
||||
// int updatedRowCount = contentResolver.update(uri, values, null, null);
|
||||
// if (updatedRowCount > 0) {
|
||||
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> 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) {
|
||||
|
@ -259,24 +272,26 @@ public abstract class ImageProvider {
|
|||
newFields.put("width", rotatedWidth);
|
||||
newFields.put("height", rotatedHeight);
|
||||
|
||||
ContentResolver contentResolver = activity.getContentResolver();
|
||||
ContentValues values = new ContentValues();
|
||||
// from Android Q, media store update needs to be flagged IS_PENDING first
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
values.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
||||
contentResolver.update(uri, values, null, null);
|
||||
values.clear();
|
||||
values.put(MediaStore.MediaColumns.IS_PENDING, 0);
|
||||
}
|
||||
values.put(MediaStore.MediaColumns.WIDTH, rotatedWidth);
|
||||
values.put(MediaStore.MediaColumns.HEIGHT, rotatedHeight);
|
||||
int updatedRowCount = contentResolver.update(uri, values, null, null);
|
||||
if (updatedRowCount > 0) {
|
||||
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
|
||||
} else {
|
||||
Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
|
||||
callback.onSuccess(newFields);
|
||||
}
|
||||
// ContentResolver contentResolver = activity.getContentResolver();
|
||||
// ContentValues values = new ContentValues();
|
||||
// // from Android Q, media store update needs to be flagged IS_PENDING first
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// values.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
||||
// // TODO TLAD catch RecoverableSecurityException
|
||||
// contentResolver.update(uri, values, null, null);
|
||||
// values.clear();
|
||||
// values.put(MediaStore.MediaColumns.IS_PENDING, 0);
|
||||
// }
|
||||
// values.put(MediaStore.MediaColumns.WIDTH, rotatedWidth);
|
||||
// values.put(MediaStore.MediaColumns.HEIGHT, rotatedHeight);
|
||||
// // TODO TLAD catch RecoverableSecurityException
|
||||
// int updatedRowCount = contentResolver.update(uri, values, null, null);
|
||||
// if (updatedRowCount > 0) {
|
||||
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
|
||||
// } else {
|
||||
// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
|
||||
// callback.onSuccess(newFields);
|
||||
// }
|
||||
}
|
||||
|
||||
public interface ImageOpCallback {
|
||||
|
|
|
@ -276,6 +276,12 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
|
||||
@Override
|
||||
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);
|
||||
if (destinationDirDocFile == null) {
|
||||
callback.onFailure(new Exception("failed to create directory at path=" + destinationDir));
|
||||
|
@ -330,6 +336,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(MediaStore.MediaColumns.DATA, destinationPath);
|
||||
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.DISPLAY_NAME, "");
|
||||
Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? MediaStore.Video.Media.getContentUri(volumeName) : MediaStore.Images.Media.getContentUri(volumeName);
|
||||
|
@ -337,7 +344,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
if (destinationUri == null) {
|
||||
future.setException(new Exception("failed to insert row to content resolver"));
|
||||
} else {
|
||||
DocumentFileCompat source = DocumentFileCompat.fromFile(new File(sourcePath));
|
||||
DocumentFileCompat source = DocumentFileCompat.fromSingleUri(activity, sourceUri);
|
||||
DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri);
|
||||
source.copyTo(destination);
|
||||
|
||||
|
@ -408,6 +415,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
MediaScannerConnection.scanFile(activity, new String[]{destinationPath}, new String[]{mimeType}, (newPath, newUri) -> {
|
||||
Map<String, Object> newFields = new HashMap<>();
|
||||
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
|
||||
String[] projection = {MediaStore.MediaColumns._ID};
|
||||
try {
|
||||
|
|
|
@ -91,28 +91,6 @@ public class PermissionManager {
|
|||
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) {
|
||||
return DocumentsContract.buildTreeDocumentUri(
|
||||
"com.android.externalstorage.documents",
|
||||
|
|
|
@ -18,14 +18,14 @@ class AndroidFileService {
|
|||
return [];
|
||||
}
|
||||
|
||||
static Future<bool> hasGrantedPermissionToVolumeRoot(String path) async {
|
||||
static Future<bool> requireVolumeAccessDialog(String path) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('hasGrantedPermissionToVolumeRoot', <String, dynamic>{
|
||||
final result = await platform.invokeMethod('requireVolumeAccessDialog', <String, dynamic>{
|
||||
'path': path,
|
||||
});
|
||||
return result as bool;
|
||||
} 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;
|
||||
}
|
||||
|
|
|
@ -10,16 +10,17 @@ mixin PermissionAwareMixin {
|
|||
}
|
||||
|
||||
Future<bool> checkStoragePermissionForPaths(BuildContext context, Iterable<String> paths) async {
|
||||
final volumes = paths.map((path) => androidFileUtils.getStorageVolume(path)).toSet();
|
||||
final removableVolumes = volumes.where((v) => v.isRemovable);
|
||||
final volumePermissions = await Future.wait<Tuple2<StorageVolume, bool>>(
|
||||
removableVolumes.map(
|
||||
(volume) => AndroidFileService.hasGrantedPermissionToVolumeRoot(volume.path).then(
|
||||
final volumes = paths.map(androidFileUtils.getStorageVolume).toSet();
|
||||
final ungrantedVolumes = (await Future.wait<Tuple2<StorageVolume, bool>>(
|
||||
volumes.map(
|
||||
(volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then(
|
||||
(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) {
|
||||
final volume = ungrantedVolumes.first;
|
||||
final confirmed = await showDialog<bool>(
|
||||
|
|
|
@ -46,8 +46,8 @@ class DebugPageState extends State<DebugPage> {
|
|||
_startDbReport();
|
||||
_volumePermissionLoader = Future.wait<Tuple2<String, bool>>(
|
||||
androidFileUtils.storageVolumes.map(
|
||||
(volume) => AndroidFileService.hasGrantedPermissionToVolumeRoot(volume.path).then(
|
||||
(value) => Tuple2(volume.path, value),
|
||||
(volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then(
|
||||
(value) => Tuple2(volume.path, !value),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue