poc: load thumbnails with glide

This commit is contained in:
Thibault Deckers 2019-07-13 19:17:50 +09:00
parent d9695b0c68
commit 79e306a99c
9 changed files with 162 additions and 122 deletions

View file

@ -54,6 +54,9 @@ flutter {
} }
dependencies { dependencies {
implementation 'com.github.bumptech.glide:glide:4.9.0'
annotationProcessor 'androidx.annotation:annotation:1.1.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.9.0'
implementation 'com.karumi:dexter:5.0.0' implementation 'com.karumi:dexter:5.0.0'
testImplementation 'junit:junit:4.12' testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2' androidTestImplementation 'com.android.support.test:runner:1.0.2'

View file

@ -1,24 +1,21 @@
package deckers.thibault.aves; package deckers.thibault.aves;
import android.Manifest; import android.Manifest;
import android.annotation.SuppressLint;
import android.app.Activity; import android.app.Activity;
import android.app.AlertDialog; import android.app.AlertDialog;
import android.content.ContentResolver;
import android.content.DialogInterface;
import android.content.Intent; import android.content.Intent;
import android.database.Cursor;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri; import android.net.Uri;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.provider.MediaStore;
import android.provider.Settings; import android.provider.Settings;
import android.util.Log; import android.util.Log;
import android.util.Size;
import android.util.SparseArray;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.Key;
import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.signature.ObjectKey;
import com.karumi.dexter.Dexter; import com.karumi.dexter.Dexter;
import com.karumi.dexter.PermissionToken; import com.karumi.dexter.PermissionToken;
import com.karumi.dexter.listener.PermissionDeniedResponse; import com.karumi.dexter.listener.PermissionDeniedResponse;
@ -27,47 +24,42 @@ import com.karumi.dexter.listener.PermissionRequest;
import com.karumi.dexter.listener.single.PermissionListener; import com.karumi.dexter.listener.single.PermissionListener;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import deckers.thibault.aves.model.ImageEntry; import deckers.thibault.aves.model.ImageEntry;
import deckers.thibault.aves.model.provider.MediaStoreImageProvider; import deckers.thibault.aves.model.provider.MediaStoreImageProvider;
import deckers.thibault.aves.utils.Utils; import deckers.thibault.aves.utils.Utils;
import io.flutter.app.FlutterActivity; import io.flutter.app.FlutterActivity;
import io.flutter.plugins.GeneratedPluginRegistrant;
import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.Result; import io.flutter.plugin.common.MethodChannel.Result;
import io.flutter.plugins.GeneratedPluginRegistrant;
class ThumbnailFetcher { class ThumbnailFetcher {
private ContentResolver contentResolver; private Activity activity;
private SparseArray<AsyncTask> taskMap = new SparseArray<>(); private HashMap<String, AsyncTask> taskMap = new HashMap<>();
ThumbnailFetcher(ContentResolver contentResolver) { ThumbnailFetcher(Activity activity) {
this.contentResolver = contentResolver; this.activity = activity;
} }
void fetch (Integer id, Result result) { void fetch(ImageEntry entry, Integer width, Integer height, Result result) {
AsyncTask task = new BitmapWorkerTask(contentResolver).execute(new BitmapWorkerTask.MyTaskParams(id, result, this::complete)); BitmapWorkerTask.MyTaskParams params = new BitmapWorkerTask.MyTaskParams(entry, width, height, result, this::complete);
taskMap.append(id, task); AsyncTask task = new BitmapWorkerTask(activity).execute(params);
taskMap.put(entry.getUri().toString(), task);
} }
void cancel(Integer id) { void cancel(String uri) {
AsyncTask task = taskMap.get(id, null); AsyncTask task = taskMap.get(uri);
if (task != null) task.cancel(true); if (task != null) task.cancel(true);
taskMap.remove(id); taskMap.remove(uri);
} }
void complete(Integer id) { private void complete(String uri) {
taskMap.remove(id); taskMap.remove(uri);
} }
} }
@ -81,7 +73,7 @@ public class MainActivity extends FlutterActivity {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
GeneratedPluginRegistrant.registerWith(this); GeneratedPluginRegistrant.registerWith(this);
thumbnailFetcher = new ThumbnailFetcher(getContentResolver()); thumbnailFetcher = new ThumbnailFetcher(this);
new MethodChannel(getFlutterView(), CHANNEL).setMethodCallHandler( new MethodChannel(getFlutterView(), CHANNEL).setMethodCallHandler(
(call, result) -> { (call, result) -> {
switch (call.method) { switch (call.method) {
@ -89,13 +81,16 @@ public class MainActivity extends FlutterActivity {
getPermissionResult(result, this); getPermissionResult(result, this);
break; break;
case "getThumbnail": { case "getThumbnail": {
Integer id = call.argument("id"); Map map = call.argument("entry");
thumbnailFetcher.fetch(id, result); Integer width = call.argument("width");
Integer height = call.argument("height");
ImageEntry entry = new ImageEntry(map);
thumbnailFetcher.fetch(entry, width, height, result);
break; break;
} }
case "cancelGetThumbnail": { case "cancelGetThumbnail": {
Integer id = call.argument("id"); String uri = call.argument("uri");
thumbnailFetcher.cancel(id); thumbnailFetcher.cancel(uri);
result.success(null); result.success(null);
break; break;
} }
@ -106,21 +101,6 @@ public class MainActivity extends FlutterActivity {
}); });
} }
// public void getImageThumbnail(final Result result, String uri, int width, int height) {
// // https://developer.android.com/reference/android/content/ContentResolver.html#loadThumbnail(android.net.Uri,%20android.util.Size,%20android.os.CancellationSignal)
// try {
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Bitmap bmp = getContentResolver().loadThumbnail(Uri.parse(uri), new Size(width, height), null);
// result.success(bmp);
// } else {
// // TODO get by mediastore
// getContentResolver().
// }
// } catch (IOException e) {
// e.printStackTrace();
// }
// }
public void getPermissionResult(final Result result, final Activity activity) { public void getPermissionResult(final Result result, final Activity activity) {
Dexter.withActivity(activity) Dexter.withActivity(activity)
.withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) .withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
@ -137,47 +117,32 @@ public class MainActivity extends FlutterActivity {
builder.setMessage("This permission is needed for use this features of the app so please, allow it!"); builder.setMessage("This permission is needed for use this features of the app so please, allow it!");
builder.setTitle("We need this permission"); builder.setTitle("We need this permission");
builder.setCancelable(false); builder.setCancelable(false);
builder.setPositiveButton("OK", new DialogInterface.OnClickListener() { builder.setPositiveButton("OK", (dialog, id) -> {
public void onClick(DialogInterface dialog, int id) { dialog.cancel();
dialog.cancel(); Intent intent = new Intent();
Intent intent = new Intent(); intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); Uri uri = Uri.fromParts("package", activity.getPackageName(), null);
Uri uri = Uri.fromParts("package", activity.getPackageName(), null); intent.setData(uri);
intent.setData(uri); activity.startActivity(intent);
activity.startActivity(intent);
}
});
builder.setNegativeButton("Cancel", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int id) {
dialog.cancel();
}
}); });
builder.setNegativeButton("Cancel", (dialog, id) -> dialog.cancel());
AlertDialog alert = builder.create(); AlertDialog alert = builder.create();
alert.show(); alert.show();
} }
@Override @Override
public void onPermissionRationaleShouldBeShown(PermissionRequest permission, final PermissionToken token) { public void onPermissionRationaleShouldBeShown(PermissionRequest permission, final PermissionToken token) {
AlertDialog.Builder builder = new AlertDialog.Builder(activity); AlertDialog.Builder builder = new AlertDialog.Builder(activity);
builder.setMessage("This permission is needed for use this features of the app so please, allow it!"); builder.setMessage("This permission is needed for use this features of the app so please, allow it!");
builder.setTitle("We need this permission"); builder.setTitle("We need this permission");
builder.setCancelable(false); builder.setCancelable(false);
builder.setPositiveButton("OK", new DialogInterface.OnClickListener() { builder.setPositiveButton("OK", (dialog, id) -> {
public void onClick(DialogInterface dialog, int id) { dialog.cancel();
dialog.cancel(); token.continuePermissionRequest();
token.continuePermissionRequest();
}
}); });
builder.setNegativeButton("Cancel", new DialogInterface.OnClickListener() { builder.setNegativeButton("Cancel", (dialog, id) -> {
public void onClick(DialogInterface dialog, int id) { dialog.cancel();
dialog.cancel(); token.cancelPermissionRequest();
token.cancelPermissionRequest();
}
}); });
AlertDialog alert = builder.create(); AlertDialog alert = builder.create();
alert.show(); alert.show();
@ -196,12 +161,15 @@ class BitmapWorkerTask extends AsyncTask<BitmapWorkerTask.MyTaskParams, Void, Bi
private static final String LOG_TAG = Utils.createLogTag(BitmapWorkerTask.class); private static final String LOG_TAG = Utils.createLogTag(BitmapWorkerTask.class);
static class MyTaskParams { static class MyTaskParams {
Integer id; ImageEntry entry;
int width, height;
Result result; Result result;
Consumer<Integer> complete; Consumer<String> complete;
MyTaskParams(Integer id, Result result, Consumer<Integer> complete) { MyTaskParams(ImageEntry entry, int width, int height, Result result, Consumer<String> complete) {
this.id = id; this.entry = entry;
this.width = width;
this.height = height;
this.result = result; this.result = result;
this.complete = complete; this.complete = complete;
} }
@ -217,26 +185,40 @@ class BitmapWorkerTask extends AsyncTask<BitmapWorkerTask.MyTaskParams, Void, Bi
} }
} }
private ContentResolver cr; @SuppressLint("StaticFieldLeak")
private Activity activity;
BitmapWorkerTask(ContentResolver cr) { BitmapWorkerTask(Activity activity) {
this.cr = cr; this.activity = activity;
} }
@Override @Override
protected MyTaskResult doInBackground(MyTaskParams... params) { protected MyTaskResult doInBackground(MyTaskParams... params) {
MyTaskParams p = params[0]; MyTaskParams p = params[0];
ImageEntry entry = p.entry;
byte[] data = null; byte[] data = null;
if (!this.isCancelled()) { if (!this.isCancelled()) {
Log.d(LOG_TAG, "getThumbnail with id=" + p.id + "(called)"); Log.d(LOG_TAG, "getThumbnail with uri=" + entry.getUri() + "(called)");
Bitmap bmp = MediaStore.Images.Thumbnails.getThumbnail(cr, p.id, MediaStore.Images.Thumbnails.MINI_KIND, null); // add signature to ignore cache for images which got modified but kept the same URI
if (bmp != null) { Key signature = new ObjectKey("" + entry.getDateModifiedSecs() + entry.getWidth() + entry.getOrientationDegrees());
ByteArrayOutputStream stream = new ByteArrayOutputStream(); FutureTarget<Bitmap> target = Glide.with(activity)
bmp.compress(Bitmap.CompressFormat.JPEG, 90, stream); .asBitmap()
data = stream.toByteArray(); .load(entry.getUri())
.signature(signature)
.submit(p.width, p.height);
try {
Bitmap bmp = target.get();
if (bmp != null) {
ByteArrayOutputStream stream = new ByteArrayOutputStream();
bmp.compress(Bitmap.CompressFormat.JPEG, 90, stream);
data = stream.toByteArray();
}
} catch (Exception e) {
e.printStackTrace();
} }
Glide.with(activity).clear(target);
} else { } else {
Log.d(LOG_TAG, "getThumbnail with id=" + p.id + "(cancelled)"); Log.d(LOG_TAG, "getThumbnail with uri=" + entry.getUri() + "(cancelled)");
} }
return new MyTaskResult(p, data); return new MyTaskResult(p, data);
} }
@ -244,11 +226,11 @@ class BitmapWorkerTask extends AsyncTask<BitmapWorkerTask.MyTaskParams, Void, Bi
@Override @Override
protected void onPostExecute(MyTaskResult result) { protected void onPostExecute(MyTaskResult result) {
MethodChannel.Result r = result.params.result; MethodChannel.Result r = result.params.result;
result.params.complete.accept(result.params.id); result.params.complete.accept(result.params.entry.getUri().toString());
if (result.data != null) { if (result.data != null) {
r.success(result.data); r.success(result.data);
} else { } else {
r.error("getthumbnail-null", "failed to get thumbnail for id=" + result.params.id, null); r.error("getthumbnail-null", "failed to get thumbnail for uri=" + result.params.entry.getUri(), null);
} }
} }
} }

