From a5b47726ed0817a63512cf0a0ce44c016ab71c63 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 18 Mar 2020 22:18:33 +0900 Subject: [PATCH] added file image provider --- .../aves/channelhandlers/ImageDecodeTask.java | 20 +- .../ImageDecodeTaskManager.java | 2 +- .../aves/channelhandlers/MetadataHandler.java | 18 +- .../thibault/aves/model/ImageEntry.java | 216 +++++++++++++++--- .../model/provider/ContentImageProvider.java | 24 ++ .../model/provider/FileImageProvider.java | 47 ++++ .../aves/model/provider/ImageProvider.java | 8 +- .../model/provider/ImageProviderFactory.java | 31 +-- .../provider/MediaStoreImageProvider.java | 112 +++------ .../provider/UnknownContentImageProvider.java | 143 ------------ .../thibault/aves/utils/Constants.java | 6 +- .../thibault/aves/utils/FileUtils.java | 12 +- .../thibault/aves/utils/MetadataHelper.java | 8 +- 13 files changed, 348 insertions(+), 299 deletions(-) create mode 100644 android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java create mode 100644 android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java delete mode 100644 android/app/src/main/java/deckers/thibault/aves/model/provider/UnknownContentImageProvider.java diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java index 435099755..768649cfb 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java @@ -74,7 +74,7 @@ public class ImageDecodeTask extends AsyncTask 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); } diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java index 501bc5fa4..8a6defd1e 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java @@ -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"); + + Map> metadataMap = new HashMap<>(); + try (InputStream is = getInputStream(path, uri)) { - Map> metadataMap = new HashMap<>(); 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"); + + Map metadataMap = new HashMap<>(); + try (InputStream is = getInputStream(path, uri)) { - Map metadataMap = new HashMap<>(); 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 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); diff --git a/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java b/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java index d434dae10..92ef5f665 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java @@ -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 toMap() { + return new HashMap() {{ + 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); + } + + 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() { - return width; + // 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 int getHeight() { - return height; - } - - public int getOrientationDegrees() { - return orientationDegrees; - } - - 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 diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java new file mode 100644 index 000000000..6fb5f9156 --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java @@ -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(); + } + } +} diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java new file mode 100644 index 000000000..895f8c899 --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java @@ -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(); + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index 20f99b699..dd8d96595 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -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); diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProviderFactory.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProviderFactory.java index 5fa3be77b..140c871ba 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProviderFactory.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProviderFactory.java @@ -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:// - // 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: - return new MediaStoreImageProvider(); -// case Constants.DOWNLOADS_AUTHORITY: -// return new DownloadImageProvider(); - default: - return new UnknownContentImageProvider(); - } - } - return null; -// case ContentResolver.SCHEME_FILE: // file:// -// return new FileImageProvider(); + + 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" + if (MediaStore.AUTHORITY.equalsIgnoreCase(uri.getHost())) { + return new MediaStoreImageProvider(); } + return new ContentImageProvider(); } + + if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(scheme)) { + return new FileImageProvider(); + } + return null; } } diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index f8d2ee89a..8f6419398 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -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; + + Map entryMap = new HashMap() {{ + 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) { // 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 - } + 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 { - Map entryMap = new HashMap() {{ - 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); 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); diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/UnknownContentImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/UnknownContentImageProvider.java deleted file mode 100644 index c4f263170..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/UnknownContentImageProvider.java +++ /dev/null @@ -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 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); - } -} diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java b/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java index e1ddbf248..d7374d62c 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java @@ -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 diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/FileUtils.java b/android/app/src/main/java/deckers/thibault/aves/utils/FileUtils.java index 8ff209539..a1a1ab178 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/FileUtils.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/FileUtils.java @@ -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(); } diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/MetadataHelper.java b/android/app/src/main/java/deckers/thibault/aves/utils/MetadataHelper.java index 1ef3a36c6..df74fe726 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/MetadataHelper.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/MetadataHelper.java @@ -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);