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);
}
} 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;
if (bitmap != null) {
@ -95,16 +95,16 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
ContentResolver resolver = activity.getContentResolver();
try {
return resolver.loadThumbnail(entry.getUri(), new Size(width, height), null);
return resolver.loadThumbnail(entry.uri, new Size(width, height), null);
} 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;
}
private Bitmap getThumbnailBytesByMediaStore(Params params) {
ImageEntry entry = params.entry;
long contentId = ContentUris.parseId(entry.getUri());
long contentId = ContentUris.parseId(entry.uri);
ContentResolver resolver = activity.getContentResolver();
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);
}
} 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;
}
@ -125,7 +125,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
int height = params.height;
// 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()
.signature(signature)
.override(width, height);
@ -136,14 +136,14 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
target = Glide.with(activity)
.asBitmap()
.apply(options)
.load(new VideoThumbnail(activity, entry.getUri()))
.load(new VideoThumbnail(activity, entry.uri))
.signature(signature)
.submit(width, height);
} else {
target = Glide.with(activity)
.asBitmap()
.apply(options)
.load(entry.getUri())
.load(entry.uri)
.signature(signature)
.submit(width, height);
}
@ -151,7 +151,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
try {
return target.get();
} 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) {
e.printStackTrace();
}
@ -162,7 +162,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
@Override
protected void onPostExecute(Result 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);
if (result.data != null) {
r.success(result.data);

View file

@ -35,7 +35,7 @@ public class ImageDecodeTaskManager {
}
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);
}

View file

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

View file

@ -1,27 +1,49 @@
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.os.Build;
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.FileInputStream;
import java.io.FileNotFoundException;
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;
public class ImageEntry {
// from source
private String path; // best effort to get local path from content providers
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;
public Uri uri; // content or file URI
public String path; // best effort to get local path
// uri: content provider uri
// path: FileUtils.getPathFromUri(activity, itemUri) is useful (for Download, File, etc.) but is slower than directly using `MediaStore.MediaColumns.DATA` from the MediaStore query
public String mimeType, title, bucketDisplayName;
@Nullable
public Integer width, height, orientationDegrees;
@Nullable
public Long sizeBytes, dateModifiedSecs, sourceDateTakenMillis, durationMillis;
public ImageEntry() {
}
public ImageEntry(Map map) {
this.uri = Uri.parse((String) map.get("uri"));
@ -38,49 +60,175 @@ public class ImageEntry {
this.durationMillis = toLong(map.get("durationMillis"));
}
public Uri getUri() {
return uri;
public Map<String, Object> toMap() {
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
public String getPath() {
return path;
private Long getContentId() {
if (uri != null && ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) {
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() {
return path == null ? null : new File(path).getName();
}
public boolean isImage() {
return mimeType.startsWith(Constants.MIME_IMAGE);
}
public boolean isVideo() {
return mimeType.startsWith(Constants.MIME_VIDEO);
}
public boolean isEditable() {
return path != null;
// metadata retrieval
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() {
return Constants.MIME_GIF.equals(mimeType);
// expects entry with: uri/path, 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() {
return mimeType;
// expects entry with: uri/path, 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);
}
public int getWidth() {
return width;
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;
}
public int getHeight() {
return height;
String title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE);
if (title != null) {
this.title = title;
}
} catch (Exception e) {
// ignore
} finally {
retriever.release();
}
}
public int getOrientationDegrees() {
return orientationDegrees;
// expects entry with: uri/path, mimeType
// 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 long getDateModifiedSecs() {
return dateModifiedSecs;
// expects entry with: uri/path
// finds: width, height
private void fillByBitmapDecode(Context context) {
try (InputStream is = getInputStream(context)) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeStream(is, null, options);
width = options.outWidth;
height = options.outHeight;
} catch (IOException e) {
// ignore
}
}
// 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.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.Metadata;
@ -40,7 +43,7 @@ import deckers.thibault.aves.utils.Utils;
public abstract class ImageProvider {
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();
}
@ -120,7 +123,8 @@ public abstract class ImageProvider {
// `context.getContentResolver().getType()` sometimes return incorrect value
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
// 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)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
FileTypeDirectory fileTypeDir = metadata.getFirstDirectoryOfType(FileTypeDirectory.class);

View file

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

View file

@ -3,26 +3,20 @@ package deckers.thibault.aves.model.provider;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.provider.MediaStore;
import android.util.Log;
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 androidx.annotation.NonNull;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Stream;
import deckers.thibault.aves.model.ImageEntry;
import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.PermissionManager;
@ -30,12 +24,9 @@ import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.EventChannel;
import static deckers.thibault.aves.utils.MetadataHelper.getOrientationDegreesForExifCode;
public class MediaStoreImageProvider extends ImageProvider {
private static final String LOG_TAG = Utils.createLogTag(MediaStoreImageProvider.class);
private static final String[] BASE_PROJECTION = {
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DATA,
@ -73,7 +64,7 @@ public class MediaStoreImageProvider extends ImageProvider {
}
@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);
int entryCount = 0;
NewEntryHandler onSuccess = (entry) -> {
@ -82,10 +73,10 @@ public class MediaStoreImageProvider extends ImageProvider {
};
if (mimeType.startsWith(Constants.MIME_IMAGE)) {
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)) {
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) {
callback.onFailure();
@ -93,12 +84,12 @@ public class MediaStoreImageProvider extends ImageProvider {
}
@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";
int entryCount = 0;
try {
Cursor cursor = activity.getContentResolver().query(contentUri, projection, null, null, orderBy);
Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, orderBy);
if (cursor != null) {
// image & video
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID);
@ -109,7 +100,6 @@ public class MediaStoreImageProvider extends ImageProvider {
int widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH);
int heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT);
int dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED);
int dateTakenColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN);
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);
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
Uri itemUri = ContentUris.withAppendedId(contentUri, contentId);
String path = cursor.getString(pathColumn);
final Uri itemUri = ContentUris.withAppendedId(contentUri, contentId);
final String path = cursor.getString(pathColumn);
int width = cursor.getInt(widthColumn);
int height = cursor.getInt(heightColumn);
int orientationDegrees = orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0;
if (width <= 0 || height <= 0) {
// some images are incorrectly registered in the Media Store,
// they are valid but miss some attributes, such as width, height, orientation
try (InputStream is = activity.getContentResolver().openInputStream(itemUri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
// JPEG
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) {
Log.w(LOG_TAG, "failed to get size for uri=" + itemUri + ", path=" + path);
} 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("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);
entryMap.put("orientationDegrees", orientationDegrees);
if (width <= 0 || height <= 0) {
// some images are incorrectly registered in the Media Store,
// they are valid but miss some attributes, such as width, height, orientation
ImageEntry entry = new ImageEntry(entryMap).fillPreCatalogMetadata(context);
entryMap = entry.toMap();
width = entry.width != null ? entry.width : 0;
height = entry.height != null ? entry.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);
} else {
newEntryHandler.handleEntry(entryMap);
entryCount++;
}
@ -213,7 +173,7 @@ public class MediaStoreImageProvider extends ImageProvider {
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
StorageUtils.deleteFromSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), path);
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
public static final String MIME_IMAGE = "image";
public static final String MIME_GIF = "image/gif";
public static final String MIME_JPEG = "image/jpeg";
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_MP2T = "video/mp2t"; // .m2ts
public static final String MIME_MP4 = "video/mp4";
// video metadata keys, from android.media.MediaMetadataRetriever

View file

@ -22,6 +22,7 @@
package deckers.thibault.aves.utils;
import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
@ -40,8 +41,9 @@ import java.io.InputStream;
import java.io.OutputStream;
public class FileUtils {
public String getPathFromUri(final Context context, final Uri uri) {
// useful (for Download, File, etc.) but slower
// 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);
if (path == null) {
path = getPathFromRemoteUri(context, uri);
@ -50,7 +52,7 @@ public class FileUtils {
}
@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;
if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) {
@ -95,7 +97,7 @@ public class FileUtils {
return getDataColumn(context, contentUri, selection, selectionArgs);
}
} else if ("content".equalsIgnoreCase(uri.getScheme())) {
} else if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme())) {
// Return the remote address
if (isGooglePhotosUri(uri)) {
@ -103,7 +105,7 @@ public class FileUtils {
}
return getDataColumn(context, uri, null, null);
} else if ("file".equalsIgnoreCase(uri.getScheme())) {
} else if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(uri.getScheme())) {
return uri.getPath();
}

View file

@ -2,6 +2,8 @@ package deckers.thibault.aves.utils;
import android.media.ExifInterface;
import androidx.annotation.Nullable;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
@ -28,7 +30,11 @@ public class MetadataHelper {
}
// 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
String subSecond = null;
Matcher subSecondMatcher = Pattern.compile("(\\d{6})(\\.\\d+)").matcher(dateString);