View file

@ -4,7 +4,6 @@ import android.content.ContentUris;
import android.content.Context; import android.content.Context;
import android.net.Uri; import android.net.Uri;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.text.format.DateUtils; import android.text.format.DateUtils;
@ -58,11 +57,27 @@ public class ImageEntry {
init(); init();
} }
public ImageEntry(Map map) {
this(
(String) map.get("path"),
toLong(map.get("contentId")),
(String) map.get("mimeType"),
(int) map.get("width"),
(int) map.get("height"),
(int) map.get("orientationDegrees"),
toLong(map.get("sizeBytes")),
(String) map.get("title"),
toLong(map.get("dateModifiedSecs")),
toLong(map.get("sourceDateTakenMillis")),
(String) map.get("bucketDisplayName"),
toLong(map.get("durationMillis"))
);
}
public static Map toMap(ImageEntry entry) { public static Map toMap(ImageEntry entry) {
return new HashMap<String, Object>() {{ return new HashMap<String, Object>() {{
put("path", entry.path); put("path", entry.path);
put("contentId", entry.contentId); put("contentId", entry.contentId);
put("uri", entry.uri);
put("mimeType", entry.mimeType); put("mimeType", entry.mimeType);
put("width", entry.width); put("width", entry.width);
put("height", entry.height); put("height", entry.height);
@ -73,6 +88,8 @@ public class ImageEntry {
put("sourceDateTakenMillis", entry.sourceDateTakenMillis); put("sourceDateTakenMillis", entry.sourceDateTakenMillis);
put("bucketDisplayName", entry.bucketDisplayName); put("bucketDisplayName", entry.bucketDisplayName);
put("durationMillis", entry.durationMillis); put("durationMillis", entry.durationMillis);
//
put("uri", entry.getUri().toString());
}}; }};
} }
@ -174,4 +191,11 @@ public class ImageEntry {
public void setTitle(String title) { public void setTitle(String title) {
this.title = title; this.title = title;
} }
// convenience method
private static long toLong(Object o) {
if (o instanceof Integer) return Long.valueOf((Integer)o);
return (long)o;
}
} }

View file

@ -5,7 +5,7 @@ buildscript {
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:3.4.1' classpath 'com.android.tools.build:gradle:3.4.2'
} }
} }

View file

@ -2,15 +2,15 @@ import 'package:aves/thumbnail.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ImageFullscreenPage extends StatelessWidget { class ImageFullscreenPage extends StatelessWidget {
final int id; final Map entry;
ImageFullscreenPage({this.id}); ImageFullscreenPage({this.entry});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var width = MediaQuery.of(context).size.width; var width = MediaQuery.of(context).size.width;
return Thumbnail( return Thumbnail(
id: id, entry: entry,
extent: width, extent: width,
); );
} }

View file

@ -25,6 +25,8 @@ class MyApp extends StatelessWidget {
class ImageFetcher { class ImageFetcher {
static const platform = const MethodChannel('deckers.thibault.aves/mediastore'); static const platform = const MethodChannel('deckers.thibault.aves/mediastore');
static double devicePixelRatio;
static Future<List> getImages() async { static Future<List> getImages() async {
try { try {
final result = await platform.invokeMethod('getImages'); final result = await platform.invokeMethod('getImages');
@ -35,10 +37,12 @@ class ImageFetcher {
return []; return [];
} }
static Future<Uint8List> getThumbnail(int id) async { static Future<Uint8List> getThumbnail(Map entry, double width, double height) async {
try { try {
final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{ final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{
'id': id, 'entry': entry,
'width': (width * devicePixelRatio).round(),
'height': (height * devicePixelRatio).round(),
}); });
return result as Uint8List; return result as Uint8List;
} on PlatformException catch (e) { } on PlatformException catch (e) {
@ -47,10 +51,10 @@ class ImageFetcher {
return Uint8List(0); return Uint8List(0);
} }
static cancelGetThumbnail(int id) async { static cancelGetThumbnail(String uri) async {
try { try {
await platform.invokeMethod('cancelGetThumbnail', <String, dynamic>{ await platform.invokeMethod('cancelGetThumbnail', <String, dynamic>{
'id': id, 'uri': uri,
}); });
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('failed with exception=${e.message}'); debugPrint('failed with exception=${e.message}');
@ -76,13 +80,17 @@ class _MyHomePageState extends State<MyHomePage> {
super.initState(); super.initState();
imageCache.maximumSizeBytes = 100 * 1024 * 1024; imageCache.maximumSizeBytes = 100 * 1024 * 1024;
imageLoader = ImageFetcher.getImages(); imageLoader = ImageFetcher.getImages();
WidgetsBinding.instance.addPostFrameCallback((duration) {
ImageFetcher.devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
debugPrint('$runtimeType devicePixelRatio=${ImageFetcher.devicePixelRatio}');
});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var columnCount = 4; var columnCount = 4;
var spacing = 1.0; var extent = MediaQuery.of(context).size.width / columnCount;
var extent = (MediaQuery.of(context).size.width - spacing * (columnCount - 1)) / columnCount;
debugPrint('MediaQuery.of(context).size=${MediaQuery.of(context).size} extent=$extent'); debugPrint('MediaQuery.of(context).size=${MediaQuery.of(context).size} extent=$extent');
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@ -112,16 +120,13 @@ class _MyHomePageState extends State<MyHomePage> {
controller: scrollController, controller: scrollController,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: extent, maxCrossAxisExtent: extent,
mainAxisSpacing: spacing,
crossAxisSpacing: spacing,
), ),
itemBuilder: (gridContext, index) { itemBuilder: (gridContext, index) {
var imageEntryMap = imageEntryList[index] as Map; var imageEntryMap = imageEntryList[index] as Map;
var contentId = imageEntryMap['contentId'];
return GestureDetector( return GestureDetector(
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
MaterialPageRoute(builder: (context) => ImageFullscreenPage(id: contentId)), MaterialPageRoute(builder: (context) => ImageFullscreenPage(entry: imageEntryMap)),
), ),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -131,7 +136,7 @@ class _MyHomePageState extends State<MyHomePage> {
), ),
), ),
child: Thumbnail( child: Thumbnail(
id: contentId, entry: imageEntryMap,
extent: extent, extent: extent,
), ),
), ),

6
lib/mime_types.dart Normal file
View file

@ -0,0 +1,6 @@
class MimeTypes {
static const String MIME_VIDEO = "video";
static const String MIME_JPEG = "image/jpeg";
static const String MIME_PNG = "image/png";
static const String MIME_GIF = "image/gif";
}

View file

@ -1,13 +1,14 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/main.dart'; import 'package:aves/main.dart';
import 'package:aves/mime_types.dart';
import 'package:flutter/material.dart';
import 'package:transparent_image/transparent_image.dart'; import 'package:transparent_image/transparent_image.dart';
import 'package:flutter/material.dart';
class Thumbnail extends StatefulWidget { class Thumbnail extends StatefulWidget {
Thumbnail({Key key, @required this.id, @required this.extent}) : super(key: key); Thumbnail({Key key, @required this.entry, @required this.extent}) : super(key: key);
final int id; final Map entry;
final double extent; final double extent;
@override @override
@ -20,29 +21,48 @@ class ThumbnailState extends State<Thumbnail> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
loader = ImageFetcher.getThumbnail(widget.id); loader = ImageFetcher.getThumbnail(widget.entry, widget.extent, widget.extent);
} }
@override @override
void dispose() { void dispose() {
ImageFetcher.cancelGetThumbnail(widget.id); ImageFetcher.cancelGetThumbnail(widget.entry['uri']);
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
String mimeType = widget.entry['mimeType'];
var isVideo = mimeType.startsWith(MimeTypes.MIME_VIDEO);
var isGif = mimeType == MimeTypes.MIME_GIF;
var iconSize = widget.extent / 4;
return FutureBuilder( return FutureBuilder(
future: loader, future: loader,
builder: (futureContext, AsyncSnapshot<Uint8List> snapshot) { builder: (futureContext, AsyncSnapshot<Uint8List> snapshot) {
var ready = snapshot.connectionState == ConnectionState.done && !snapshot.hasError; var ready = snapshot.connectionState == ConnectionState.done && !snapshot.hasError;
Uint8List bytes = ready ? snapshot.data : kTransparentImage; Uint8List bytes = ready ? snapshot.data : kTransparentImage;
return Hero( return Hero(
tag: widget.id, tag: widget.entry['uri'],
child: Image.memory( child: Stack(
bytes, alignment: AlignmentDirectional.bottomStart,
width: widget.extent, children: [
height: widget.extent, Image.memory(
fit: BoxFit.cover, bytes,
width: widget.extent,
height: widget.extent,
fit: BoxFit.cover,
),
if (isVideo)
Icon(
Icons.play_circle_outline,
size: iconSize,
),
if (isGif)
Icon(
Icons.gif,
size: iconSize,
),
],
), ),
); );
}); });

View file

@ -14,7 +14,7 @@ description: A new Flutter application.
version: 1.0.0+1 version: 1.0.0+1
environment: environment:
sdk: ">=2.1.0 <3.0.0" sdk: ">=2.2.2 <3.0.0"
dependencies: dependencies:
flutter: flutter: