added file image provider

This commit is contained in:
Thibault Deckers 2020-03-18 22:18:33 +09:00
parent ce878614cf
commit a5b47726ed
13 changed files with 348 additions and 299 deletions

View file

@ -74,7 +74,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
bitmap = getThumbnailBytesByMediaStore(p); bitmap = getThumbnailBytesByMediaStore(p);
} }
} else { } else {
Log.d(LOG_TAG, "getImageBytes with uri=" + p.entry.getUri() + " cancelled"); Log.d(LOG_TAG, "getImageBytes with uri=" + p.entry.uri + " cancelled");
} }
byte[] data = null; byte[] data = null;
if (bitmap != null) { if (bitmap != null) {
@ -95,16 +95,16 @@ 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.uri, new Size(width, height), null);
} catch (IOException e) { } catch (IOException e) {
Log.e(LOG_TAG, "failed to load thumbnail for uri=" + entry.getUri(), e); Log.e(LOG_TAG, "failed to load thumbnail for uri=" + entry.uri, e);
} }
return null; return null;
} }
private Bitmap getThumbnailBytesByMediaStore(Params params) { private Bitmap getThumbnailBytesByMediaStore(Params params) {
ImageEntry entry = params.entry; ImageEntry entry = params.entry;
long contentId = ContentUris.parseId(entry.getUri()); long contentId = ContentUris.parseId(entry.uri);
ContentResolver resolver = activity.getContentResolver(); ContentResolver resolver = activity.getContentResolver();
try { try {
@ -114,7 +114,7 @@ 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) {
Log.e(LOG_TAG, "failed to get thumbnail for uri=" + entry.getUri(), e); Log.e(LOG_TAG, "failed to get thumbnail for uri=" + entry.uri, e);
} }
return null; return null;
} }
@ -125,7 +125,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
int height = params.height; int height = params.height;
// add signature to ignore cache for images which got modified but kept the same URI // 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()); Key signature = new ObjectKey("" + entry.dateModifiedSecs + entry.width + entry.orientationDegrees);
RequestOptions options = new RequestOptions() RequestOptions options = new RequestOptions()
.signature(signature) .signature(signature)
.override(width, height); .override(width, height);
@ -136,14 +136,14 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
target = Glide.with(activity) target = Glide.with(activity)
.asBitmap() .asBitmap()
.apply(options) .apply(options)
.load(new VideoThumbnail(activity, entry.getUri())) .load(new VideoThumbnail(activity, entry.uri))
.signature(signature) .signature(signature)
.submit(width, height); .submit(width, height);
} else { } else {
target = Glide.with(activity) target = Glide.with(activity)
.asBitmap() .asBitmap()
.apply(options) .apply(options)
.load(entry.getUri()) .load(entry.uri)
.signature(signature) .signature(signature)
.submit(width, height); .submit(width, height);
} }
@ -151,7 +151,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
try { try {
return target.get(); return target.get();
} catch (InterruptedException e) { } catch (InterruptedException e) {
Log.d(LOG_TAG, "getImageBytes with uri=" + entry.getUri() + " interrupted"); Log.d(LOG_TAG, "getImageBytes with uri=" + entry.uri + " interrupted");
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
} }
@ -162,7 +162,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
@Override @Override
protected void onPostExecute(Result result) { protected void onPostExecute(Result result) {
MethodChannel.Result r = result.params.result; MethodChannel.Result r = result.params.result;
String uri = result.params.entry.getUri().toString(); String uri = result.params.entry.uri.toString();
result.params.complete.accept(uri); result.params.complete.accept(uri);
if (result.data != null) { if (result.data != null) {
r.success(result.data); r.success(result.data);

View file

@ -35,7 +35,7 @@ public class ImageDecodeTaskManager {
} }
void cancel(String uri) { void cancel(String uri) {
boolean removed = taskParamsQueue.removeIf(p -> uri.equals(p.entry.getUri().toString())); boolean removed = taskParamsQueue.removeIf(p -> uri.equals(p.entry.uri.toString()));
if (removed) Log.d(LOG_TAG, "cancelled uri=" + uri); if (removed) Log.d(LOG_TAG, "cancelled uri=" + uri);
} }

View file

@ -80,8 +80,10 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
private void getAllMetadata(MethodCall call, MethodChannel.Result result) { private void getAllMetadata(MethodCall call, MethodChannel.Result result) {
String path = call.argument("path"); String path = call.argument("path");
String uri = call.argument("uri"); String uri = call.argument("uri");
Map<String, Map<String, String>> metadataMap = new HashMap<>();
try (InputStream is = getInputStream(path, uri)) { try (InputStream is = getInputStream(path, uri)) {
Map<String, Map<String, String>> metadataMap = new HashMap<>();
Metadata metadata = ImageMetadataReader.readMetadata(is); Metadata metadata = ImageMetadataReader.readMetadata(is);
for (Directory dir : metadata.getDirectories()) { for (Directory dir : metadata.getDirectories()) {
if (dir.getTagCount() > 0) { if (dir.getTagCount() > 0) {
@ -165,8 +167,10 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
String mimeType = call.argument("mimeType"); String mimeType = call.argument("mimeType");
String path = call.argument("path"); String path = call.argument("path");
String uri = call.argument("uri"); String uri = call.argument("uri");
Map<String, Object> metadataMap = new HashMap<>();
try (InputStream is = getInputStream(path, uri)) { try (InputStream is = getInputStream(path, uri)) {
Map<String, Object> metadataMap = new HashMap<>();
if (!Constants.MIME_MP2T.equalsIgnoreCase(mimeType)) { if (!Constants.MIME_MP2T.equalsIgnoreCase(mimeType)) {
Metadata metadata = ImageMetadataReader.readMetadata(is); Metadata metadata = ImageMetadataReader.readMetadata(is);
@ -208,7 +212,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
} }
} }
if (isVideo(call.argument("mimeType"))) { if (isVideo(mimeType)) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever(); MediaMetadataRetriever retriever = new MediaMetadataRetriever();
try { try {
if (path != null) { if (path != null) {
@ -266,15 +270,17 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
} }
private void getOverlayMetadata(MethodCall call, MethodChannel.Result result) { private void getOverlayMetadata(MethodCall call, MethodChannel.Result result) {
String mimeType = call.argument("mimeType");
String path = call.argument("path");
String uri = call.argument("uri");
Map<String, String> metadataMap = new HashMap<>(); Map<String, String> metadataMap = new HashMap<>();
if (isVideo(call.argument("mimeType"))) { if (isVideo(mimeType)) {
result.success(metadataMap); result.success(metadataMap);
return; return;
} }
String path = call.argument("path");
String uri = call.argument("uri");
try (InputStream is = getInputStream(path, uri)) { try (InputStream is = getInputStream(path, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is); Metadata metadata = ImageMetadataReader.readMetadata(is);
ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class); ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class);

View file

@ -1,27 +1,49 @@
package deckers.thibault.aves.model; package deckers.thibault.aves.model;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.graphics.BitmapFactory;
import android.media.MediaMetadataRetriever;
import android.net.Uri; import android.net.Uri;
import android.os.Build;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.Metadata;
import com.drew.metadata.MetadataException;
import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.jpeg.JpegDirectory;
import com.drew.metadata.mp4.media.Mp4VideoDirectory;
import java.io.File; import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.TimeZone;
import deckers.thibault.aves.utils.Constants; import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.MetadataHelper;
import static deckers.thibault.aves.utils.MetadataHelper.getOrientationDegreesForExifCode;
public class ImageEntry { public class ImageEntry {
// from source public Uri uri; // content or file URI
private String path; // best effort to get local path from content providers public String path; // best effort to get local path
private Uri uri; // content URI
private String mimeType;
private int width, height, orientationDegrees;
private long sizeBytes;
private String title, bucketDisplayName;
private long dateModifiedSecs, sourceDateTakenMillis;
private long durationMillis;
// uri: content provider uri public String mimeType, title, bucketDisplayName;
// path: FileUtils.getPathFromUri(activity, itemUri) is useful (for Download, File, etc.) but is slower than directly using `MediaStore.MediaColumns.DATA` from the MediaStore query @Nullable
public Integer width, height, orientationDegrees;
@Nullable
public Long sizeBytes, dateModifiedSecs, sourceDateTakenMillis, durationMillis;
public ImageEntry() {
}
public ImageEntry(Map map) { public ImageEntry(Map map) {
this.uri = Uri.parse((String) map.get("uri")); this.uri = Uri.parse((String) map.get("uri"));
@ -38,49 +60,175 @@ public class ImageEntry {
this.durationMillis = toLong(map.get("durationMillis")); this.durationMillis = toLong(map.get("durationMillis"));
} }
public Uri getUri() { public Map<String, Object> toMap() {
return uri; return new HashMap<String, Object>() {{
put("uri", uri.toString());
put("path", path);
put("mimeType", mimeType);
put("width", width);
put("height", height);
put("orientationDegrees", orientationDegrees != null ? orientationDegrees : 0);
put("sizeBytes", sizeBytes);
put("title", title);
put("dateModifiedSecs", dateModifiedSecs);
put("sourceDateTakenMillis", sourceDateTakenMillis);
put("bucketDisplayName", bucketDisplayName);
put("durationMillis", durationMillis);
// only for map export
put("contentId", getContentId());
}};
} }
@Nullable private Long getContentId() {
public String getPath() { if (uri != null && ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) {
return path; try {
return ContentUris.parseId(uri);
} catch (NumberFormatException | UnsupportedOperationException e) {
// ignore when the ID is not a number
// e.g. content://com.sec.android.app.myfiles.FileProvider/device_storage/20200109_162621.jpg
}
}
return null;
}
public boolean hasSize() {
return width != null && width > 0 && height != null && height > 0;
} }
public String getFilename() { public String getFilename() {
return path == null ? null : new File(path).getName(); return path == null ? null : new File(path).getName();
} }
public boolean isImage() {
return mimeType.startsWith(Constants.MIME_IMAGE);
}
public boolean isVideo() { public boolean isVideo() {
return mimeType.startsWith(Constants.MIME_VIDEO); return mimeType.startsWith(Constants.MIME_VIDEO);
} }
public boolean isEditable() { // metadata retrieval
return path != null;
private InputStream getInputStream(Context context) throws FileNotFoundException {
// FileInputStream is faster than input stream from ContentResolver
return path != null ? new FileInputStream(path) : context.getContentResolver().openInputStream(uri);
} }
public boolean isGif() { // expects entry with: uri/path, mimeType
return Constants.MIME_GIF.equals(mimeType); // finds: width, height, orientation/rotation, date, title, duration
public ImageEntry fillPreCatalogMetadata(Context context) {
fillByMediaMetadataRetriever(context);
if (hasSize()) return this;
fillByMetadataExtractor(context);
if (hasSize()) return this;
fillByBitmapDecode(context);
return this;
} }
public String getMimeType() { // expects entry with: uri/path, mimeType
return mimeType; // finds: width, height, orientation/rotation, date, title, duration
private void fillByMediaMetadataRetriever(Context context) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
try {
retriever.setDataSource(context, uri);
String width = null, height = null, rotation = null, durationMillis = null;
if (isImage()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH);
height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT);
rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION);
}
} else if (isVideo()) {
width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH);
height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT);
rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
durationMillis = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
}
if (width != null) {
this.width = Integer.parseInt(width);
}
if (height != null) {
this.height = Integer.parseInt(height);
}
if (rotation != null) {
this.orientationDegrees = Integer.parseInt(rotation);
}
if (durationMillis != null) {
this.durationMillis = Long.parseLong(durationMillis);
}
String dateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE);
long dateMillis = MetadataHelper.parseVideoMetadataDate(dateString);
// some entries have an invalid default date (19040101T000000.000Z) that is before Epoch time
if (dateMillis > 0) {
this.sourceDateTakenMillis = dateMillis;
}
String title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE);
if (title != null) {
this.title = title;
}
} catch (Exception e) {
// ignore
} finally {
retriever.release();
}
} }
public int getWidth() { // expects entry with: uri/path, mimeType
return width; // finds: width, height, orientation, date
private void fillByMetadataExtractor(Context context) {
try (InputStream is = getInputStream(context)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
if (Constants.MIME_JPEG.equals(mimeType)) {
JpegDirectory jpegDir = metadata.getFirstDirectoryOfType(JpegDirectory.class);
if (jpegDir != null) {
if (jpegDir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) {
width = jpegDir.getInt(JpegDirectory.TAG_IMAGE_WIDTH);
}
if (jpegDir.containsTag(JpegDirectory.TAG_IMAGE_HEIGHT)) {
height = jpegDir.getInt(JpegDirectory.TAG_IMAGE_HEIGHT);
}
}
ExifIFD0Directory exifDir = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class);
if (exifDir != null) {
if (exifDir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
orientationDegrees = getOrientationDegreesForExifCode(exifDir.getInt(ExifIFD0Directory.TAG_ORIENTATION));
}
if (exifDir.containsTag(ExifIFD0Directory.TAG_DATETIME)) {
sourceDateTakenMillis = exifDir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime();
}
}
} else if (Constants.MIME_MP4.equals(mimeType)) {
Mp4VideoDirectory mp4VideoDir = metadata.getFirstDirectoryOfType(Mp4VideoDirectory.class);
if (mp4VideoDir != null) {
if (mp4VideoDir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) {
width = mp4VideoDir.getInt(Mp4VideoDirectory.TAG_WIDTH);
}
if (mp4VideoDir.containsTag(Mp4VideoDirectory.TAG_HEIGHT)) {
height = mp4VideoDir.getInt(Mp4VideoDirectory.TAG_HEIGHT);
}
}
}
} catch (IOException | ImageProcessingException | MetadataException e) {
// ignore
}
} }
public int getHeight() { // expects entry with: uri/path
return height; // finds: width, height
} private void fillByBitmapDecode(Context context) {
try (InputStream is = getInputStream(context)) {
public int getOrientationDegrees() { BitmapFactory.Options options = new BitmapFactory.Options();
return orientationDegrees; options.inJustDecodeBounds = true;
} BitmapFactory.decodeStream(is, null, options);
width = options.outWidth;
public long getDateModifiedSecs() { height = options.outHeight;
return dateModifiedSecs; } catch (IOException e) {
// ignore
}
} }
// convenience method // convenience method

View file

@ -0,0 +1,24 @@
package deckers.thibault.aves.model.provider;
import android.content.Context;
import android.net.Uri;
import androidx.annotation.NonNull;
import deckers.thibault.aves.model.ImageEntry;
class ContentImageProvider extends ImageProvider {
@Override
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
ImageEntry entry = new ImageEntry();
entry.uri = uri;
entry.mimeType = mimeType;
entry.fillPreCatalogMetadata(context);
if (entry.hasSize()) {
callback.onSuccess(entry.toMap());
} else {
callback.onFailure();
}
}
}

View file

@ -0,0 +1,47 @@
package deckers.thibault.aves.model.provider;
import android.content.Context;
import android.net.Uri;
import android.util.Log;
import androidx.annotation.NonNull;
import java.io.File;
import deckers.thibault.aves.model.ImageEntry;
import deckers.thibault.aves.utils.FileUtils;
import deckers.thibault.aves.utils.Utils;
class FileImageProvider extends ImageProvider {
private static final String LOG_TAG = Utils.createLogTag(FileImageProvider.class);
@Override
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
ImageEntry entry = new ImageEntry();
entry.uri = uri;
entry.mimeType = mimeType;
String path = FileUtils.getPathFromUri(context, uri);
if (path != null) {
try {
File file = new File(path);
if (file.exists()) {
entry.path = path;
entry.title = file.getName();
entry.sizeBytes = file.length();
entry.dateModifiedSecs = file.lastModified() / 1000;
}
} catch (SecurityException e) {
Log.w(LOG_TAG, "failed to get path from file at uri=" + uri);
callback.onFailure();
}
}
entry.fillPreCatalogMetadata(context);
if (entry.hasSize()) {
callback.onSuccess(entry.toMap());
} else {
callback.onFailure();
}
}
}

View file

@ -16,6 +16,9 @@ import android.os.ParcelFileDescriptor;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.drew.imaging.ImageMetadataReader; import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException; import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.Metadata; import com.drew.metadata.Metadata;
@ -40,7 +43,7 @@ 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);
public void fetchSingle(final Activity activity, final Uri uri, final String mimeType, final ImageOpCallback callback) { public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
callback.onFailure(); callback.onFailure();
} }
@ -120,7 +123,8 @@ public abstract class ImageProvider {
// `context.getContentResolver().getType()` sometimes return incorrect value // `context.getContentResolver().getType()` sometimes return incorrect value
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000` // `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
// so we check with `metadata-extractor` // so we check with `metadata-extractor`
private String getMimeType(final Context context, final Uri uri) { @Nullable
private String getMimeType(@NonNull final Context context, @NonNull final Uri uri) {
try (InputStream is = context.getContentResolver().openInputStream(uri)) { try (InputStream is = context.getContentResolver().openInputStream(uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is); Metadata metadata = ImageMetadataReader.readMetadata(is);
FileTypeDirectory fileTypeDir = metadata.getFirstDirectoryOfType(FileTypeDirectory.class); FileTypeDirectory fileTypeDir = metadata.getFirstDirectoryOfType(FileTypeDirectory.class);

View file

@ -9,27 +9,20 @@ import androidx.annotation.NonNull;
public class ImageProviderFactory { public class ImageProviderFactory {
public static ImageProvider getProvider(@NonNull Uri uri) { public static ImageProvider getProvider(@NonNull Uri uri) {
String scheme = uri.getScheme(); String scheme = uri.getScheme();
if (scheme != null) {
switch (scheme) { if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(scheme)) {
case ContentResolver.SCHEME_CONTENT: // content:// // a URI's authority is [userinfo@]host[:port]
// a URI's authority is [userinfo@]host[:port] // but we only want the host when comparing to Media Store's "authority"
// but we only want the host when comparing to Media Store's "authority" if (MediaStore.AUTHORITY.equalsIgnoreCase(uri.getHost())) {
String host = uri.getHost(); return new MediaStoreImageProvider();
if (host != null) {
switch (host) {
case MediaStore.AUTHORITY:
return new MediaStoreImageProvider();
// case Constants.DOWNLOADS_AUTHORITY:
// return new DownloadImageProvider();
default:
return new UnknownContentImageProvider();
}
}
return null;
// case ContentResolver.SCHEME_FILE: // file://
// return new FileImageProvider();
} }
return new ContentImageProvider();
} }
if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(scheme)) {
return new FileImageProvider();
}
return null; return null;
} }
} }

View file

@ -3,26 +3,20 @@ package deckers.thibault.aves.model.provider;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Activity; import android.app.Activity;
import android.content.ContentUris; import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.util.Log; import android.util.Log;
import com.drew.imaging.ImageMetadataReader; import androidx.annotation.NonNull;
import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.Metadata;
import com.drew.metadata.MetadataException;
import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.jpeg.JpegDirectory;
import com.drew.metadata.mp4.media.Mp4VideoDirectory;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.stream.Stream; import java.util.stream.Stream;
import deckers.thibault.aves.model.ImageEntry;
import deckers.thibault.aves.utils.Constants; import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.Env; import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.PermissionManager; import deckers.thibault.aves.utils.PermissionManager;
@ -30,12 +24,9 @@ import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils; import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.EventChannel;
import static deckers.thibault.aves.utils.MetadataHelper.getOrientationDegreesForExifCode;
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);
private static final String[] BASE_PROJECTION = { private static final String[] BASE_PROJECTION = {
MediaStore.MediaColumns._ID, MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DATA,
@ -73,7 +64,7 @@ public class MediaStoreImageProvider extends ImageProvider {
} }
@Override @Override
public void fetchSingle(final Activity activity, final Uri uri, final String mimeType, final ImageOpCallback callback) { public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
long id = ContentUris.parseId(uri); long id = ContentUris.parseId(uri);
int entryCount = 0; int entryCount = 0;
NewEntryHandler onSuccess = (entry) -> { NewEntryHandler onSuccess = (entry) -> {
@ -82,10 +73,10 @@ public class MediaStoreImageProvider extends ImageProvider {
}; };
if (mimeType.startsWith(Constants.MIME_IMAGE)) { if (mimeType.startsWith(Constants.MIME_IMAGE)) {
Uri contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); Uri contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
entryCount = fetchFrom(activity, onSuccess, contentUri, IMAGE_PROJECTION); entryCount = fetchFrom(context, onSuccess, contentUri, IMAGE_PROJECTION);
} else if (mimeType.startsWith(Constants.MIME_VIDEO)) { } else if (mimeType.startsWith(Constants.MIME_VIDEO)) {
Uri contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id); Uri contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id);
entryCount = fetchFrom(activity, onSuccess, contentUri, VIDEO_PROJECTION); entryCount = fetchFrom(context, onSuccess, contentUri, VIDEO_PROJECTION);
} }
if (entryCount == 0) { if (entryCount == 0) {
callback.onFailure(); callback.onFailure();
@ -93,12 +84,12 @@ public class MediaStoreImageProvider extends ImageProvider {
} }
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
private int fetchFrom(final Activity activity, NewEntryHandler newEntryHandler, final Uri contentUri, String[] projection) { private int fetchFrom(final Context context, NewEntryHandler newEntryHandler, final Uri contentUri, String[] projection) {
String orderBy = MediaStore.MediaColumns.DATE_TAKEN + " DESC"; String orderBy = MediaStore.MediaColumns.DATE_TAKEN + " DESC";
int entryCount = 0; int entryCount = 0;
try { try {
Cursor cursor = activity.getContentResolver().query(contentUri, projection, null, null, orderBy); Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, orderBy);
if (cursor != null) { if (cursor != null) {
// image & video // image & video
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID); int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID);
@ -109,7 +100,6 @@ public class MediaStoreImageProvider extends ImageProvider {
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 dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED); int dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED);
int dateTakenColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN); int dateTakenColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN);
int bucketDisplayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_DISPLAY_NAME); int bucketDisplayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_DISPLAY_NAME);
@ -120,73 +110,43 @@ public class MediaStoreImageProvider extends ImageProvider {
int durationColumn = cursor.getColumnIndex(MediaStore.MediaColumns.DURATION); int durationColumn = cursor.getColumnIndex(MediaStore.MediaColumns.DURATION);
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
long contentId = cursor.getLong(idColumn); final long contentId = cursor.getLong(idColumn);
// this is fine if `contentUri` does not already contain the ID // this is fine if `contentUri` does not already contain the ID
Uri itemUri = ContentUris.withAppendedId(contentUri, contentId); final Uri itemUri = ContentUris.withAppendedId(contentUri, contentId);
String path = cursor.getString(pathColumn); final String path = cursor.getString(pathColumn);
int width = cursor.getInt(widthColumn); int width = cursor.getInt(widthColumn);
int height = cursor.getInt(heightColumn); int height = cursor.getInt(heightColumn);
int orientationDegrees = orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0;
Map<String, Object> entryMap = new HashMap<String, Object>() {{
put("uri", itemUri.toString());
put("path", path);
put("mimeType", cursor.getString(mimeTypeColumn));
put("orientationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0);
put("sizeBytes", cursor.getLong(sizeColumn));
put("title", cursor.getString(titleColumn));
put("dateModifiedSecs", cursor.getLong(dateModifiedColumn));
put("sourceDateTakenMillis", cursor.getLong(dateTakenColumn));
put("bucketDisplayName", cursor.getString(bucketDisplayNameColumn));
put("durationMillis", durationColumn != -1 ? cursor.getLong(durationColumn) : 0);
// only for map export
put("contentId", contentId);
}};
entryMap.put("width", width);
entryMap.put("height", height);
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0) {
// some images are incorrectly registered in the Media Store, // some images are incorrectly registered in the Media Store,
// they are valid but miss some attributes, such as width, height, orientation // they are valid but miss some attributes, such as width, height, orientation
try (InputStream is = activity.getContentResolver().openInputStream(itemUri)) { ImageEntry entry = new ImageEntry(entryMap).fillPreCatalogMetadata(context);
Metadata metadata = ImageMetadataReader.readMetadata(is); entryMap = entry.toMap();
width = entry.width != null ? entry.width : 0;
// JPEG height = entry.height != null ? entry.height : 0;
JpegDirectory jpegDir = metadata.getFirstDirectoryOfType(JpegDirectory.class);
if (jpegDir != null) {
if (jpegDir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) {
width = jpegDir.getInt(JpegDirectory.TAG_IMAGE_WIDTH);
}
if (jpegDir.containsTag(JpegDirectory.TAG_IMAGE_HEIGHT)) {
height = jpegDir.getInt(JpegDirectory.TAG_IMAGE_HEIGHT);
}
}
// EXIF
ExifIFD0Directory exifDir = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class);
if (exifDir != null) {
if (exifDir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
orientationDegrees = getOrientationDegreesForExifCode(exifDir.getInt(ExifIFD0Directory.TAG_ORIENTATION));
}
}
// MP4
Mp4VideoDirectory mp4VideoDir = metadata.getFirstDirectoryOfType(Mp4VideoDirectory.class);
if (mp4VideoDir != null) {
if (mp4VideoDir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) {
width = mp4VideoDir.getInt(Mp4VideoDirectory.TAG_WIDTH);
}
if (mp4VideoDir.containsTag(Mp4VideoDirectory.TAG_HEIGHT)) {
height = mp4VideoDir.getInt(Mp4VideoDirectory.TAG_HEIGHT);
}
}
} catch (IOException | ImageProcessingException | MetadataException e) {
// this is probably not a real image, like "/storage/emulated/0", so we skip it
}
} }
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0) {
// this is probably not a real image, like "/storage/emulated/0", so we skip it
Log.w(LOG_TAG, "failed to get size for uri=" + itemUri + ", path=" + path); Log.w(LOG_TAG, "failed to get size for uri=" + itemUri + ", path=" + path);
} else { } else {
Map<String, Object> entryMap = new HashMap<String, Object>() {{
put("uri", itemUri.toString());
put("path", path);
put("contentId", contentId);
put("mimeType", cursor.getString(mimeTypeColumn));
put("sizeBytes", cursor.getLong(sizeColumn));
put("title", cursor.getString(titleColumn));
put("dateModifiedSecs", cursor.getLong(dateModifiedColumn));
put("sourceDateTakenMillis", cursor.getLong(dateTakenColumn));
put("bucketDisplayName", cursor.getString(bucketDisplayNameColumn));
put("durationMillis", durationColumn != -1 ? cursor.getLong(durationColumn) : 0);
}};
entryMap.put("width", width);
entryMap.put("height", height);
entryMap.put("orientationDegrees", orientationDegrees);
newEntryHandler.handleEntry(entryMap); newEntryHandler.handleEntry(entryMap);
entryCount++; entryCount++;
} }
@ -213,7 +173,7 @@ public class MediaStoreImageProvider extends ImageProvider {
return; return;
} }
// if the file is on SD card, calling the content resolver delete() removes the entry from the MediaStore // if the file is on SD card, calling the content resolver delete() removes the entry from the Media Store
// but it doesn't delete the file, even if the app has the permission // but it doesn't delete the file, even if the app has the permission
StorageUtils.deleteFromSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), path); StorageUtils.deleteFromSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), path);
Log.d(LOG_TAG, "deleted from SD card at path=" + uri); Log.d(LOG_TAG, "deleted from SD card at path=" + uri);

View file

@ -1,143 +0,0 @@
package deckers.thibault.aves.model.provider;
import android.app.Activity;
import android.graphics.BitmapFactory;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.os.Build;
import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.Metadata;
import com.drew.metadata.MetadataException;
import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.jpeg.JpegDirectory;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.TimeZone;
import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.MetadataHelper;
import static deckers.thibault.aves.utils.MetadataHelper.getOrientationDegreesForExifCode;
class UnknownContentImageProvider extends ImageProvider {
@Override
public void fetchSingle(final Activity activity, final Uri uri, final String mimeType, final ImageOpCallback callback) {
int width = 0, height = 0;
Integer orientationDegrees = null;
Long sourceDateTakenMillis = null, durationMillis = null;
String title = null;
// check first metadata with MediaMetadataRetriever
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
try {
retriever.setDataSource(activity, uri);
title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE);
String dateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE);
long dateMillis = MetadataHelper.parseVideoMetadataDate(dateString);
// some entries have an invalid default date (19040101T000000.000Z) that is before Epoch time
if (dateMillis > 0) {
sourceDateTakenMillis = dateMillis;
}
String widthString = null, heightString = null, rotationString = null, durationMillisString = null;
if (mimeType.startsWith(Constants.MIME_IMAGE)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
widthString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH);
heightString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT);
rotationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION);
}
} else if (mimeType.startsWith(Constants.MIME_VIDEO)) {
widthString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH);
heightString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT);
rotationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
durationMillisString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
}
if (widthString != null) {
width = Integer.parseInt(widthString);
}
if (heightString != null) {
height = Integer.parseInt(heightString);
}
if (rotationString != null) {
orientationDegrees = Integer.parseInt(rotationString);
}
if (durationMillisString != null) {
durationMillis = Long.parseLong(durationMillisString);
}
} catch (Exception e) {
// ignore
} finally {
retriever.release();
}
// fallback to metadata-extractor for known types
if (width <= 0 || height <= 0 || orientationDegrees == null || sourceDateTakenMillis == null) {
if (Constants.MIME_JPEG.equals(mimeType)) {
try (InputStream is = activity.getContentResolver().openInputStream(uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
JpegDirectory jpegDir = metadata.getFirstDirectoryOfType(JpegDirectory.class);
if (jpegDir != null) {
if (jpegDir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) {
width = jpegDir.getInt(JpegDirectory.TAG_IMAGE_WIDTH);
}
if (jpegDir.containsTag(JpegDirectory.TAG_IMAGE_HEIGHT)) {
height = jpegDir.getInt(JpegDirectory.TAG_IMAGE_HEIGHT);
}
}
ExifIFD0Directory exifDir = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class);
if (exifDir != null) {
if (exifDir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
orientationDegrees = getOrientationDegreesForExifCode(exifDir.getInt(ExifIFD0Directory.TAG_ORIENTATION));
}
if (exifDir.containsTag(ExifIFD0Directory.TAG_DATETIME)) {
sourceDateTakenMillis = exifDir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime();
}
}
} catch (IOException | ImageProcessingException | MetadataException e) {
// ignore
}
}
}
// fallback to decoding the image bounds
if (width <= 0 || height <= 0) {
try (InputStream is = activity.getContentResolver().openInputStream(uri)) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeStream(is, null, options);
width = options.outWidth;
height = options.outHeight;
} catch (IOException e) {
// ignore
}
}
if (width <= 0 || height <= 0) {
callback.onFailure();
return;
}
Map<String, Object> entry = new HashMap<>();
entry.put("uri", uri.toString());
entry.put("path", null);
entry.put("contentId", null);
entry.put("mimeType", mimeType);
entry.put("width", width);
entry.put("height", height);
entry.put("orientationDegrees", orientationDegrees != null ? orientationDegrees : 0);
entry.put("sizeBytes", null);
entry.put("title", title);
entry.put("dateModifiedSecs", null);
entry.put("sourceDateTakenMillis", sourceDateTakenMillis);
entry.put("bucketDisplayName", null);
entry.put("durationMillis", durationMillis);
callback.onSuccess(entry);
}
}

