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']]
}
// 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'

View file

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

View file

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

View file

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

View file

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

View file

@ -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",

View file

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

View file

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

View file

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