From e79ffbdb8956295e17fe7c5c21c00f12cb0fba55 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 5 Jul 2020 16:18:53 +0900 Subject: [PATCH] storage access: misc fixes for Android R style storage --- android/app/build.gradle | 14 +-- .../aves/channel/calls/StorageHandler.java | 9 +- .../streams/StorageAccessStreamHandler.java | 2 +- .../aves/model/provider/ImageProvider.java | 117 ++++++++++-------- .../provider/MediaStoreImageProvider.java | 10 +- .../aves/utils/PermissionManager.java | 22 ---- lib/services/android_file_service.dart | 6 +- .../action_delegates/permission_aware.dart | 15 +-- lib/widgets/debug_page.dart | 4 +- 9 files changed, 102 insertions(+), 97 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 3e314b977..a8d7a399c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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' diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java index f7df319a3..764c033bb 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java @@ -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: diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java index 65dd42b6e..c1d529dd6 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java @@ -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); } diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index 1b4029fd7..946bc7081 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -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 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 { diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index 037f1606b..90abe66dc 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -276,6 +276,12 @@ public class MediaStoreImageProvider extends ImageProvider { @Override public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List 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 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 { diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java index 000e086cc..9463e9f24 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java @@ -91,28 +91,6 @@ public class PermissionManager { runnable.run(); } - public static boolean hasGrantedPermissionToVolumeRoot(Context context, String path) { - boolean canAccess = false; - Stream 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 hasGrantedPermissionToVolumeRoot(String path) async { + static Future requireVolumeAccessDialog(String path) async { try { - final result = await platform.invokeMethod('hasGrantedPermissionToVolumeRoot', { + final result = await platform.invokeMethod('requireVolumeAccessDialog', { '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; } diff --git a/lib/widgets/common/action_delegates/permission_aware.dart b/lib/widgets/common/action_delegates/permission_aware.dart index 0557fd1bb..d22d93d52 100644 --- a/lib/widgets/common/action_delegates/permission_aware.dart +++ b/lib/widgets/common/action_delegates/permission_aware.dart @@ -10,16 +10,17 @@ mixin PermissionAwareMixin { } Future checkStoragePermissionForPaths(BuildContext context, Iterable paths) async { - final volumes = paths.map((path) => androidFileUtils.getStorageVolume(path)).toSet(); - final removableVolumes = volumes.where((v) => v.isRemovable); - final volumePermissions = await Future.wait>( - removableVolumes.map( - (volume) => AndroidFileService.hasGrantedPermissionToVolumeRoot(volume.path).then( + final volumes = paths.map(androidFileUtils.getStorageVolume).toSet(); + final ungrantedVolumes = (await Future.wait>( + 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( diff --git a/lib/widgets/debug_page.dart b/lib/widgets/debug_page.dart index d20a06fda..c18f8f8a3 100644 --- a/lib/widgets/debug_page.dart +++ b/lib/widgets/debug_page.dart @@ -46,8 +46,8 @@ class DebugPageState extends State { _startDbReport(); _volumePermissionLoader = Future.wait>( 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), ), ), );