From e33b365c5d1b4a650283736970d4e185b4d77550 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 26 Feb 2020 01:49:26 +0900 Subject: [PATCH] Android Q: use new API to get thumbnail, fixed MediaStore update fullscreen: evict image cache after rotation init: directly use event sink instead of stream when getting MediaStore items --- .../aves/channelhandlers/ImageDecodeTask.java | 24 ++--- .../MediaStoreStreamHandler.java | 4 +- .../aves/model/provider/ImageProvider.java | 50 ++++++++--- .../provider/MediaStoreImageProvider.java | 90 +++++++++---------- lib/model/image_entry.dart | 6 ++ 5 files changed, 101 insertions(+), 73 deletions(-) diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java index 055892408..676ba24c5 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java @@ -19,6 +19,7 @@ import com.bumptech.glide.request.RequestOptions; import com.bumptech.glide.signature.ObjectKey; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.util.function.Consumer; import deckers.thibault.aves.decoder.VideoThumbnail; @@ -66,12 +67,11 @@ public class ImageDecodeTask extends AsyncTask= Build.VERSION_CODES.Q) { -// bitmap = getBytesByResolverThumbnail(p); -// } else { - bitmap = getBytesByMediaStoreThumbnail(p); -// bitmap = getBytesByGlide(p); -// } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + bitmap = getThumbnailBytesByResolver(p); + } else { + bitmap = getThumbnailBytesByMediaStore(p); + } } else { Log.d(LOG_TAG, "getImageBytes with uri=" + p.entry.getUri() + " cancelled"); } @@ -87,7 +87,7 @@ public class ImageDecodeTask extends AsyncTask stream = new MediaStoreImageProvider().fetchAll(activity); // 100ms - stream.map(ImageEntry::toMap) - .forEach(entry -> eventSink.success(entry)); // 250ms + new MediaStoreImageProvider().fetchAll(activity, eventSink); // 350ms eventSink.endOfStream(); Log.d(LOG_TAG, "fetchAll complete in " + Duration.between(start, Instant.now()).toMillis() + "ms"); } 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 741740020..39740a1f1 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 @@ -1,6 +1,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.database.Cursor; @@ -10,6 +11,7 @@ import android.graphics.Matrix; import android.media.ExifInterface; import android.media.MediaScannerConnection; import android.net.Uri; +import android.os.Build; import android.os.ParcelFileDescriptor; import android.provider.MediaStore; import android.util.Log; @@ -32,6 +34,8 @@ import deckers.thibault.aves.utils.Utils; public abstract class ImageProvider { private static final String LOG_TAG = Utils.createLogTag(ImageProvider.class); + private static Uri FILES_URI = MediaStore.Files.getContentUri("external"); + public void delete(final Activity activity, final String path, final Uri uri, final ImageOpCallback callback) { callback.onFailure(); } @@ -87,7 +91,7 @@ public abstract class ImageProvider { if (cursor != null) { if (cursor.moveToNext()) { long contentId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)); - Uri itemUri = ContentUris.withAppendedId(MediaStoreImageProvider.FILES_URI, contentId); + Uri itemUri = ContentUris.withAppendedId(FILES_URI, contentId); newFields.put("uri", itemUri.toString()); newFields.put("contentId", contentId); newFields.put("path", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA))); @@ -168,15 +172,28 @@ public abstract class ImageProvider { } // update fields in media store - ContentValues values = new ContentValues(); int orientationDegrees = MetadataHelper.getOrientationDegreesForExifCode(newOrientationCode); - values.put(MediaStore.Images.Media.ORIENTATION, orientationDegrees); - if (activity.getContentResolver().update(uri, values, null, null) > 0) { + 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); + } + values.put(MediaStore.MediaColumns.ORIENTATION, orientationDegrees); + int updatedRowCount = contentResolver.update(uri, values, null, null); + if (updatedRowCount > 0) { MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> { - Map newFields = new HashMap<>(); - newFields.put("orientationDegrees", orientationDegrees); callback.onSuccess(newFields); }); + } else { + Log.w(LOG_TAG, "failed to update fields in MediaStore for uri=" + uri); + callback.onSuccess(newFields); } } @@ -238,16 +255,29 @@ public abstract class ImageProvider { // update fields in media store @SuppressWarnings("SuspiciousNameCombination") int rotatedWidth = originalHeight; @SuppressWarnings("SuspiciousNameCombination") int rotatedHeight = originalWidth; + Map newFields = new HashMap<>(); + 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); - if (activity.getContentResolver().update(uri, values, null, null) > 0) { + int updatedRowCount = contentResolver.update(uri, values, null, null); + if (updatedRowCount > 0) { MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> { - Map newFields = new HashMap<>(); - newFields.put("width", rotatedWidth); - newFields.put("height", rotatedHeight); callback.onSuccess(newFields); }); + } else { + Log.w(LOG_TAG, "failed to update fields in MediaStore for uri=" + uri); + callback.onSuccess(newFields); } } 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 6885d9f73..754ea5ad9 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 @@ -7,7 +7,6 @@ import android.net.Uri; import android.provider.MediaStore; import android.util.Log; -import java.util.ArrayList; import java.util.stream.Stream; import deckers.thibault.aves.model.ImageEntry; @@ -15,14 +14,12 @@ import deckers.thibault.aves.utils.Env; import deckers.thibault.aves.utils.PermissionManager; import deckers.thibault.aves.utils.StorageUtils; import deckers.thibault.aves.utils.Utils; +import io.flutter.plugin.common.EventChannel; public class MediaStoreImageProvider extends ImageProvider { private static final String LOG_TAG = Utils.createLogTag(MediaStoreImageProvider.class); - public static Uri FILES_URI = MediaStore.Files.getContentUri("external"); - - private static final String[] PROJECTION = { - // image & video + private static final String[] IMAGE_PROJECTION = { MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.MIME_TYPE, @@ -30,38 +27,28 @@ public class MediaStoreImageProvider extends ImageProvider { MediaStore.MediaColumns.TITLE, MediaStore.MediaColumns.WIDTH, MediaStore.MediaColumns.HEIGHT, - MediaStore.Images.Media.ORIENTATION, + MediaStore.MediaColumns.ORIENTATION, MediaStore.MediaColumns.DATE_MODIFIED, - MediaStore.Images.Media.DATE_TAKEN, - MediaStore.Images.Media.BUCKET_DISPLAY_NAME, - // video only - MediaStore.Video.Media.DURATION, + MediaStore.MediaColumns.DATE_TAKEN, + MediaStore.MediaColumns.BUCKET_DISPLAY_NAME, }; - private static final String SELECTION = MediaStore.Files.FileColumns.MEDIA_TYPE + "=" + MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE - + " OR " + MediaStore.Files.FileColumns.MEDIA_TYPE + "=" + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO; + private static final String[] VIDEO_PROJECTION = Stream.of(IMAGE_PROJECTION, new String[]{ + MediaStore.MediaColumns.DURATION + }).flatMap(Stream::of).toArray(String[]::new); - - public Stream fetchAll(Activity activity) { - return fetch(activity, FILES_URI); + public void fetchAll(Activity activity, EventChannel.EventSink entrySink) { + fetch(activity, entrySink, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_PROJECTION); + fetch(activity, entrySink, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, VIDEO_PROJECTION); } - private Stream fetch(final Activity activity, final Uri queryUri) { - ArrayList entries = new ArrayList<>(); - - // URI should refer to the "files" table, not to the "images" or "videos" one, - // as our projection includes a mix of columns from both - Uri filesUri = queryUri; - if (!FILES_URI.equals(queryUri)) { - String id = queryUri.getLastPathSegment(); - filesUri = Uri.withAppendedPath(FILES_URI, id); - } - - String orderBy = MediaStore.Images.ImageColumns.DATE_TAKEN + " DESC"; + private void fetch(final Activity activity, EventChannel.EventSink entrySink, final Uri contentUri, String[] projection) { + String orderBy = MediaStore.MediaColumns.DATE_TAKEN + " DESC"; try { - Cursor cursor = activity.getContentResolver().query(filesUri, PROJECTION, SELECTION, null, orderBy); + Cursor cursor = activity.getContentResolver().query(contentUri, projection, null, null, orderBy); if (cursor != null) { + // image & video int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID); int pathColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA); int mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE); @@ -69,15 +56,16 @@ public class MediaStoreImageProvider extends ImageProvider { int titleColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE); int widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH); int heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT); - int orientationColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION); + int orientationColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION); int dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED); - int dateTakenColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN); - int bucketDisplayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_DISPLAY_NAME); - int durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION); + int dateTakenColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN); + int bucketDisplayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_DISPLAY_NAME); + // video only + int durationColumn = cursor.getColumnIndex(MediaStore.MediaColumns.DURATION); while (cursor.moveToNext()) { long contentId = cursor.getLong(idColumn); - Uri itemUri = ContentUris.withAppendedId(FILES_URI, contentId); + Uri itemUri = ContentUris.withAppendedId(contentUri, contentId); ImageEntry imageEntry = new ImageEntry( itemUri, cursor.getString(pathColumn), @@ -91,28 +79,34 @@ public class MediaStoreImageProvider extends ImageProvider { cursor.getLong(dateModifiedColumn), cursor.getLong(dateTakenColumn), cursor.getString(bucketDisplayNameColumn), - cursor.getLong(durationColumn) + durationColumn != -1 ? cursor.getLong(durationColumn) : 0 ); + // TODO TLAD sanitize mimeType + // problem: some images were added as image/jpeg, but they're actually image/png + // possible solution: + // 1) check that MediaStore mimeType matches expected mimeType from file path extension + // 2) extract actual mimeType with metadata-extractor + // 3) update MediaStore if (imageEntry.getWidth() > 0) { - entries.add(imageEntry); -// } else { -// // some images are incorrectly registered in the MediaStore, -// // they are valid but miss some attributes, such as width, height, orientation -// try { -// imageEntry.fixMissingWidthHeightOrientation(activity); -// entries.add(imageEntry); -// } catch (IOException e) { -// // this is probably not a real image, like "/storage/emulated/0", so we skip it -// Log.w(LOG_TAG, "failed to compute dimensions of imageEntry=" + imageEntry); -// } + // TODO TLAD avoid creating ImageEntry to convert it right after + entrySink.success(ImageEntry.toMap(imageEntry)); +// } else { +// // some images are incorrectly registered in the MediaStore, +// // they are valid but miss some attributes, such as width, height, orientation +// try { +// imageEntry.fixMissingWidthHeightOrientation(activity); +// entrySink.success(imageEntry); +// } catch (IOException e) { +// // this is probably not a real image, like "/storage/emulated/0", so we skip it +// Log.w(LOG_TAG, "failed to compute dimensions of imageEntry=" + imageEntry); +// } } } cursor.close(); } } catch (Exception e) { - Log.d(LOG_TAG, "failed to get entries", e); + Log.e(LOG_TAG, "failed to get entries", e); } - return entries.stream(); } @Override @@ -145,4 +139,4 @@ public class MediaStoreImageProvider extends ImageProvider { callback.onFailure(); } -} +} \ No newline at end of file diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 78d4d7a45..a7eb992c7 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -1,9 +1,12 @@ +import 'dart:io'; + import 'package:aves/model/image_file_service.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata_service.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/time_utils.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:geocoder/geocoder.dart'; import 'package:path/path.dart'; import 'package:tuple/tuple.dart'; @@ -228,6 +231,9 @@ class ImageEntry { if (height is int) this.height = height; final orientationDegrees = newFields['orientationDegrees']; if (orientationDegrees is int) this.orientationDegrees = orientationDegrees; + + // TODO TLAD move cache eviction out of ImageEntry and into ImagePage together with `imageChangeNotifier` handling + await FileImage(File(this.path)).evict(); imageChangeNotifier.notifyListeners(); return true; }