View file

@ -10,12 +10,14 @@ public class Constants {
// mime types // mime types
public static final String MIME_IMAGE = "image";
public static final String MIME_GIF = "image/gif"; public static final String MIME_GIF = "image/gif";
public static final String MIME_JPEG = "image/jpeg"; public static final String MIME_JPEG = "image/jpeg";
public static final String MIME_PNG = "image/png"; public static final String MIME_PNG = "image/png";
public static final String MIME_MP2T = "video/mp2t"; // .m2ts
public static final String MIME_IMAGE = "image";
public static final String MIME_VIDEO = "video"; public static final String MIME_VIDEO = "video";
public static final String MIME_MP2T = "video/mp2t"; // .m2ts
public static final String MIME_MP4 = "video/mp4";
// video metadata keys, from android.media.MediaMetadataRetriever // video metadata keys, from android.media.MediaMetadataRetriever

View file

@ -22,6 +22,7 @@
package deckers.thibault.aves.utils; package deckers.thibault.aves.utils;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.ContentUris; import android.content.ContentUris;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
@ -40,8 +41,9 @@ import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
public class FileUtils { public class FileUtils {
// useful (for Download, File, etc.) but slower
public String getPathFromUri(final Context context, final Uri uri) { // than directly using `MediaStore.MediaColumns.DATA` from the MediaStore query
public static String getPathFromUri(final Context context, final Uri uri) {
String path = getPathFromLocalUri(context, uri); String path = getPathFromLocalUri(context, uri);
if (path == null) { if (path == null) {
path = getPathFromRemoteUri(context, uri); path = getPathFromRemoteUri(context, uri);
@ -50,7 +52,7 @@ public class FileUtils {
} }
@SuppressLint("NewApi") @SuppressLint("NewApi")
private String getPathFromLocalUri(final Context context, final Uri uri) { private static String getPathFromLocalUri(final Context context, final Uri uri) {
final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) { if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) {
@ -95,7 +97,7 @@ public class FileUtils {
return getDataColumn(context, contentUri, selection, selectionArgs); return getDataColumn(context, contentUri, selection, selectionArgs);
} }
} else if ("content".equalsIgnoreCase(uri.getScheme())) { } else if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme())) {
// Return the remote address // Return the remote address
if (isGooglePhotosUri(uri)) { if (isGooglePhotosUri(uri)) {
@ -103,7 +105,7 @@ public class FileUtils {
} }
return getDataColumn(context, uri, null, null); return getDataColumn(context, uri, null, null);
} else if ("file".equalsIgnoreCase(uri.getScheme())) { } else if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(uri.getScheme())) {
return uri.getPath(); return uri.getPath();
} }

View file

@ -2,6 +2,8 @@ package deckers.thibault.aves.utils;
import android.media.ExifInterface; import android.media.ExifInterface;
import androidx.annotation.Nullable;
import java.text.DateFormat; import java.text.DateFormat;
import java.text.ParseException; import java.text.ParseException;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
@ -28,7 +30,11 @@ public class MetadataHelper {
} }
// yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)? // yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
public static long parseVideoMetadataDate(String dateString) { public static long parseVideoMetadataDate(@Nullable String dateString) {
if (dateString == null) {
return 0;
}
// optional sub-second // optional sub-second
String subSecond = null; String subSecond = null;
Matcher subSecondMatcher = Pattern.compile("(\\d{6})(\\.\\d+)").matcher(dateString); Matcher subSecondMatcher = Pattern.compile("(\\d{6})(\\.\\d+)").matcher(dateString);