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
This commit is contained in:
Thibault Deckers 2020-02-26 01:49:26 +09:00
parent 5fd7ab2fa6
commit e33b365c5d
5 changed files with 101 additions and 73 deletions

View file

@ -19,6 +19,7 @@ import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.signature.ObjectKey; import com.bumptech.glide.signature.ObjectKey;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.function.Consumer; import java.util.function.Consumer;
import deckers.thibault.aves.decoder.VideoThumbnail; import deckers.thibault.aves.decoder.VideoThumbnail;
@ -66,12 +67,11 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
Params p = params[0]; Params p = params[0];
Bitmap bitmap = null; Bitmap bitmap = null;
if (!this.isCancelled()) { if (!this.isCancelled()) {
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// bitmap = getBytesByResolverThumbnail(p); bitmap = getThumbnailBytesByResolver(p);
// } else { } else {
bitmap = getBytesByMediaStoreThumbnail(p); bitmap = getThumbnailBytesByMediaStore(p);
// bitmap = getBytesByGlide(p); }
// }
} else { } else {
Log.d(LOG_TAG, "getImageBytes with uri=" + p.entry.getUri() + " cancelled"); Log.d(LOG_TAG, "getImageBytes with uri=" + p.entry.getUri() + " cancelled");
} }
@ -87,7 +87,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
} }
@TargetApi(Build.VERSION_CODES.Q) @TargetApi(Build.VERSION_CODES.Q)
private Bitmap getBytesByResolverThumbnail(Params params) { private Bitmap getThumbnailBytesByResolver(Params params) {
ImageEntry entry = params.entry; ImageEntry entry = params.entry;
int width = params.width; int width = params.width;
int height = params.height; int height = params.height;
@ -95,13 +95,13 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
ContentResolver resolver = activity.getContentResolver(); ContentResolver resolver = activity.getContentResolver();
try { try {
return resolver.loadThumbnail(entry.getUri(), new Size(width, height), null); return resolver.loadThumbnail(entry.getUri(), new Size(width, height), null);
} catch (Exception e) { } catch (IOException e) {
e.printStackTrace(); Log.e(LOG_TAG, "failed to load thumbnail for uri=" + entry.getUri(), e);
} }
return null; return null;
} }
private Bitmap getBytesByMediaStoreThumbnail(Params params) { private Bitmap getThumbnailBytesByMediaStore(Params params) {
ImageEntry entry = params.entry; ImageEntry entry = params.entry;
long contentId = entry.getContentId(); long contentId = entry.getContentId();
@ -113,12 +113,12 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
return MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null); return MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null);
} }
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); Log.e(LOG_TAG, "failed to get thumbnail for uri=" + entry.getUri(), e);
} }
return null; return null;
} }
private Bitmap getBytesByGlide(Params params) { private Bitmap getImageBytesByGlide(Params params) {
ImageEntry entry = params.entry; ImageEntry entry = params.entry;
int width = params.width; int width = params.width;
int height = params.height; int height = params.height;

View file

@ -32,9 +32,7 @@ public class MediaStoreStreamHandler implements EventChannel.StreamHandler {
void fetchAll(Activity activity) { void fetchAll(Activity activity) {
Log.d(LOG_TAG, "fetchAll start"); Log.d(LOG_TAG, "fetchAll start");
Instant start = Instant.now(); Instant start = Instant.now();
Stream<ImageEntry> stream = new MediaStoreImageProvider().fetchAll(activity); // 100ms new MediaStoreImageProvider().fetchAll(activity, eventSink); // 350ms
stream.map(ImageEntry::toMap)
.forEach(entry -> eventSink.success(entry)); // 250ms
eventSink.endOfStream(); eventSink.endOfStream();
Log.d(LOG_TAG, "fetchAll complete in " + Duration.between(start, Instant.now()).toMillis() + "ms"); Log.d(LOG_TAG, "fetchAll complete in " + Duration.between(start, Instant.now()).toMillis() + "ms");
} }

View file

@ -1,6 +1,7 @@
package deckers.thibault.aves.model.provider; package deckers.thibault.aves.model.provider;
import android.app.Activity; import android.app.Activity;
import android.content.ContentResolver;
import android.content.ContentUris; import android.content.ContentUris;
import android.content.ContentValues; import android.content.ContentValues;
import android.database.Cursor; import android.database.Cursor;
@ -10,6 +11,7 @@ import android.graphics.Matrix;
import android.media.ExifInterface; import android.media.ExifInterface;
import android.media.MediaScannerConnection; import android.media.MediaScannerConnection;
import android.net.Uri; import android.net.Uri;
import android.os.Build;
import android.os.ParcelFileDescriptor; import android.os.ParcelFileDescriptor;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.util.Log; import android.util.Log;
@ -32,6 +34,8 @@ import deckers.thibault.aves.utils.Utils;
public abstract class ImageProvider { public abstract class ImageProvider {
private static final String LOG_TAG = Utils.createLogTag(ImageProvider.class); 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) { public void delete(final Activity activity, final String path, final Uri uri, final ImageOpCallback callback) {
callback.onFailure(); callback.onFailure();
} }
@ -87,7 +91,7 @@ public abstract class ImageProvider {
if (cursor != null) { if (cursor != null) {
if (cursor.moveToNext()) { if (cursor.moveToNext()) {
long contentId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)); 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("uri", itemUri.toString());
newFields.put("contentId", contentId); newFields.put("contentId", contentId);
newFields.put("path", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA))); newFields.put("path", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)));
@ -168,15 +172,28 @@ public abstract class ImageProvider {
} }
// update fields in media store // update fields in media store
ContentValues values = new ContentValues();
int orientationDegrees = MetadataHelper.getOrientationDegreesForExifCode(newOrientationCode); int orientationDegrees = MetadataHelper.getOrientationDegreesForExifCode(newOrientationCode);
values.put(MediaStore.Images.Media.ORIENTATION, orientationDegrees); Map<String, Object> newFields = new HashMap<>();
if (activity.getContentResolver().update(uri, values, null, null) > 0) { 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) -> { MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> {
Map<String, Object> newFields = new HashMap<>();
newFields.put("orientationDegrees", orientationDegrees);
callback.onSuccess(newFields); 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 // update fields in media store
@SuppressWarnings("SuspiciousNameCombination") int rotatedWidth = originalHeight; @SuppressWarnings("SuspiciousNameCombination") int rotatedWidth = originalHeight;
@SuppressWarnings("SuspiciousNameCombination") int rotatedHeight = originalWidth; @SuppressWarnings("SuspiciousNameCombination") int rotatedHeight = originalWidth;
Map<String, Object> newFields = new HashMap<>();
newFields.put("width", rotatedWidth);
newFields.put("height", rotatedHeight);
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
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.WIDTH, rotatedWidth);
values.put(MediaStore.MediaColumns.HEIGHT, rotatedHeight); 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) -> { MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> {
Map<String, Object> newFields = new HashMap<>();
newFields.put("width", rotatedWidth);
newFields.put("height", rotatedHeight);
callback.onSuccess(newFields); callback.onSuccess(newFields);
}); });
} else {
Log.w(LOG_TAG, "failed to update fields in MediaStore for uri=" + uri);
callback.onSuccess(newFields);
} }
} }

View file

@ -7,7 +7,6 @@ import android.net.Uri;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.util.Log; import android.util.Log;
import java.util.ArrayList;
import java.util.stream.Stream; import java.util.stream.Stream;
import deckers.thibault.aves.model.ImageEntry; 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.PermissionManager;
import deckers.thibault.aves.utils.StorageUtils; import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils; import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.EventChannel;
public class MediaStoreImageProvider extends ImageProvider { public class MediaStoreImageProvider extends ImageProvider {
private static final String LOG_TAG = Utils.createLogTag(MediaStoreImageProvider.class); private static final String LOG_TAG = Utils.createLogTag(MediaStoreImageProvider.class);
public static Uri FILES_URI = MediaStore.Files.getContentUri("external"); private static final String[] IMAGE_PROJECTION = {
private static final String[] PROJECTION = {
// image & video
MediaStore.MediaColumns._ID, MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DATA,
MediaStore.MediaColumns.MIME_TYPE, MediaStore.MediaColumns.MIME_TYPE,
@ -30,38 +27,28 @@ public class MediaStoreImageProvider extends ImageProvider {
MediaStore.MediaColumns.TITLE, MediaStore.MediaColumns.TITLE,
MediaStore.MediaColumns.WIDTH, MediaStore.MediaColumns.WIDTH,
MediaStore.MediaColumns.HEIGHT, MediaStore.MediaColumns.HEIGHT,
MediaStore.Images.Media.ORIENTATION, MediaStore.MediaColumns.ORIENTATION,
MediaStore.MediaColumns.DATE_MODIFIED, MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.Images.Media.DATE_TAKEN, MediaStore.MediaColumns.DATE_TAKEN,
MediaStore.Images.Media.BUCKET_DISPLAY_NAME, MediaStore.MediaColumns.BUCKET_DISPLAY_NAME,
// video only
MediaStore.Video.Media.DURATION,
}; };
private static final String SELECTION = MediaStore.Files.FileColumns.MEDIA_TYPE + "=" + MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE private static final String[] VIDEO_PROJECTION = Stream.of(IMAGE_PROJECTION, new String[]{
+ " OR " + MediaStore.Files.FileColumns.MEDIA_TYPE + "=" + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO; MediaStore.MediaColumns.DURATION
}).flatMap(Stream::of).toArray(String[]::new);
public void fetchAll(Activity activity, EventChannel.EventSink entrySink) {
public Stream<ImageEntry> fetchAll(Activity activity) { fetch(activity, entrySink, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_PROJECTION);
return fetch(activity, FILES_URI); fetch(activity, entrySink, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, VIDEO_PROJECTION);
} }
private Stream<ImageEntry> fetch(final Activity activity, final Uri queryUri) { private void fetch(final Activity activity, EventChannel.EventSink entrySink, final Uri contentUri, String[] projection) {
ArrayList<ImageEntry> entries = new ArrayList<>(); String orderBy = MediaStore.MediaColumns.DATE_TAKEN + " DESC";
// 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";
try { try {
Cursor cursor = activity.getContentResolver().query(filesUri, PROJECTION, SELECTION, null, orderBy); Cursor cursor = activity.getContentResolver().query(contentUri, projection, null, null, orderBy);
if (cursor != null) { if (cursor != null) {
// image & video
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID); int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID);
int pathColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA); int pathColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA);
int mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE); 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 titleColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE);
int widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH); int widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH);
int heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT); 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 dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED);
int dateTakenColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN); int dateTakenColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN);
int bucketDisplayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_DISPLAY_NAME); int bucketDisplayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_DISPLAY_NAME);
int durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION); // video only
int durationColumn = cursor.getColumnIndex(MediaStore.MediaColumns.DURATION);
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
long contentId = cursor.getLong(idColumn); long contentId = cursor.getLong(idColumn);
Uri itemUri = ContentUris.withAppendedId(FILES_URI, contentId); Uri itemUri = ContentUris.withAppendedId(contentUri, contentId);
ImageEntry imageEntry = new ImageEntry( ImageEntry imageEntry = new ImageEntry(
itemUri, itemUri,
cursor.getString(pathColumn), cursor.getString(pathColumn),
@ -91,28 +79,34 @@ public class MediaStoreImageProvider extends ImageProvider {
cursor.getLong(dateModifiedColumn), cursor.getLong(dateModifiedColumn),
cursor.getLong(dateTakenColumn), cursor.getLong(dateTakenColumn),
cursor.getString(bucketDisplayNameColumn), 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) { if (imageEntry.getWidth() > 0) {
entries.add(imageEntry); // TODO TLAD avoid creating ImageEntry to convert it right after
// } else { entrySink.success(ImageEntry.toMap(imageEntry));
// // some images are incorrectly registered in the MediaStore, // } else {
// // they are valid but miss some attributes, such as width, height, orientation // // some images are incorrectly registered in the MediaStore,
// try { // // they are valid but miss some attributes, such as width, height, orientation
// imageEntry.fixMissingWidthHeightOrientation(activity); // try {
// entries.add(imageEntry); // imageEntry.fixMissingWidthHeightOrientation(activity);
// } catch (IOException e) { // entrySink.success(imageEntry);
// // this is probably not a real image, like "/storage/emulated/0", so we skip it // } catch (IOException e) {
// Log.w(LOG_TAG, "failed to compute dimensions of imageEntry=" + imageEntry); // // 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(); cursor.close();
} }
} catch (Exception e) { } 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 @Override
@ -145,4 +139,4 @@ public class MediaStoreImageProvider extends ImageProvider {
callback.onFailure(); callback.onFailure();
} }
} }

View file

@ -1,9 +1,12 @@
import 'dart:io';
import 'package:aves/model/image_file_service.dart'; import 'package:aves/model/image_file_service.dart';
import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_service.dart'; import 'package:aves/model/metadata_service.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/time_utils.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:geocoder/geocoder.dart'; import 'package:geocoder/geocoder.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
@ -228,6 +231,9 @@ class ImageEntry {
if (height is int) this.height = height; if (height is int) this.height = height;
final orientationDegrees = newFields['orientationDegrees']; final orientationDegrees = newFields['orientationDegrees'];
if (orientationDegrees is int) this.orientationDegrees = 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(); imageChangeNotifier.notifyListeners();
return true; return true;
} }