load video embedded thumbnail

This commit is contained in:
Thibault Deckers 2019-07-30 23:26:55 +09:00
parent 8f94af28d3
commit 8c265b6479
15 changed files with 353 additions and 211 deletions

View file

@ -3,7 +3,8 @@ package deckers.thibault.aves.channelhandlers;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.support.annotation.NonNull;
import androidx.annotation.NonNull;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;

View file

@ -6,7 +6,8 @@ import android.app.AlertDialog;
import android.content.Intent;
import android.net.Uri;
import android.provider.Settings;
import android.support.annotation.NonNull;
import androidx.annotation.NonNull;
import com.karumi.dexter.Dexter;
import com.karumi.dexter.PermissionToken;

View file

@ -8,12 +8,15 @@ import android.util.Log;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.Key;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.signature.ObjectKey;
import java.io.ByteArrayOutputStream;
import java.util.function.Consumer;
import deckers.thibault.aves.decoder.VideoThumbnail;
import deckers.thibault.aves.model.ImageEntry;
import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.MethodChannel;
@ -61,11 +64,28 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
if (!this.isCancelled()) {
// 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())
RequestOptions options = new RequestOptions()
.signature(signature)
.submit(p.width, p.height);
.override(p.width, p.height);
FutureTarget<Bitmap> target;
if (entry.isVideo()) {
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE);
target = Glide.with(activity)
.asBitmap()
.apply(options)
.load(new VideoThumbnail(activity, entry.getUri()))
.signature(signature)
.submit(p.width, p.height);
} else {
target = Glide.with(activity)
.asBitmap()
.apply(options)
.load(entry.getUri())
.signature(signature)
.submit(p.width, p.height);
}
try {
Bitmap bmp = target.get();
if (bmp != null) {

View file

@ -1,6 +1,6 @@
package deckers.thibault.aves.channelhandlers;
import android.support.annotation.NonNull;
import androidx.annotation.NonNull;
import com.adobe.xmp.XMPException;
import com.adobe.xmp.XMPIterator;

View file

@ -0,0 +1,22 @@
package deckers.thibault.aves.decoder;
import android.content.Context;
import android.net.Uri;
public class VideoThumbnail {
private Context mContext;
private Uri mUri;
public VideoThumbnail(Context context, Uri uri) {
mContext = context;
mUri = uri;
}
public Context getContext() {
return mContext;
}
Uri getUri() {
return mUri;
}
}

View file

@ -0,0 +1,67 @@
package deckers.thibault.aves.decoder;
import android.graphics.Bitmap;
import android.media.MediaMetadataRetriever;
import androidx.annotation.NonNull;
import com.bumptech.glide.Priority;
import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.data.DataFetcher;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
class VideoThumbnailFetcher implements DataFetcher<InputStream> {
private final VideoThumbnail model;
VideoThumbnailFetcher(VideoThumbnail model) {
this.model = model;
}
@Override
public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
try {
retriever.setDataSource(model.getContext(), model.getUri());
byte[] picture = retriever.getEmbeddedPicture();
if (picture != null) {
callback.onDataReady(new ByteArrayInputStream(picture));
} else {
// not ideal: bitmap -> byte[] -> bitmap
// but simple fallback and we cache result
Bitmap bitmap = retriever.getFrameAtTime();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.PNG, 0, bos);
callback.onDataReady(new ByteArrayInputStream(bos.toByteArray()));
}
} catch (Exception ex) {
callback.onLoadFailed(ex);
} finally {
retriever.release();
}
}
@Override
public void cleanup() {
// already cleaned up in loadData and ByteArrayInputStream will be GC'd
}
@Override
public void cancel() {
// cannot cancel
}
@NonNull
@Override
public Class<InputStream> getDataClass() {
return InputStream.class;
}
@NonNull
@Override
public DataSource getDataSource() {
return DataSource.LOCAL;
}
}

View file

@ -0,0 +1,33 @@
package deckers.thibault.aves.decoder;
import android.content.Context;
import androidx.annotation.NonNull;
import com.bumptech.glide.Glide;
import com.bumptech.glide.GlideBuilder;
import com.bumptech.glide.Registry;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.module.AppGlideModule;
import com.bumptech.glide.request.RequestOptions;
import java.io.InputStream;
@GlideModule
public class VideoThumbnailGlideModule extends AppGlideModule {
@Override
public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) {
builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565));
}
@Override
public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
registry.append(VideoThumbnail.class, InputStream.class, new VideoThumbnailLoader.Factory());
}
@Override
public boolean isManifestParsingEnabled() {
return false;
}
}

View file

@ -0,0 +1,37 @@
package deckers.thibault.aves.decoder;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.bumptech.glide.load.Options;
import com.bumptech.glide.load.model.ModelLoader;
import com.bumptech.glide.load.model.ModelLoaderFactory;
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
import com.bumptech.glide.signature.ObjectKey;
import java.io.InputStream;
class VideoThumbnailLoader implements ModelLoader<VideoThumbnail, InputStream> {
@Nullable
@Override
public LoadData<InputStream> buildLoadData(@NonNull VideoThumbnail model, int width, int height, @NonNull Options options) {
return new LoadData<>(new ObjectKey(model.getUri()), new VideoThumbnailFetcher(model));
}
@Override
public boolean handles(@NonNull VideoThumbnail videoThumbnail) {
return true;
}
static class Factory implements ModelLoaderFactory<VideoThumbnail, InputStream> {
@NonNull
@Override
public ModelLoader<VideoThumbnail, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) {
return new VideoThumbnailLoader();
}
@Override
public void teardown() {
}
}
}

View file

@ -1,8 +1,8 @@
package deckers.thibault.aves.model;
import android.net.Uri;
import android.support.annotation.Nullable;
import android.text.format.DateUtils;
import androidx.annotation.Nullable;
import java.io.File;
import java.util.HashMap;
@ -21,14 +21,11 @@ public class ImageEntry {
private String title, bucketDisplayName;
private long dateModifiedSecs, sourceDateTakenMillis;
private long durationMillis;
// derived
private boolean isVideo;
public ImageEntry(Uri uri, String mimeType) {
this.contentId = -1;
this.uri = uri;
this.mimeType = mimeType;
init();
}
// uri: content provider uri
@ -48,7 +45,6 @@ public class ImageEntry {
this.sourceDateTakenMillis = dateTakenMillis;
this.bucketDisplayName = bucketDisplayName;
this.durationMillis = durationMillis;
init();
}
public ImageEntry(Map map) {
@ -87,10 +83,6 @@ public class ImageEntry {
}};
}
private void init() {
isVideo = mimeType.startsWith(Constants.MIME_VIDEO);
}
public Uri getUri() {
return uri;
}
@ -105,7 +97,7 @@ public class ImageEntry {
}
public boolean isVideo() {
return isVideo;
return mimeType.startsWith(Constants.MIME_VIDEO);
}
public boolean isEditable() {
@ -132,56 +124,14 @@ public class ImageEntry {
return orientationDegrees;
}
public long getSizeBytes() {
return sizeBytes;
}
public String getTitle() {
return title;
}
public long getDateModifiedSecs() {
return dateModifiedSecs;
}
String getBucketDisplayName() {
return bucketDisplayName;
}
public String getDuration() {
return DateUtils.formatElapsedTime(durationMillis / 1000);
}
public int getMegaPixels() {
return Math.round((width * height) / 1000000f);
}
// setters
public void setContentId(long contentId) {
this.contentId = contentId;
}
public void setPath(String path) {
this.path = path;
}
public void setSizeBytes(long sizeBytes) {
this.sizeBytes = sizeBytes;
}
public void setDateModifiedSecs(long dateModifiedSecs) {
this.dateModifiedSecs = dateModifiedSecs;
}
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;
if (o instanceof Integer) return Long.valueOf((Integer) o);
return (long) o;
}
}

View file

@ -8,7 +8,6 @@ import android.provider.MediaStore;
import android.util.Log;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import deckers.thibault.aves.model.ImageEntry;

View file

@ -32,6 +32,7 @@ import android.provider.DocumentsContract;
import android.provider.MediaStore;
import android.text.TextUtils;
import android.webkit.MimeTypeMap;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
@ -40,172 +41,174 @@ import java.io.OutputStream;
public class FileUtils {
public String getPathFromUri(final Context context, final Uri uri) {
String path = getPathFromLocalUri(context, uri);
if (path == null) {
path = getPathFromRemoteUri(context, uri);
public String getPathFromUri(final Context context, final Uri uri) {
String path = getPathFromLocalUri(context, uri);
if (path == null) {
path = getPathFromRemoteUri(context, uri);
}
return path;
}
return path;
}
@SuppressLint("NewApi")
private String getPathFromLocalUri(final Context context, final Uri uri) {
final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
@SuppressLint("NewApi")
private String getPathFromLocalUri(final Context context, final Uri uri) {
final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) {
if (isExternalStorageDocument(uri)) {
final String docId = DocumentsContract.getDocumentId(uri);
final String[] split = docId.split(":");
final String type = split[0];
if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) {
if (isExternalStorageDocument(uri)) {
final String docId = DocumentsContract.getDocumentId(uri);
final String[] split = docId.split(":");
final String type = split[0];
if ("primary".equalsIgnoreCase(type)) {
return Environment.getExternalStorageDirectory() + "/" + split[1];
}
} else if (isDownloadsDocument(uri)) {
final String id = DocumentsContract.getDocumentId(uri);
if ("primary".equalsIgnoreCase(type)) {
return Environment.getExternalStorageDirectory() + "/" + split[1];
}
} else if (isDownloadsDocument(uri)) {
final String id = DocumentsContract.getDocumentId(uri);
if (!TextUtils.isEmpty(id)) {
try {
final Uri contentUri =
ContentUris.withAppendedId(
Uri.parse(Environment.DIRECTORY_DOWNLOADS), Long.valueOf(id));
return getDataColumn(context, contentUri, null, null);
} catch (NumberFormatException e) {
return null;
}
if (!TextUtils.isEmpty(id)) {
try {
final Uri contentUri =
ContentUris.withAppendedId(
Uri.parse(Environment.DIRECTORY_DOWNLOADS), Long.valueOf(id));
return getDataColumn(context, contentUri, null, null);
} catch (NumberFormatException e) {
return null;
}
}
} else if (isMediaDocument(uri)) {
final String docId = DocumentsContract.getDocumentId(uri);
final String[] split = docId.split(":");
final String type = split[0];
Uri contentUri = null;
if ("image".equals(type)) {
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
} else if ("video".equals(type)) {
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
} else if ("audio".equals(type)) {
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
}
final String selection = "_id=?";
final String[] selectionArgs = new String[]{split[1]};
return getDataColumn(context, contentUri, selection, selectionArgs);
}
} else if ("content".equalsIgnoreCase(uri.getScheme())) {
// Return the remote address
if (isGooglePhotosUri(uri)) {
return null;
}
return getDataColumn(context, uri, null, null);
} else if ("file".equalsIgnoreCase(uri.getScheme())) {
return uri.getPath();
}
} else if (isMediaDocument(uri)) {
final String docId = DocumentsContract.getDocumentId(uri);
final String[] split = docId.split(":");
final String type = split[0];
Uri contentUri = null;
if ("image".equals(type)) {
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
} else if ("video".equals(type)) {
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
} else if ("audio".equals(type)) {
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
}
final String selection = "_id=?";
final String[] selectionArgs = new String[] {split[1]};
return getDataColumn(context, contentUri, selection, selectionArgs);
}
} else if ("content".equalsIgnoreCase(uri.getScheme())) {
// Return the remote address
if (isGooglePhotosUri(uri)) {
return null;
}
return getDataColumn(context, uri, null, null);
} else if ("file".equalsIgnoreCase(uri.getScheme())) {
return uri.getPath();
}
return null;
}
private static String getDataColumn(
Context context, Uri uri, String selection, String[] selectionArgs) {
private static String getDataColumn(
Context context, Uri uri, String selection, String[] selectionArgs) {
final String column = "_data";
final String[] projection = {column};
try (Cursor cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null)) {
if (cursor != null && cursor.moveToFirst()) {
final int column_index = cursor.getColumnIndex(column);
final String column = "_data";
final String[] projection = {column};
try (Cursor cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null)) {
if (cursor != null && cursor.moveToFirst()) {
final int column_index = cursor.getColumnIndex(column);
//yandex.disk and dropbox do not have _data column
if (column_index == -1) {
return null;
}
//yandex.disk and dropbox do not have _data column
if (column_index == -1) {
return null;
return cursor.getString(column_index);
}
}
return null;
}
private static String getPathFromRemoteUri(final Context context, final Uri uri) {
// The code below is why Java now has try-with-resources and the Files utility.
File file = null;
InputStream inputStream = null;
OutputStream outputStream = null;
boolean success = false;
try {
String extension = getImageExtension(context, uri);
inputStream = context.getContentResolver().openInputStream(uri);
file = File.createTempFile("image_picker", extension, context.getCacheDir());
outputStream = new FileOutputStream(file);
if (inputStream != null) {
copy(inputStream, outputStream);
success = true;
}
} catch (IOException ignored) {
} finally {
try {
if (inputStream != null) inputStream.close();
} catch (IOException ignored) {
}
try {
if (outputStream != null) outputStream.close();
} catch (IOException ignored) {
// If closing the output stream fails, we cannot be sure that the
// target file was written in full. Flushing the stream merely moves
// the bytes into the OS, not necessarily to the file.
success = false;
}
}
return success ? file.getPath() : null;
}
/**
* @return extension of image with dot, or default .jpg if it none.
*/
private static String getImageExtension(Context context, Uri uriImage) {
String extension = null;
try (Cursor cursor = context
.getContentResolver()
.query(uriImage, new String[]{MediaStore.MediaColumns.MIME_TYPE}, null, null, null)) {
if (cursor != null && cursor.moveToNext()) {
String mimeType = cursor.getString(0);
extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
}
}
return cursor.getString(column_index);
}
}
return null;
}
private static String getPathFromRemoteUri(final Context context, final Uri uri) {
// The code below is why Java now has try-with-resources and the Files utility.
File file = null;
InputStream inputStream = null;
OutputStream outputStream = null;
boolean success = false;
try {
String extension = getImageExtension(context, uri);
inputStream = context.getContentResolver().openInputStream(uri);
file = File.createTempFile("image_picker", extension, context.getCacheDir());
outputStream = new FileOutputStream(file);
if (inputStream != null) {
copy(inputStream, outputStream);
success = true;
}
} catch (IOException ignored) {
} finally {
try {
if (inputStream != null) inputStream.close();
} catch (IOException ignored) {
}
try {
if (outputStream != null) outputStream.close();
} catch (IOException ignored) {
// If closing the output stream fails, we cannot be sure that the
// target file was written in full. Flushing the stream merely moves
// the bytes into the OS, not necessarily to the file.
success = false;
}
}
return success ? file.getPath() : null;
}
/** @return extension of image with dot, or default .jpg if it none. */
private static String getImageExtension(Context context, Uri uriImage) {
String extension = null;
try (Cursor cursor = context
.getContentResolver()
.query(uriImage, new String[]{MediaStore.MediaColumns.MIME_TYPE}, null, null, null)) {
if (cursor != null && cursor.moveToNext()) {
String mimeType = cursor.getString(0);
extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
}
if (extension == null) {
//default extension for matches the previous behavior of the plugin
extension = "jpg";
}
return "." + extension;
}
if (extension == null) {
//default extension for matches the previous behavior of the plugin
extension = "jpg";
private static void copy(InputStream in, OutputStream out) throws IOException {
final byte[] buffer = new byte[4 * 1024];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
out.flush();
}
return "." + extension;
}
private static void copy(InputStream in, OutputStream out) throws IOException {
final byte[] buffer = new byte[4 * 1024];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
private static boolean isExternalStorageDocument(Uri uri) {
return "com.android.externalstorage.documents".equals(uri.getAuthority());
}
out.flush();
}
private static boolean isExternalStorageDocument(Uri uri) {
return "com.android.externalstorage.documents".equals(uri.getAuthority());
}
private static boolean isDownloadsDocument(Uri uri) {
return "com.android.providers.downloads.documents".equals(uri.getAuthority());
}
private static boolean isDownloadsDocument(Uri uri) {
return "com.android.providers.downloads.documents".equals(uri.getAuthority());
}
private static boolean isMediaDocument(Uri uri) {
return "com.android.providers.media.documents".equals(uri.getAuthority());
}
private static boolean isMediaDocument(Uri uri) {
return "com.android.providers.media.documents".equals(uri.getAuthority());
}
private static boolean isGooglePhotosUri(Uri uri) {
return "com.google.android.apps.photos.contentprovider".equals(uri.getAuthority());
}
private static boolean isGooglePhotosUri(Uri uri) {
return "com.google.android.apps.photos.contentprovider".equals(uri.getAuthority());
}
}

View file

@ -1 +1,4 @@
org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
android.enableJetifier=true

View file

@ -16,7 +16,7 @@ class ImageDecodeService {
}
static Future<Uint8List> getImageBytes(ImageEntry entry, int width, int height) async {
debugPrint('getImageBytes with uri=${entry.uri}');
debugPrint('getImageBytes with path=${entry.path} contentId=${entry.contentId}');
if (width > 0 && height > 0) {
try {
final result = await platform.invokeMethod('getImageBytes', <String, dynamic>{

View file

@ -1,3 +1,5 @@
import 'mime_types.dart';
class ImageEntry {
String uri;
String path;
@ -65,6 +67,8 @@ class ImageEntry {
};
}
bool get isVideo => mimeType.startsWith(MimeTypes.MIME_VIDEO);
int getMegaPixels() {
return ((width * height) / 1000000).round();
}

View file

@ -38,6 +38,8 @@ class InfoPageState extends State<InfoPage> {
@override
Widget build(BuildContext context) {
final date = entry.getBestDate();
final dateText = '${DateFormat.yMMMd().format(date)} ${DateFormat.Hm().format(date)}';
final resolutionText = '${entry.width} × ${entry.height}${entry.isVideo ? '': ' (${entry.getMegaPixels()} MP)'}';
return Scaffold(
appBar: AppBar(
leading: IconButton(
@ -72,8 +74,8 @@ class InfoPageState extends State<InfoPage> {
children: [
SectionRow('File'),
InfoRow('Title', entry.title),
InfoRow('Date', '${DateFormat.yMMMd().format(date)} ${DateFormat.Hm().format(date)}'),
InfoRow('Resolution', '${entry.width} × ${entry.height} (${entry.getMegaPixels()} MP)'),
InfoRow('Date', dateText),
InfoRow('Resolution', resolutionText),
InfoRow('Size', formatFilesize(entry.sizeBytes)),
InfoRow('Path', entry.path),
SectionRow('Metadata'),