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 {
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'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'

View file

@ -1,24 +1,21 @@
package deckers.thibault.aves;
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ContentResolver;
import android.content.DialogInterface;
import android.content.Intent;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.provider.MediaStore;
import android.provider.Settings;
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.PermissionToken;
import com.karumi.dexter.listener.PermissionDeniedResponse;
@ -27,47 +24,42 @@ import com.karumi.dexter.listener.PermissionRequest;
import com.karumi.dexter.listener.single.PermissionListener;
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.List;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
import deckers.thibault.aves.model.ImageEntry;
import deckers.thibault.aves.model.provider.MediaStoreImageProvider;
import deckers.thibault.aves.utils.Utils;
import io.flutter.app.FlutterActivity;
import io.flutter.plugins.GeneratedPluginRegistrant;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.Result;
import io.flutter.plugins.GeneratedPluginRegistrant;
class ThumbnailFetcher {
private ContentResolver contentResolver;
private SparseArray<AsyncTask> taskMap = new SparseArray<>();
private Activity activity;
private HashMap<String, AsyncTask> taskMap = new HashMap<>();
ThumbnailFetcher(ContentResolver contentResolver) {
this.contentResolver = contentResolver;
ThumbnailFetcher(Activity activity) {
this.activity = activity;
}
void fetch (Integer id, Result result) {
AsyncTask task = new BitmapWorkerTask(contentResolver).execute(new BitmapWorkerTask.MyTaskParams(id, result, this::complete));
taskMap.append(id, task);
void fetch(ImageEntry entry, Integer width, Integer height, Result result) {
BitmapWorkerTask.MyTaskParams params = new BitmapWorkerTask.MyTaskParams(entry, width, height, result, this::complete);
AsyncTask task = new BitmapWorkerTask(activity).execute(params);
taskMap.put(entry.getUri().toString(), task);
}
void cancel(Integer id) {
AsyncTask task = taskMap.get(id, null);
void cancel(String uri) {
AsyncTask task = taskMap.get(uri);
if (task != null) task.cancel(true);
taskMap.remove(id);
taskMap.remove(uri);
}
void complete(Integer id) {
taskMap.remove(id);
private void complete(String uri) {
taskMap.remove(uri);
}
}
@ -81,7 +73,7 @@ public class MainActivity extends FlutterActivity {
super.onCreate(savedInstanceState);
GeneratedPluginRegistrant.registerWith(this);
thumbnailFetcher = new ThumbnailFetcher(getContentResolver());
thumbnailFetcher = new ThumbnailFetcher(this);
new MethodChannel(getFlutterView(), CHANNEL).setMethodCallHandler(
(call, result) -> {
switch (call.method) {
@ -89,13 +81,16 @@ public class MainActivity extends FlutterActivity {
getPermissionResult(result, this);
break;
case "getThumbnail": {
Integer id = call.argument("id");
thumbnailFetcher.fetch(id, result);
Map map = call.argument("entry");
Integer width = call.argument("width");
Integer height = call.argument("height");
ImageEntry entry = new ImageEntry(map);
thumbnailFetcher.fetch(entry, width, height, result);
break;
}
case "cancelGetThumbnail": {
Integer id = call.argument("id");
thumbnailFetcher.cancel(id);
String uri = call.argument("uri");
thumbnailFetcher.cancel(uri);
result.success(null);
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) {
Dexter.withActivity(activity)
.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.setTitle("We need this permission");
builder.setCancelable(false);
builder.setPositiveButton("OK", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int id) {
dialog.cancel();
Intent intent = new Intent();
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
Uri uri = Uri.fromParts("package", activity.getPackageName(), null);
intent.setData(uri);
activity.startActivity(intent);
}
});
builder.setNegativeButton("Cancel", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int id) {
dialog.cancel();
}
builder.setPositiveButton("OK", (dialog, id) -> {
dialog.cancel();
Intent intent = new Intent();
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
Uri uri = Uri.fromParts("package", activity.getPackageName(), null);
intent.setData(uri);
activity.startActivity(intent);
});
builder.setNegativeButton("Cancel", (dialog, id) -> dialog.cancel());
AlertDialog alert = builder.create();
alert.show();
}
@Override
public void onPermissionRationaleShouldBeShown(PermissionRequest permission, final PermissionToken token) {
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.setTitle("We need this permission");
builder.setCancelable(false);
builder.setPositiveButton("OK", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int id) {
dialog.cancel();
token.continuePermissionRequest();
}
builder.setPositiveButton("OK", (dialog, id) -> {
dialog.cancel();
token.continuePermissionRequest();
});
builder.setNegativeButton("Cancel", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int id) {
dialog.cancel();
token.cancelPermissionRequest();
}
builder.setNegativeButton("Cancel", (dialog, id) -> {
dialog.cancel();
token.cancelPermissionRequest();
});
AlertDialog alert = builder.create();
alert.show();
@ -196,12 +161,15 @@ class BitmapWorkerTask extends AsyncTask<BitmapWorkerTask.MyTaskParams, Void, Bi
private static final String LOG_TAG = Utils.createLogTag(BitmapWorkerTask.class);
static class MyTaskParams {
Integer id;
ImageEntry entry;
int width, height;
Result result;
Consumer<Integer> complete;
Consumer<String> complete;
MyTaskParams(Integer id, Result result, Consumer<Integer> complete) {
this.id = id;
MyTaskParams(ImageEntry entry, int width, int height, Result result, Consumer<String> complete) {
this.entry = entry;
this.width = width;
this.height = height;
this.result = result;
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) {
this.cr = cr;
BitmapWorkerTask(Activity activity) {
this.activity = activity;
}
@Override
protected MyTaskResult doInBackground(MyTaskParams... params) {
MyTaskParams p = params[0];
ImageEntry entry = p.entry;
byte[] data = null;
if (!this.isCancelled()) {
Log.d(LOG_TAG, "getThumbnail with id=" + p.id + "(called)");
Bitmap bmp = MediaStore.Images.Thumbnails.getThumbnail(cr, p.id, MediaStore.Images.Thumbnails.MINI_KIND, null);
if (bmp != null) {
ByteArrayOutputStream stream = new ByteArrayOutputStream();
bmp.compress(Bitmap.CompressFormat.JPEG, 90, stream);
data = stream.toByteArray();
Log.d(LOG_TAG, "getThumbnail with uri=" + entry.getUri() + "(called)");
// add signature to ignore cache for images which got modified but kept the same URI
Key signature = new ObjectKey("" + entry.getDateModifiedSecs() + entry.getWidth() + entry.getOrientationDegrees());
FutureTarget<Bitmap> target = Glide.with(activity)
.asBitmap()
.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 {
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);
}
@ -244,11 +226,11 @@ class BitmapWorkerTask extends AsyncTask<BitmapWorkerTask.MyTaskParams, Void, Bi
@Override
protected void onPostExecute(MyTaskResult 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) {
r.success(result.data);
} 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.net.Uri;
import android.provider.MediaStore;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.format.DateUtils;
@ -58,11 +57,27 @@ public class ImageEntry {
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) {
return new HashMap<String, Object>() {{
put("path", entry.path);
put("contentId", entry.contentId);
put("uri", entry.uri);
put("mimeType", entry.mimeType);
put("width", entry.width);
put("height", entry.height);
@ -73,6 +88,8 @@ public class ImageEntry {
put("sourceDateTakenMillis", entry.sourceDateTakenMillis);
put("bucketDisplayName", entry.bucketDisplayName);
put("durationMillis", entry.durationMillis);
//
put("uri", entry.getUri().toString());
}};
}
@ -174,4 +191,11 @@ public class ImageEntry {
public void setTitle(String 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 {
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';
class ImageFullscreenPage extends StatelessWidget {
final int id;
final Map entry;
ImageFullscreenPage({this.id});
ImageFullscreenPage({this.entry});
@override
Widget build(BuildContext context) {
var width = MediaQuery.of(context).size.width;
return Thumbnail(
id: id,
entry: entry,
extent: width,
);
}

View file

@ -25,6 +25,8 @@ class MyApp extends StatelessWidget {
class ImageFetcher {
static const platform = const MethodChannel('deckers.thibault.aves/mediastore');
static double devicePixelRatio;
static Future<List> getImages() async {
try {
final result = await platform.invokeMethod('getImages');
@ -35,10 +37,12 @@ class ImageFetcher {
return [];
}
static Future<Uint8List> getThumbnail(int id) async {
static Future<Uint8List> getThumbnail(Map entry, double width, double height) async {
try {
final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{
'id': id,
'entry': entry,
'width': (width * devicePixelRatio).round(),
'height': (height * devicePixelRatio).round(),
});
return result as Uint8List;
} on PlatformException catch (e) {
@ -47,10 +51,10 @@ class ImageFetcher {
return Uint8List(0);
}
static cancelGetThumbnail(int id) async {
static cancelGetThumbnail(String uri) async {
try {
await platform.invokeMethod('cancelGetThumbnail', <String, dynamic>{
'id': id,
'uri': uri,
});
} on PlatformException catch (e) {
debugPrint('failed with exception=${e.message}');
@ -76,13 +80,17 @@ class _MyHomePageState extends State<MyHomePage> {
super.initState();
imageCache.maximumSizeBytes = 100 * 1024 * 1024;
imageLoader = ImageFetcher.getImages();
WidgetsBinding.instance.addPostFrameCallback((duration) {
ImageFetcher.devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
debugPrint('$runtimeType devicePixelRatio=${ImageFetcher.devicePixelRatio}');
});
}
@override
Widget build(BuildContext context) {
var columnCount = 4;
var spacing = 1.0;
var extent = (MediaQuery.of(context).size.width - spacing * (columnCount - 1)) / columnCount;
var extent = MediaQuery.of(context).size.width / columnCount;
debugPrint('MediaQuery.of(context).size=${MediaQuery.of(context).size} extent=$extent');
return Scaffold(
appBar: AppBar(
@ -112,16 +120,13 @@ class _MyHomePageState extends State<MyHomePage> {
controller: scrollController,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: extent,
mainAxisSpacing: spacing,
crossAxisSpacing: spacing,
),
itemBuilder: (gridContext, index) {
var imageEntryMap = imageEntryList[index] as Map;
var contentId = imageEntryMap['contentId'];
return GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => ImageFullscreenPage(id: contentId)),
MaterialPageRoute(builder: (context) => ImageFullscreenPage(entry: imageEntryMap)),
),
child: Container(
decoration: BoxDecoration(
@ -131,7 +136,7 @@ class _MyHomePageState extends State<MyHomePage> {
),
),
child: Thumbnail(
id: contentId,
entry: imageEntryMap,
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 'package:aves/main.dart';
import 'package:aves/mime_types.dart';
import 'package:flutter/material.dart';
import 'package:transparent_image/transparent_image.dart';
import 'package:flutter/material.dart';
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;
@override
@ -20,29 +21,48 @@ class ThumbnailState extends State<Thumbnail> {
@override
void initState() {
super.initState();
loader = ImageFetcher.getThumbnail(widget.id);
loader = ImageFetcher.getThumbnail(widget.entry, widget.extent, widget.extent);
}
@override
void dispose() {
ImageFetcher.cancelGetThumbnail(widget.id);
ImageFetcher.cancelGetThumbnail(widget.entry['uri']);
super.dispose();
}
@override
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(
future: loader,
builder: (futureContext, AsyncSnapshot<Uint8List> snapshot) {
var ready = snapshot.connectionState == ConnectionState.done && !snapshot.hasError;
Uint8List bytes = ready ? snapshot.data : kTransparentImage;
return Hero(
tag: widget.id,
child: Image.memory(
bytes,
width: widget.extent,
height: widget.extent,
fit: BoxFit.cover,
tag: widget.entry['uri'],
child: Stack(
alignment: AlignmentDirectional.bottomStart,
children: [
Image.memory(
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
environment:
sdk: ">=2.1.0 <3.0.0"
sdk: ">=2.2.2 <3.0.0"
dependencies:
flutter: