storage access: misc fixes for Android R style storage

This commit is contained in:
Thibault Deckers 2020-07-05 16:18:53 +09:00
parent 4f30f5427e
commit e79ffbdb89
9 changed files with 102 additions and 97 deletions

View file

@ -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'

View file

@ -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:

View file

@ -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);
} }

View file

@ -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;
@ -99,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);
@ -189,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) {
@ -259,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 {

View file

@ -276,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));
@ -330,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);
@ -337,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);
@ -408,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 {

View file

@ -91,28 +91,6 @@ public class PermissionManager {
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",

View file

@ -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;
} }

View file

@ -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>(

View file

@ -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),
), ),
), ),
); );