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:
parent
5fd7ab2fa6
commit
e33b365c5d
5 changed files with 101 additions and 73 deletions
|
@ -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<ImageDecodeTask.Params, Void, Ima
|
|||
Params p = params[0];
|
||||
Bitmap bitmap = null;
|
||||
if (!this.isCancelled()) {
|
||||
// if (Build.VERSION.SDK_INT >= 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<ImageDecodeTask.Params, Void, Ima
|
|||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.Q)
|
||||
private Bitmap getBytesByResolverThumbnail(Params params) {
|
||||
private Bitmap getThumbnailBytesByResolver(Params params) {
|
||||
ImageEntry entry = params.entry;
|
||||
int width = params.width;
|
||||
int height = params.height;
|
||||
|
@ -95,13 +95,13 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
|
|||
ContentResolver resolver = activity.getContentResolver();
|
||||
try {
|
||||
return resolver.loadThumbnail(entry.getUri(), new Size(width, height), null);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
} catch (IOException e) {
|
||||
Log.e(LOG_TAG, "failed to load thumbnail for uri=" + entry.getUri(), e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Bitmap getBytesByMediaStoreThumbnail(Params params) {
|
||||
private Bitmap getThumbnailBytesByMediaStore(Params params) {
|
||||
ImageEntry entry = params.entry;
|
||||
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);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
Log.e(LOG_TAG, "failed to get thumbnail for uri=" + entry.getUri(), e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Bitmap getBytesByGlide(Params params) {
|
||||
private Bitmap getImageBytesByGlide(Params params) {
|
||||
ImageEntry entry = params.entry;
|
||||
int width = params.width;
|
||||
int height = params.height;
|
||||
|
|
|
@ -32,9 +32,7 @@ public class MediaStoreStreamHandler implements EventChannel.StreamHandler {
|
|||
void fetchAll(Activity activity) {
|
||||
Log.d(LOG_TAG, "fetchAll start");
|
||||
Instant start = Instant.now();
|
||||
Stream<ImageEntry> 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");
|
||||
}
|
||||
|
|
|
@ -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<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);
|
||||
}
|
||||
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<String, Object> 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<String, Object> 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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<ImageEntry> 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<ImageEntry> fetch(final Activity activity, final Uri queryUri) {
|
||||
ArrayList<ImageEntry> 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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue