From 60d16a3e17377c726abfb7f808c4256f0846a61c Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 7 Oct 2020 13:38:28 +0900 Subject: [PATCH] improved metadata initialization from the media store flipping (WIP) --- .../aves/channel/calls/ImageFileHandler.java | 10 +- .../aves/channel/calls/MetadataHandler.java | 186 ++++++++++-------- .../streams/ImageByteStreamHandler.java | 6 +- .../thibault/aves/model/AvesImageEntry.java | 2 +- .../thibault/aves/model/SourceImageEntry.java | 131 ++++++------ .../aves/model/provider/ImageProvider.java | 5 +- .../provider/MediaStoreImageProvider.java | 21 +- .../utils/MediaMetadataRetrieverHelper.kt | 51 +++++ .../deckers/thibault/aves/utils/MimeTypes.kt | 7 + lib/model/image_entry.dart | 18 +- lib/model/image_metadata.dart | 11 +- lib/model/metadata_db.dart | 41 +++- lib/services/image_file_service.dart | 8 +- lib/services/metadata_service.dart | 19 +- lib/widgets/collection/thumbnail/raster.dart | 2 +- .../entry_action_delegate.dart | 6 +- .../image_providers/uri_image_provider.dart | 6 +- lib/widgets/fullscreen/debug.dart | 20 +- lib/widgets/fullscreen/fullscreen_body.dart | 2 +- lib/widgets/fullscreen/image_view.dart | 4 +- .../fullscreen/info/metadata_thumbnail.dart | 4 +- lib/widgets/fullscreen/video_view.dart | 2 +- 22 files changed, 369 insertions(+), 193 deletions(-) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/utils/MediaMetadataRetrieverHelper.kt diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java index ebe81b15d..3722837c2 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java @@ -95,17 +95,17 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { } private void getImageEntry(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { - String uriString = call.argument("uri"); String mimeType = call.argument("mimeType"); - if (uriString == null || mimeType == null) { + Uri uri = Uri.parse(call.argument("uri")); + + if (uri == null || mimeType == null) { result.error("getImageEntry-args", "failed because of missing arguments", null); return; } - Uri uri = Uri.parse(uriString); ImageProvider provider = ImageProviderFactory.getProvider(uri); if (provider == null) { - result.error("getImageEntry-provider", "failed to find provider for uri=" + uriString, null); + result.error("getImageEntry-provider", "failed to find provider for uri=" + uri, null); return; } @@ -117,7 +117,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { @Override public void onFailure(Throwable throwable) { - result.error("getImageEntry-failure", "failed to get entry for uri=" + uriString, throwable.getMessage()); + result.error("getImageEntry-failure", "failed to get entry for uri=" + uri, throwable.getMessage()); } }); } diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java index 6d2063aaa..cbb3bc04d 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java @@ -48,6 +48,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import deckers.thibault.aves.utils.ExifInterfaceHelper; +import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper; import deckers.thibault.aves.utils.MetadataHelper; import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.StorageUtils; @@ -63,6 +64,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { // catalog metadata private static final String KEY_MIME_TYPE = "mimeType"; private static final String KEY_DATE_MILLIS = "dateMillis"; + private static final String KEY_IS_FLIPPED = "isFlipped"; private static final String KEY_IS_ANIMATED = "isAnimated"; private static final String KEY_LATITUDE = "latitude"; private static final String KEY_LONGITUDE = "longitude"; @@ -146,6 +148,9 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { case "getExifInterfaceMetadata": new Thread(() -> getExifInterfaceMetadata(call, new MethodResultWrapper(result))).start(); break; + case "getMediaMetadataRetrieverMetadata": + new Thread(() -> getMediaMetadataRetrieverMetadata(call, new MethodResultWrapper(result))).start(); + break; case "getEmbeddedPictures": new Thread(() -> getEmbeddedPictures(call, new MethodResultWrapper(result))).start(); break; @@ -167,49 +172,50 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { private void getAllMetadata(MethodCall call, MethodChannel.Result result) { String mimeType = call.argument("mimeType"); - String uriString = call.argument("uri"); - Uri uri = Uri.parse(uriString); + Uri uri = Uri.parse(call.argument("uri")); Map> metadataMap = new HashMap<>(); - boolean foundExif = false; - try (InputStream is = StorageUtils.openInputStream(context, uri)) { - Metadata metadata = ImageMetadataReader.readMetadata(is); - for (Directory dir : metadata.getDirectories()) { - if (dir.getTagCount() > 0 && !(dir instanceof FileTypeDirectory)) { - foundExif |= dir instanceof ExifDirectoryBase; - // directory name - String dirName = dir.getName(); - Map dirMap = Objects.requireNonNull(metadataMap.getOrDefault(dirName, new HashMap<>())); - metadataMap.put(dirName, dirMap); + if (MimeTypes.isSupportedByMetadataExtractor(mimeType)) { + try (InputStream is = StorageUtils.openInputStream(context, uri)) { + Metadata metadata = ImageMetadataReader.readMetadata(is); + for (Directory dir : metadata.getDirectories()) { + if (dir.getTagCount() > 0 && !(dir instanceof FileTypeDirectory)) { + foundExif |= dir instanceof ExifDirectoryBase; - // tags - for (Tag tag : dir.getTags()) { - dirMap.put(tag.getTagName(), tag.getDescription()); - } - if (dir instanceof XmpDirectory) { - try { - XmpDirectory xmpDir = (XmpDirectory) dir; - XMPMeta xmpMeta = xmpDir.getXMPMeta(); - xmpMeta.sort(); - XMPIterator xmpIterator = xmpMeta.iterator(); - while (xmpIterator.hasNext()) { - XMPPropertyInfo prop = (XMPPropertyInfo) xmpIterator.next(); - String xmpPath = prop.getPath(); - String xmpValue = prop.getValue(); - if (xmpPath != null && !xmpPath.isEmpty() && xmpValue != null && !xmpValue.isEmpty()) { - dirMap.put(xmpPath, xmpValue); + // directory name + String dirName = dir.getName(); + Map dirMap = Objects.requireNonNull(metadataMap.getOrDefault(dirName, new HashMap<>())); + metadataMap.put(dirName, dirMap); + + // tags + for (Tag tag : dir.getTags()) { + dirMap.put(tag.getTagName(), tag.getDescription()); + } + if (dir instanceof XmpDirectory) { + try { + XmpDirectory xmpDir = (XmpDirectory) dir; + XMPMeta xmpMeta = xmpDir.getXMPMeta(); + xmpMeta.sort(); + XMPIterator xmpIterator = xmpMeta.iterator(); + while (xmpIterator.hasNext()) { + XMPPropertyInfo prop = (XMPPropertyInfo) xmpIterator.next(); + String xmpPath = prop.getPath(); + String xmpValue = prop.getValue(); + if (xmpPath != null && !xmpPath.isEmpty() && xmpValue != null && !xmpValue.isEmpty()) { + dirMap.put(xmpPath, xmpValue); + } } + } catch (XMPException e) { + Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uri, e); } - } catch (XMPException e) { - Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uriString, e); } } } + } catch (Exception | NoClassDefFoundError e) { + Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=" + uri, e); } - } catch (Exception | NoClassDefFoundError e) { - Log.w(LOG_TAG, "failed to get metadata by ImageMetadataReader for uri=" + uriString, e); } if (!foundExif) { @@ -218,27 +224,27 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { ExifInterface exif = new ExifInterface(is); metadataMap.putAll(ExifInterfaceHelper.describeAll(exif)); } catch (IOException e) { - Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=" + uriString, e); + Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=" + uri, e); } } if (isVideo(mimeType)) { - Map videoDir = getVideoAllMetadataByMediaMetadataRetriever(uriString); + Map videoDir = getVideoAllMetadataByMediaMetadataRetriever(uri); if (!videoDir.isEmpty()) { metadataMap.put("Video", videoDir); } } if (metadataMap.isEmpty()) { - result.error("getAllMetadata-failure", "failed to get metadata for uri=" + uriString, null); + result.error("getAllMetadata-failure", "failed to get metadata for uri=" + uri, null); } else { result.success(metadataMap); } } - private Map getVideoAllMetadataByMediaMetadataRetriever(String uri) { + private Map getVideoAllMetadataByMediaMetadataRetriever(Uri uri) { Map dirMap = new HashMap<>(); - MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, Uri.parse(uri)); + MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri); if (retriever != null) { try { for (Map.Entry kv : VIDEO_MEDIA_METADATA_KEYS.entrySet()) { @@ -268,7 +274,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { private void getCatalogMetadata(MethodCall call, MethodChannel.Result result) { String mimeType = call.argument("mimeType"); - String uri = call.argument("uri"); + Uri uri = Uri.parse(call.argument("uri")); String extension = call.argument("extension"); Map metadataMap = new HashMap<>(getCatalogMetadataByImageMetadataReader(uri, mimeType, extension)); @@ -280,13 +286,12 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { result.success(metadataMap); } - private Map getCatalogMetadataByImageMetadataReader(String uri, String mimeType, String extension) { + private Map getCatalogMetadataByImageMetadataReader(Uri uri, String mimeType, String extension) { Map metadataMap = new HashMap<>(); - // as of metadata-extractor v2.14.0, MP2T/WBMP files are not supported - if (MimeTypes.MP2T.equals(mimeType) || MimeTypes.WBMP.equals(mimeType)) return metadataMap; + if (!MimeTypes.isSupportedByMetadataExtractor(mimeType)) return metadataMap; - try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri))) { + try (InputStream is = StorageUtils.openInputStream(context, uri)) { Metadata metadata = ImageMetadataReader.readMetadata(is); // File type @@ -356,14 +361,14 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { } } } catch (Exception | NoClassDefFoundError e) { - Log.w(LOG_TAG, "failed to get catalog metadata by ImageMetadataReader for uri=" + uri + ", mimeType=" + mimeType, e); + Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for uri=" + uri + ", mimeType=" + mimeType, e); } return metadataMap; } - private Map getVideoCatalogMetadataByMediaMetadataRetriever(String uri) { + private Map getVideoCatalogMetadataByMediaMetadataRetriever(Uri uri) { Map metadataMap = new HashMap<>(); - MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, Uri.parse(uri)); + MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri); if (retriever != null) { try { String dateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE); @@ -411,16 +416,16 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { private void getOverlayMetadata(MethodCall call, MethodChannel.Result result) { String mimeType = call.argument("mimeType"); - String uri = call.argument("uri"); + Uri uri = Uri.parse(call.argument("uri")); Map metadataMap = new HashMap<>(); - if (isVideo(mimeType)) { + if (isVideo(mimeType) || !MimeTypes.isSupportedByMetadataExtractor(mimeType)) { result.success(metadataMap); return; } - try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri))) { + try (InputStream is = StorageUtils.openInputStream(context, uri)) { Metadata metadata = ImageMetadataReader.readMetadata(is); for (ExifSubIFDDirectory directory : metadata.getDirectoriesOfType(ExifSubIFDDirectory.class)) { putDescriptionFromTag(metadataMap, KEY_APERTURE, directory, ExifSubIFDDirectory.TAG_FNUMBER); @@ -453,13 +458,8 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { private void getContentResolverMetadata(MethodCall call, MethodChannel.Result result) { String mimeType = call.argument("mimeType"); - String uriString = call.argument("uri"); - if (mimeType == null || uriString == null) { - result.error("getContentResolverMetadata-args", "failed because of missing arguments", null); - return; - } + Uri uri = Uri.parse(call.argument("uri")); - Uri uri = Uri.parse(uriString); long id = ContentUris.parseId(uri); Uri contentUri = uri; if (mimeType.startsWith(MimeTypes.IMAGE)) { @@ -509,13 +509,9 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { } private void getExifInterfaceMetadata(MethodCall call, MethodChannel.Result result) { - String uriString = call.argument("uri"); - if (uriString == null) { - result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null); - return; - } + Uri uri = Uri.parse(call.argument("uri")); - try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uriString))) { + try (InputStream is = StorageUtils.openInputStream(context, uri)) { ExifInterface exif = new ExifInterface(is); Map metadataMap = new HashMap<>(); for (String tag : ExifInterfaceHelper.allTags.keySet()) { @@ -525,12 +521,39 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { } result.success(metadataMap); } catch (IOException e) { - result.error("getExifInterfaceMetadata-failure", "failed to get exif for uri=" + uriString, e.getMessage()); + result.error("getExifInterfaceMetadata-failure", "failed to get exif for uri=" + uri, e.getMessage()); + } + } + + private void getMediaMetadataRetrieverMetadata(MethodCall call, MethodChannel.Result result) { + Uri uri = Uri.parse(call.argument("uri")); + + MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri); + if (retriever == null) { + result.error("getMediaMetadataRetrieverMetadata-null", "failed to open retriever for uri=" + uri, null); + return; + } + + try { + Map metadataMap = new HashMap<>(); + for (Map.Entry kv : MediaMetadataRetrieverHelper.allKeys.entrySet()) { + String value = retriever.extractMetadata(kv.getValue()); + if (value != null) { + metadataMap.put(kv.getKey(), value); + } + } + result.success(metadataMap); + } catch (Exception e) { + result.error("getMediaMetadataRetrieverMetadata-failure", "failed to extract metadata for uri=" + uri, e.getMessage()); + } finally { + // cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs + retriever.release(); } } private void getEmbeddedPictures(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { Uri uri = Uri.parse(call.argument("uri")); + List pictures = new ArrayList<>(); MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri); if (retriever != null) { @@ -551,6 +574,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { private void getExifThumbnails(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { Uri uri = Uri.parse(call.argument("uri")); + List thumbnails = new ArrayList<>(); try (InputStream is = StorageUtils.openInputStream(context, uri)) { ExifInterface exif = new ExifInterface(is); @@ -560,33 +584,41 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { } catch (IOException e) { Log.w(LOG_TAG, "failed to extract exif thumbnail with ExifInterface for uri=" + uri, e); } - result.success(thumbnails); } private void getXmpThumbnails(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + String mimeType = call.argument("mimeType"); Uri uri = Uri.parse(call.argument("uri")); + + if (uri == null || mimeType == null) { + result.error("getXmpThumbnails-args", "failed because of missing arguments", null); + return; + } + List thumbnails = new ArrayList<>(); - try (InputStream is = StorageUtils.openInputStream(context, uri)) { - Metadata metadata = ImageMetadataReader.readMetadata(is); - for (XmpDirectory dir : metadata.getDirectoriesOfType(XmpDirectory.class)) { - XMPMeta xmpMeta = dir.getXMPMeta(); - try { - if (xmpMeta.doesPropertyExist(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME)) { - int count = xmpMeta.countArrayItems(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME); - for (int i = 1; i < count + 1; i++) { - XMPProperty image = xmpMeta.getStructField(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME + "[" + i + "]", XMP_IMG_SCHEMA_NS, XMP_THUMBNAIL_IMAGE_PROP_NAME); - if (image != null) { - thumbnails.add(XMPUtils.decodeBase64(image.getValue())); + if (MimeTypes.isSupportedByMetadataExtractor(mimeType)) { + try (InputStream is = StorageUtils.openInputStream(context, uri)) { + Metadata metadata = ImageMetadataReader.readMetadata(is); + for (XmpDirectory dir : metadata.getDirectoriesOfType(XmpDirectory.class)) { + XMPMeta xmpMeta = dir.getXMPMeta(); + try { + if (xmpMeta.doesPropertyExist(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME)) { + int count = xmpMeta.countArrayItems(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME); + for (int i = 1; i < count + 1; i++) { + XMPProperty image = xmpMeta.getStructField(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME + "[" + i + "]", XMP_IMG_SCHEMA_NS, XMP_THUMBNAIL_IMAGE_PROP_NAME); + if (image != null) { + thumbnails.add(XMPUtils.decodeBase64(image.getValue())); + } } } + } catch (XMPException e) { + Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uri, e); } - } catch (XMPException e) { - Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uri, e); } + } catch (IOException | ImageProcessingException | NoClassDefFoundError e) { + Log.w(LOG_TAG, "failed to extract xmp thumbnail", e); } - } catch (IOException | ImageProcessingException | NoClassDefFoundError e) { - Log.w(LOG_TAG, "failed to extract xmp thumbnail", e); } result.success(thumbnails); } diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.java index 6c856d477..5c55d896b 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.java @@ -30,7 +30,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler { private Activity activity; private Uri uri; private String mimeType; - private int orientationDegrees; + private int rotationDegrees; private EventChannel.EventSink eventSink; private Handler handler; @@ -52,7 +52,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler { Map argMap = (Map) arguments; this.mimeType = (String) argMap.get("mimeType"); this.uri = Uri.parse((String) argMap.get("uri")); - this.orientationDegrees = (int) argMap.get("orientationDegrees"); + this.rotationDegrees = (int) argMap.get("rotationDegrees"); } } @@ -118,7 +118,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler { Bitmap bitmap = target.get(); if (bitmap != null) { // TODO TLAD use exif orientation to rotate & flip? - bitmap = TransformationUtils.rotateImage(bitmap, orientationDegrees); + bitmap = TransformationUtils.rotateImage(bitmap, rotationDegrees); ByteArrayOutputStream stream = new ByteArrayOutputStream(); // we compress the bitmap because Dart Image.memory cannot decode the raw bytes // Bitmap.CompressFormat.PNG is slower than JPEG diff --git a/android/app/src/main/java/deckers/thibault/aves/model/AvesImageEntry.java b/android/app/src/main/java/deckers/thibault/aves/model/AvesImageEntry.java index dd039d3ee..f1cc8ded1 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/AvesImageEntry.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/AvesImageEntry.java @@ -23,7 +23,7 @@ public class AvesImageEntry { this.mimeType = (String) map.get("mimeType"); this.width = (Integer) map.get("width"); this.height = (Integer) map.get("height"); - this.rotationDegrees = (Integer) map.get("orientationDegrees"); + this.rotationDegrees = (Integer) map.get("rotationDegrees"); this.dateModifiedSecs = toLong(map.get("dateModifiedSecs")); } diff --git a/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java b/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java index 8fd402b46..ce6dace73 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java @@ -42,6 +42,8 @@ public class SourceImageEntry { @Nullable public Integer width, height, rotationDegrees; @Nullable + public Boolean isFlipped; + @Nullable public Long sizeBytes; @Nullable public Long dateModifiedSecs; @@ -74,7 +76,8 @@ public class SourceImageEntry { put("sourceMimeType", sourceMimeType); put("width", width); put("height", height); - put("orientationDegrees", rotationDegrees != null ? rotationDegrees : 0); + put("rotationDegrees", rotationDegrees != null ? rotationDegrees : 0); + put("isFlipped", isFlipped != null ? isFlipped : false); put("sizeBytes", sizeBytes); put("title", title); put("dateModifiedSecs", dateModifiedSecs); @@ -101,6 +104,10 @@ public class SourceImageEntry { return width != null && width > 0 && height != null && height > 0; } + public boolean hasOrientation() { + return rotationDegrees != null; + } + private boolean hasDuration() { return durationMillis != null && durationMillis > 0; } @@ -122,8 +129,9 @@ public class SourceImageEntry { // expects entry with: uri, mimeType // finds: width, height, orientation/rotation, date, title, duration public SourceImageEntry fillPreCatalogMetadata(@NonNull Context context) { + if (isSvg()) return this; fillByMediaMetadataRetriever(context); - if (hasSize() && (!isVideo() || hasDuration())) return this; + if (hasSize() && hasOrientation() && (!isVideo() || hasDuration())) return this; fillByMetadataExtractor(context); if (hasSize()) return this; fillByBitmapDecode(context); @@ -133,6 +141,8 @@ public class SourceImageEntry { // expects entry with: uri, mimeType // finds: width, height, orientation/rotation, date, title, duration private void fillByMediaMetadataRetriever(@NonNull Context context) { + if (isImage()) return; + MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri); if (retriever != null) { try { @@ -185,75 +195,72 @@ public class SourceImageEntry { // expects entry with: uri, mimeType // finds: width, height, orientation, date private void fillByMetadataExtractor(@NonNull Context context) { - if (isSvg()) return; + if (!MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) return; try (InputStream is = StorageUtils.openInputStream(context, uri)) { Metadata metadata = ImageMetadataReader.readMetadata(is); - switch (sourceMimeType) { - case MimeTypes.JPEG: - for (JpegDirectory dir : metadata.getDirectoriesOfType(JpegDirectory.class)) { - if (dir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) { - width = dir.getInt(JpegDirectory.TAG_IMAGE_WIDTH); - } - if (dir.containsTag(JpegDirectory.TAG_IMAGE_HEIGHT)) { - height = dir.getInt(JpegDirectory.TAG_IMAGE_HEIGHT); - } + // do not switch on specific mime types, as the reported mime type could be wrong + // (e.g. PNG registered as JPG) + if (isVideo()) { + for (AviDirectory dir : metadata.getDirectoriesOfType(AviDirectory.class)) { + if (dir.containsTag(AviDirectory.TAG_WIDTH)) { + width = dir.getInt(AviDirectory.TAG_WIDTH); } - break; - case MimeTypes.MP4: - for (Mp4VideoDirectory dir : metadata.getDirectoriesOfType(Mp4VideoDirectory.class)) { - if (dir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) { - width = dir.getInt(Mp4VideoDirectory.TAG_WIDTH); - } - if (dir.containsTag(Mp4VideoDirectory.TAG_HEIGHT)) { - height = dir.getInt(Mp4VideoDirectory.TAG_HEIGHT); - } + if (dir.containsTag(AviDirectory.TAG_HEIGHT)) { + height = dir.getInt(AviDirectory.TAG_HEIGHT); } - for (Mp4Directory dir : metadata.getDirectoriesOfType(Mp4Directory.class)) { - if (dir.containsTag(Mp4Directory.TAG_DURATION)) { - durationMillis = dir.getLong(Mp4Directory.TAG_DURATION); - } + if (dir.containsTag(AviDirectory.TAG_DURATION)) { + durationMillis = dir.getLong(AviDirectory.TAG_DURATION); } - break; - case MimeTypes.AVI: - for (AviDirectory dir : metadata.getDirectoriesOfType(AviDirectory.class)) { - if (dir.containsTag(AviDirectory.TAG_WIDTH)) { - width = dir.getInt(AviDirectory.TAG_WIDTH); - } - if (dir.containsTag(AviDirectory.TAG_HEIGHT)) { - height = dir.getInt(AviDirectory.TAG_HEIGHT); - } - if (dir.containsTag(AviDirectory.TAG_DURATION)) { - durationMillis = dir.getLong(AviDirectory.TAG_DURATION); - } + } + for (Mp4VideoDirectory dir : metadata.getDirectoriesOfType(Mp4VideoDirectory.class)) { + if (dir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) { + width = dir.getInt(Mp4VideoDirectory.TAG_WIDTH); } - break; - case MimeTypes.PSD: - for (PsdHeaderDirectory dir : metadata.getDirectoriesOfType(PsdHeaderDirectory.class)) { - if (dir.containsTag(PsdHeaderDirectory.TAG_IMAGE_WIDTH)) { - width = dir.getInt(PsdHeaderDirectory.TAG_IMAGE_WIDTH); - } - if (dir.containsTag(PsdHeaderDirectory.TAG_IMAGE_HEIGHT)) { - height = dir.getInt(PsdHeaderDirectory.TAG_IMAGE_HEIGHT); - } + if (dir.containsTag(Mp4VideoDirectory.TAG_HEIGHT)) { + height = dir.getInt(Mp4VideoDirectory.TAG_HEIGHT); } - break; - } + } + for (Mp4Directory dir : metadata.getDirectoriesOfType(Mp4Directory.class)) { + if (dir.containsTag(Mp4Directory.TAG_DURATION)) { + durationMillis = dir.getLong(Mp4Directory.TAG_DURATION); + } + } + } else { + for (JpegDirectory dir : metadata.getDirectoriesOfType(JpegDirectory.class)) { + if (dir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) { + width = dir.getInt(JpegDirectory.TAG_IMAGE_WIDTH); + } + if (dir.containsTag(JpegDirectory.TAG_IMAGE_HEIGHT)) { + height = dir.getInt(JpegDirectory.TAG_IMAGE_HEIGHT); + } + } + for (PsdHeaderDirectory dir : metadata.getDirectoriesOfType(PsdHeaderDirectory.class)) { + if (dir.containsTag(PsdHeaderDirectory.TAG_IMAGE_WIDTH)) { + width = dir.getInt(PsdHeaderDirectory.TAG_IMAGE_WIDTH); + } + if (dir.containsTag(PsdHeaderDirectory.TAG_IMAGE_HEIGHT)) { + height = dir.getInt(PsdHeaderDirectory.TAG_IMAGE_HEIGHT); + } + } - for (ExifIFD0Directory dir : metadata.getDirectoriesOfType(ExifIFD0Directory.class)) { - if (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_WIDTH)) { - width = dir.getInt(ExifIFD0Directory.TAG_IMAGE_WIDTH); - } - if (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_HEIGHT)) { - height = dir.getInt(ExifIFD0Directory.TAG_IMAGE_HEIGHT); - } - if (dir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) { - int exifOrientation = dir.getInt(ExifIFD0Directory.TAG_ORIENTATION); - rotationDegrees = MetadataHelper.getRotationDegreesForExifCode(exifOrientation); - } - if (dir.containsTag(ExifIFD0Directory.TAG_DATETIME)) { - sourceDateTakenMillis = dir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime(); + // EXIF, if defined, should override metadata found in other directories + for (ExifIFD0Directory dir : metadata.getDirectoriesOfType(ExifIFD0Directory.class)) { + if (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_WIDTH)) { + width = dir.getInt(ExifIFD0Directory.TAG_IMAGE_WIDTH); + } + if (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_HEIGHT)) { + height = dir.getInt(ExifIFD0Directory.TAG_IMAGE_HEIGHT); + } + if (dir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) { + int exifOrientation = dir.getInt(ExifIFD0Directory.TAG_ORIENTATION); + rotationDegrees = MetadataHelper.getRotationDegreesForExifCode(exifOrientation); + isFlipped = MetadataHelper.isFlippedForExifCode(exifOrientation); + } + if (dir.containsTag(ExifIFD0Directory.TAG_DATETIME)) { + sourceDateTakenMillis = dir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime(); + } } } } catch (IOException | ImageProcessingException | MetadataException | NoClassDefFoundError e) { @@ -264,8 +271,6 @@ public class SourceImageEntry { // expects entry with: uri // finds: width, height private void fillByBitmapDecode(@NonNull Context context) { - if (isSvg()) return; - try (InputStream is = StorageUtils.openInputStream(context, uri)) { BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; 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 9d7b0ad95..0d96a86fa 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 @@ -130,7 +130,8 @@ public abstract class ImageProvider { // copy the edited temporary file back to the original DocumentFileCompat.fromFile(new File(editablePath)).copyTo(originalDocumentFile); - newFields.put("orientationDegrees", exif.getRotationDegrees()); + newFields.put("rotationDegrees", exif.getRotationDegrees()); + newFields.put("isFlipped", exif.isFlipped()); } catch (IOException e) { callback.onFailure(e); return; @@ -147,7 +148,7 @@ public abstract class ImageProvider { // values.put(MediaStore.MediaColumns.IS_PENDING, 0); // } // // uses MediaStore.Images.Media instead of MediaStore.MediaColumns for APIs < Q -// values.put(MediaStore.Images.Media.ORIENTATION, orientationDegrees); +// values.put(MediaStore.Images.Media.ORIENTATION, rotationDegrees); // // TODO TLAD catch RecoverableSecurityException // int updatedRowCount = contentResolver.update(uri, values, null, null); // if (updatedRowCount > 0) { 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 36491011a..4dd00189e 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 @@ -124,9 +124,14 @@ public class MediaStoreImageProvider extends ImageProvider { @SuppressLint("InlinedApi") private int fetchFrom(final Context context, NewEntryChecker newEntryChecker, NewEntryHandler newEntryHandler, final Uri contentUri, String[] projection) { int newEntryCount = 0; - final boolean needDuration = projection == VIDEO_PROJECTION; final String orderBy = MediaStore.MediaColumns.DATE_MODIFIED + " DESC"; + // it is reasonable to assume a default orientation when it is missing for videos, + // but not so for images, often containing with metadata ignored by the Media Store + final boolean needOrientation = projection == IMAGE_PROJECTION; + + final boolean needDuration = projection == VIDEO_PROJECTION; + try { Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, orderBy); if (cursor != null) { @@ -159,11 +164,18 @@ public class MediaStoreImageProvider extends ImageProvider { int height = cursor.getInt(heightColumn); final long durationMillis = durationColumn != -1 ? cursor.getLong(durationColumn) : 0; + Integer rotationDegrees = null; + // check whether the field may be `null` to distinguish it from a legitimate `0` + // this can happen for specific formats (e.g. for PNG, WEBP) + // or for JPEG that were not properly registered + if (orientationColumn != -1 && cursor.getType(orientationColumn) == Cursor.FIELD_TYPE_INTEGER) { + rotationDegrees = cursor.getInt(orientationColumn); + } + Map entryMap = new HashMap() {{ put("uri", itemUri.toString()); put("path", path); put("sourceMimeType", mimeType); - put("rotationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0); put("sizeBytes", cursor.getLong(sizeColumn)); put("title", cursor.getString(titleColumn)); put("dateModifiedSecs", dateModifiedSecs); @@ -174,8 +186,11 @@ public class MediaStoreImageProvider extends ImageProvider { entryMap.put("width", width); entryMap.put("height", height); entryMap.put("durationMillis", durationMillis); + entryMap.put("rotationDegrees", rotationDegrees != null ? rotationDegrees : 0); - if (((width <= 0 || height <= 0) && needSize(mimeType)) || (durationMillis == 0 && needDuration)) { + if (((width <= 0 || height <= 0) && needSize(mimeType)) + || (rotationDegrees == null && needOrientation) + || (durationMillis == 0 && needDuration)) { // some images are incorrectly registered in the Media Store, // they are valid but miss some attributes, such as width, height, orientation SourceImageEntry entry = new SourceImageEntry(entryMap).fillPreCatalogMetadata(context); diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MediaMetadataRetrieverHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MediaMetadataRetrieverHelper.kt new file mode 100644 index 000000000..6c822f407 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MediaMetadataRetrieverHelper.kt @@ -0,0 +1,51 @@ +package deckers.thibault.aves.utils + +import android.media.MediaMetadataRetriever +import android.os.Build + +object MediaMetadataRetrieverHelper { + @JvmField + val allKeys: Map = hashMapOf( + "METADATA_KEY_ALBUM" to MediaMetadataRetriever.METADATA_KEY_ALBUM, + "METADATA_KEY_ALBUMARTIST" to MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST, + "METADATA_KEY_ARTIST" to MediaMetadataRetriever.METADATA_KEY_ARTIST, + "METADATA_KEY_AUTHOR" to MediaMetadataRetriever.METADATA_KEY_AUTHOR, + "METADATA_KEY_BITRATE" to MediaMetadataRetriever.METADATA_KEY_BITRATE, + "METADATA_KEY_CAPTURE_FRAMERATE" to MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE, + "METADATA_KEY_CD_TRACK_NUMBER" to MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER, + "METADATA_KEY_COLOR_RANGE" to MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE, + "METADATA_KEY_COLOR_STANDARD" to MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD, + "METADATA_KEY_COLOR_TRANSFER" to MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER, + "METADATA_KEY_COMPILATION" to MediaMetadataRetriever.METADATA_KEY_COMPILATION, + "METADATA_KEY_COMPOSER" to MediaMetadataRetriever.METADATA_KEY_COMPOSER, + "METADATA_KEY_DATE" to MediaMetadataRetriever.METADATA_KEY_DATE, + "METADATA_KEY_DISC_NUMBER" to MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER, + "METADATA_KEY_DURATION" to MediaMetadataRetriever.METADATA_KEY_DURATION, + "METADATA_KEY_EXIF_LENGTH" to MediaMetadataRetriever.METADATA_KEY_EXIF_LENGTH, + "METADATA_KEY_EXIF_OFFSET" to MediaMetadataRetriever.METADATA_KEY_EXIF_OFFSET, + "METADATA_KEY_GENRE" to MediaMetadataRetriever.METADATA_KEY_GENRE, + "METADATA_KEY_HAS_AUDIO" to MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO, + "METADATA_KEY_HAS_VIDEO" to MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO, + "METADATA_KEY_LOCATION" to MediaMetadataRetriever.METADATA_KEY_LOCATION, + "METADATA_KEY_MIMETYPE" to MediaMetadataRetriever.METADATA_KEY_MIMETYPE, + "METADATA_KEY_NUM_TRACKS" to MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS, + "METADATA_KEY_TITLE" to MediaMetadataRetriever.METADATA_KEY_TITLE, + "METADATA_KEY_VIDEO_HEIGHT" to MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, + "METADATA_KEY_VIDEO_ROTATION" to MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION, + "METADATA_KEY_VIDEO_WIDTH" to MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH, + "METADATA_KEY_WRITER" to MediaMetadataRetriever.METADATA_KEY_WRITER, + "METADATA_KEY_YEAR" to MediaMetadataRetriever.METADATA_KEY_YEAR, + ).apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + putAll(hashMapOf( + "METADATA_KEY_HAS_IMAGE" to MediaMetadataRetriever.METADATA_KEY_HAS_IMAGE, + "METADATA_KEY_IMAGE_COUNT" to MediaMetadataRetriever.METADATA_KEY_IMAGE_COUNT, + "METADATA_KEY_IMAGE_HEIGHT" to MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT, + "METADATA_KEY_IMAGE_PRIMARY" to MediaMetadataRetriever.METADATA_KEY_IMAGE_PRIMARY, + "METADATA_KEY_IMAGE_ROTATION" to MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION, + "METADATA_KEY_IMAGE_WIDTH" to MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH, + "METADATA_KEY_VIDEO_FRAME_COUNT" to MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT, + )) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 33a7e912e..835fd183c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -50,6 +50,13 @@ object MimeTypes { const val MOV = "video/quicktime" const val MP2T = "video/mp2t" const val MP4 = "video/mp4" + const val WEBM = "video/webm" + + // as of metadata-extractor v2.14.0, the following formats are not supported + private val unsupportedMetadataExtractorFormats = listOf(WBMP, MP2T, WEBM) + + @JvmStatic + fun isSupportedByMetadataExtractor(mimeType: String) = !unsupportedMetadataExtractorFormats.contains(mimeType) @JvmStatic fun getMimeTypeForExtension(extension: String?): String? = when (extension?.toLowerCase(Locale.ROOT)) { diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 78540d5a8..a8fd87923 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -23,7 +23,7 @@ class ImageEntry { final String sourceMimeType; int width; int height; - int orientationDegrees; + int rotationDegrees; final int sizeBytes; String sourceTitle; int _dateModifiedSecs; @@ -42,7 +42,7 @@ class ImageEntry { this.sourceMimeType, @required this.width, @required this.height, - this.orientationDegrees, + this.rotationDegrees, this.sizeBytes, this.sourceTitle, int dateModifiedSecs, @@ -68,7 +68,7 @@ class ImageEntry { sourceMimeType: sourceMimeType, width: width, height: height, - orientationDegrees: orientationDegrees, + rotationDegrees: rotationDegrees, sizeBytes: sizeBytes, sourceTitle: sourceTitle, dateModifiedSecs: dateModifiedSecs, @@ -90,7 +90,7 @@ class ImageEntry { sourceMimeType: map['sourceMimeType'] as String, width: map['width'] as int ?? 0, height: map['height'] as int ?? 0, - orientationDegrees: map['orientationDegrees'] as int ?? 0, + rotationDegrees: map['rotationDegrees'] as int ?? 0, sizeBytes: map['sizeBytes'] as int, sourceTitle: map['title'] as String, dateModifiedSecs: map['dateModifiedSecs'] as int, @@ -108,7 +108,7 @@ class ImageEntry { 'sourceMimeType': sourceMimeType, 'width': width, 'height': height, - 'orientationDegrees': orientationDegrees, + 'rotationDegrees': rotationDegrees, 'sizeBytes': sizeBytes, 'title': sourceTitle, 'dateModifiedSecs': dateModifiedSecs, @@ -171,6 +171,8 @@ class ImageEntry { bool get isCatalogued => _catalogMetadata != null; + bool get isFlipped => _catalogMetadata?.isFlipped ?? false; + bool get isAnimated => _catalogMetadata?.isAnimated ?? false; bool get canEdit => path != null; @@ -192,7 +194,7 @@ class ImageEntry { } } - bool get portrait => ((isVideo && isCatalogued) ? _catalogMetadata.videoRotation : orientationDegrees) % 180 == 90; + bool get portrait => ((isVideo && isCatalogued) ? _catalogMetadata.videoRotation : rotationDegrees) % 180 == 90; double get displayAspectRatio { if (width == 0 || height == 0) return 1; @@ -369,8 +371,8 @@ class ImageEntry { if (width is int) this.width = width; final height = newFields['height']; if (height is int) this.height = height; - final orientationDegrees = newFields['orientationDegrees']; - if (orientationDegrees is int) this.orientationDegrees = orientationDegrees; + final rotationDegrees = newFields['rotationDegrees']; + if (rotationDegrees is int) this.rotationDegrees = rotationDegrees; imageChangeNotifier.notifyListeners(); return true; diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index a383d93df..c9273e9b3 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -29,7 +29,7 @@ class DateMetadata { class CatalogMetadata { final int contentId, dateMillis, videoRotation; - final bool isAnimated; + final bool isFlipped, isAnimated; final String mimeType, xmpSubjects, xmpTitleDescription; final double latitude, longitude; Address address; @@ -38,13 +38,14 @@ class CatalogMetadata { this.contentId, this.mimeType, this.dateMillis, + this.isFlipped, this.isAnimated, this.videoRotation, this.xmpSubjects, this.xmpTitleDescription, double latitude, double longitude, - }) + }) // Geocoder throws an IllegalArgumentException when a coordinate has a funky values like 1.7056881853375E7 : latitude = latitude == null || latitude < -90.0 || latitude > 90.0 ? null : latitude, longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude; @@ -56,6 +57,7 @@ class CatalogMetadata { contentId: contentId ?? this.contentId, mimeType: mimeType, dateMillis: dateMillis, + isFlipped: isFlipped, isAnimated: isAnimated, videoRotation: videoRotation, xmpSubjects: xmpSubjects, @@ -66,11 +68,13 @@ class CatalogMetadata { } factory CatalogMetadata.fromMap(Map map, {bool boolAsInteger = false}) { + final isFlipped = map['isFlipped'] ?? (boolAsInteger ? 0 : false); final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false); return CatalogMetadata( contentId: map['contentId'], mimeType: map['mimeType'], dateMillis: map['dateMillis'] ?? 0, + isFlipped: boolAsInteger ? isFlipped != 0 : isFlipped, isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated, videoRotation: map['videoRotation'] ?? 0, xmpSubjects: map['xmpSubjects'] ?? '', @@ -84,6 +88,7 @@ class CatalogMetadata { 'contentId': contentId, 'mimeType': mimeType, 'dateMillis': dateMillis, + 'isFlipped': boolAsInteger ? (isFlipped ? 1 : 0) : isFlipped, 'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated, 'videoRotation': videoRotation, 'xmpSubjects': xmpSubjects, @@ -94,7 +99,7 @@ class CatalogMetadata { @override String toString() { - return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; + return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isFlipped=$isFlipped, isAnimated=$isAnimated, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; } } diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index 88682abe9..08777d02e 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -33,7 +33,7 @@ class MetadataDb { ', sourceMimeType TEXT' ', width INTEGER' ', height INTEGER' - ', orientationDegrees INTEGER' + ', rotationDegrees INTEGER' ', sizeBytes INTEGER' ', title TEXT' ', dateModifiedSecs INTEGER' @@ -48,6 +48,7 @@ class MetadataDb { 'contentId INTEGER PRIMARY KEY' ', mimeType TEXT' ', dateMillis INTEGER' + ', isFlipped INTEGER' ', isAnimated INTEGER' ', videoRotation INTEGER' ', xmpSubjects TEXT' @@ -68,7 +69,43 @@ class MetadataDb { ', path TEXT' ')'); }, - version: 1, + onUpgrade: (db, oldVersion, newVersion) async { + // warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported + // on SQLite <3.25.0, bundled on older Android devices + while (oldVersion < newVersion) { + if (oldVersion == 1) { + await db.transaction((txn) async { + // rename column 'orientationDegrees' to 'rotationDegrees' + const newEntryTable = '${entryTable}TEMP'; + await db.execute('CREATE TABLE $newEntryTable(' + 'contentId INTEGER PRIMARY KEY' + ', uri TEXT' + ', path TEXT' + ', sourceMimeType TEXT' + ', width INTEGER' + ', height INTEGER' + ', rotationDegrees INTEGER' + ', sizeBytes INTEGER' + ', title TEXT' + ', dateModifiedSecs INTEGER' + ', sourceDateTakenMillis INTEGER' + ', durationMillis INTEGER' + ')'); + await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,rotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)' + ' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis' + ' FROM $entryTable;'); + await db.execute('DROP TABLE $entryTable;'); + await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;'); + }); + + // new column 'isFlipped' + await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;'); + + oldVersion++; + } + } + }, + version: 2, ); } diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 996c3d217..2b6097d01 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -23,7 +23,7 @@ class ImageFileService { 'mimeType': entry.mimeType, 'width': entry.width, 'height': entry.height, - 'orientationDegrees': entry.orientationDegrees, + 'rotationDegrees': entry.rotationDegrees, 'dateModifiedSecs': entry.dateModifiedSecs, }; } @@ -66,7 +66,7 @@ class ImageFileService { return null; } - static Future getImage(String uri, String mimeType, {int orientationDegrees, int expectedContentLength, BytesReceivedCallback onBytesReceived}) { + static Future getImage(String uri, String mimeType, {int rotationDegrees, int expectedContentLength, BytesReceivedCallback onBytesReceived}) { try { final completer = Completer.sync(); final sink = _OutputBuffer(); @@ -74,7 +74,7 @@ class ImageFileService { byteChannel.receiveBroadcastStream({ 'uri': uri, 'mimeType': mimeType, - 'orientationDegrees': orientationDegrees ?? 0, + 'rotationDegrees': rotationDegrees ?? 0, }).listen( (data) { final chunk = data as Uint8List; @@ -183,7 +183,7 @@ class ImageFileService { static Future rotate(ImageEntry entry, {@required bool clockwise}) async { try { - // return map with: 'width' 'height' 'orientationDegrees' (all optional) + // return map with: 'width' 'height' 'rotationDegrees' (all optional) final result = await platform.invokeMethod('rotate', { 'entry': _toPlatformEntryMap(entry), 'clockwise': clockwise, diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index 9e5416177..e9df0907f 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -33,6 +33,7 @@ class MetadataService { // return map with: // 'mimeType': MIME type as reported by metadata extractors, not Media Store (string) // 'dateMillis': date taken in milliseconds since Epoch (long) + // 'isFlipped': flipped according to EXIF orientation (bool) // 'isAnimated': animated gif/webp (bool) // 'latitude': latitude (double) // 'longitude': longitude (double) @@ -103,6 +104,19 @@ class MetadataService { return {}; } + static Future getMediaMetadataRetrieverMetadata(ImageEntry entry) async { + try { + // return map with all data available from the MediaMetadataRetriever + final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', { + 'uri': entry.uri, + }) as Map; + return result; + } on PlatformException catch (e) { + debugPrint('getMediaMetadataRetrieverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return {}; + } + static Future> getEmbeddedPictures(String uri) async { try { final result = await platform.invokeMethod('getEmbeddedPictures', { @@ -127,10 +141,11 @@ class MetadataService { return []; } - static Future> getXmpThumbnails(String uri) async { + static Future> getXmpThumbnails(ImageEntry entry) async { try { final result = await platform.invokeMethod('getXmpThumbnails', { - 'uri': uri, + 'mimeType': entry.mimeType, + 'uri': entry.uri, }); return (result as List).cast(); } on PlatformException catch (e) { diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index 685d49572..ba9d30766 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -138,7 +138,7 @@ class _ThumbnailRasterImageState extends State { final imageProvider = UriImage( uri: entry.uri, mimeType: entry.mimeType, - orientationDegrees: entry.orientationDegrees, + rotationDegrees: entry.rotationDegrees, expectedContentLength: entry.sizeBytes, ); if (imageCache.statusForKey(imageProvider).keepAlive) { diff --git a/lib/widgets/common/action_delegates/entry_action_delegate.dart b/lib/widgets/common/action_delegates/entry_action_delegate.dart index d3b167365..febec6329 100644 --- a/lib/widgets/common/action_delegates/entry_action_delegate.dart +++ b/lib/widgets/common/action_delegates/entry_action_delegate.dart @@ -75,13 +75,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { Future _print(ImageEntry entry) async { final uri = entry.uri; final mimeType = entry.mimeType; - final orientationDegrees = entry.orientationDegrees; + final rotationDegrees = entry.rotationDegrees; final documentName = entry.bestTitle ?? 'Aves'; final doc = pdf.Document(title: documentName); PdfImage pdfImage; if (entry.isSvg) { - final bytes = await ImageFileService.getImage(uri, mimeType, orientationDegrees: entry.orientationDegrees); + final bytes = await ImageFileService.getImage(uri, mimeType, rotationDegrees: entry.rotationDegrees); if (bytes != null && bytes.isNotEmpty) { final svgRoot = await svg.fromSvgBytes(bytes, uri); final viewBox = svgRoot.viewport.viewBox; @@ -100,7 +100,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { image: UriImage( uri: uri, mimeType: mimeType, - orientationDegrees: orientationDegrees, + rotationDegrees: rotationDegrees, ), ); } diff --git a/lib/widgets/common/image_providers/uri_image_provider.dart b/lib/widgets/common/image_providers/uri_image_provider.dart index 46a7b48fa..afd08b802 100644 --- a/lib/widgets/common/image_providers/uri_image_provider.dart +++ b/lib/widgets/common/image_providers/uri_image_provider.dart @@ -10,14 +10,14 @@ class UriImage extends ImageProvider { const UriImage({ @required this.uri, @required this.mimeType, - @required this.orientationDegrees, + @required this.rotationDegrees, this.expectedContentLength, this.scale = 1.0, }) : assert(uri != null), assert(scale != null); final String uri, mimeType; - final int orientationDegrees, expectedContentLength; + final int rotationDegrees, expectedContentLength; final double scale; @override @@ -46,7 +46,7 @@ class UriImage extends ImageProvider { final bytes = await ImageFileService.getImage( uri, mimeType, - orientationDegrees: orientationDegrees, + rotationDegrees: rotationDegrees, expectedContentLength: expectedContentLength, onBytesReceived: (cumulative, total) { chunkEvents.add(ImageChunkEvent( diff --git a/lib/widgets/fullscreen/debug.dart b/lib/widgets/fullscreen/debug.dart index 7c3e0591d..c27dd3de0 100644 --- a/lib/widgets/fullscreen/debug.dart +++ b/lib/widgets/fullscreen/debug.dart @@ -30,7 +30,7 @@ class _FullscreenDebugPageState extends State { Future _dbDateLoader; Future _dbMetadataLoader; Future _dbAddressLoader; - Future _contentResolverMetadataLoader, _exifInterfaceMetadataLoader; + Future _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader; ImageEntry get entry => widget.entry; @@ -89,25 +89,21 @@ class _FullscreenDebugPageState extends State { 'sourceTitle': '${entry.sourceTitle}', 'sourceMimeType': '${entry.sourceMimeType}', 'mimeType': '${entry.mimeType}', - 'mimeTypeAnySubtype': '${entry.mimeTypeAnySubtype}', }), Divider(), InfoRowGroup({ 'dateModifiedSecs': toDateValue(entry.dateModifiedSecs, factor: 1000), 'sourceDateTakenMillis': toDateValue(entry.sourceDateTakenMillis), 'bestDate': '${entry.bestDate}', - 'monthTaken': '${entry.monthTaken}', - 'dayTaken': '${entry.dayTaken}', }), Divider(), InfoRowGroup({ 'width': '${entry.width}', 'height': '${entry.height}', - 'orientationDegrees': '${entry.orientationDegrees}', + 'rotationDegrees': '${entry.rotationDegrees}', 'portrait': '${entry.portrait}', 'displayAspectRatio': '${entry.displayAspectRatio}', 'displaySize': '${entry.displaySize}', - 'megaPixels': '${entry.megaPixels}', }), Divider(), InfoRowGroup({ @@ -122,8 +118,10 @@ class _FullscreenDebugPageState extends State { 'isPhoto': '${entry.isPhoto}', 'isVideo': '${entry.isVideo}', 'isCatalogued': '${entry.isCatalogued}', + 'isFlipped': '${entry.isFlipped}', 'isAnimated': '${entry.isAnimated}', 'canEdit': '${entry.canEdit}', + 'canEditExif': '${entry.canEditExif}', 'canPrint': '${entry.canPrint}', 'canRotate': '${entry.canRotate}', 'xmpSubjects': '${entry.xmpSubjects}', @@ -204,6 +202,7 @@ class _FullscreenDebugPageState extends State { InfoRowGroup({ 'mimeType': '${data.mimeType}', 'dateMillis': '${data.dateMillis}', + 'isFlipped': '${data.isFlipped}', 'isAnimated': '${data.isAnimated}', 'videoRotation': '${data.videoRotation}', 'latitude': '${data.latitude}', @@ -245,6 +244,7 @@ class _FullscreenDebugPageState extends State { 'contentId': '${catalog.contentId}', 'mimeType': '${catalog.mimeType}', 'dateMillis': '${catalog.dateMillis}', + 'isFlipped': '${catalog.isFlipped}', 'isAnimated': '${catalog.isAnimated}', 'videoRotation': '${catalog.videoRotation}', 'latitude': '${catalog.latitude}', @@ -281,7 +281,8 @@ class _FullscreenDebugPageState extends State { return AvesExpansionTile( title: title, children: [ - Padding( + Container( + alignment: AlignmentDirectional.topStart, padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), child: InfoRowGroup( data, @@ -303,6 +304,10 @@ class _FullscreenDebugPageState extends State { future: _exifInterfaceMetadataLoader, builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'), ), + FutureBuilder( + future: _mediaMetadataLoader, + builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'), + ), ], ); } @@ -313,6 +318,7 @@ class _FullscreenDebugPageState extends State { _dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null)); _contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry); _exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry); + _mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry); setState(() {}); } } diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index 554e48e31..436030570 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -544,7 +544,7 @@ class _FullscreenVerticalPageViewState extends State await UriImage( uri: entry.uri, mimeType: entry.mimeType, - orientationDegrees: entry.orientationDegrees, + rotationDegrees: entry.rotationDegrees, ).evict(); // evict low quality thumbnail (without specified extents) await ThumbnailProvider(entry: entry).evict(); diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index 5887704ac..a77dca114 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -97,12 +97,12 @@ class ImageView extends StatelessWidget { final uriImage = UriImage( uri: entry.uri, mimeType: entry.mimeType, - orientationDegrees: entry.orientationDegrees, + rotationDegrees: entry.rotationDegrees, expectedContentLength: entry.sizeBytes, ); child = PhotoView( // key includes size and orientation to refresh when the image is rotated - key: ValueKey('${entry.orientationDegrees}_${entry.width}_${entry.height}_${entry.path}'), + key: ValueKey('${entry.rotationDegrees}_${entry.width}_${entry.height}_${entry.path}'), imageProvider: uriImage, // when the full image is ready, we use it in the `loadingBuilder` // we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation diff --git a/lib/widgets/fullscreen/info/metadata_thumbnail.dart b/lib/widgets/fullscreen/info/metadata_thumbnail.dart index 2727d9a0c..f7475e0c2 100644 --- a/lib/widgets/fullscreen/info/metadata_thumbnail.dart +++ b/lib/widgets/fullscreen/info/metadata_thumbnail.dart @@ -39,7 +39,7 @@ class _MetadataThumbnailsState extends State { _loader = MetadataService.getExifThumbnails(uri); break; case MetadataThumbnailSource.xmp: - _loader = MetadataService.getXmpThumbnails(uri); + _loader = MetadataService.getXmpThumbnails(entry); break; } } @@ -50,7 +50,7 @@ class _MetadataThumbnailsState extends State { future: _loader, builder: (context, snapshot) { if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done && snapshot.data.isNotEmpty) { - final turns = (entry.orientationDegrees / 90).round(); + final turns = (entry.rotationDegrees / 90).round(); final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; return Container( alignment: AlignmentDirectional.topStart, diff --git a/lib/widgets/fullscreen/video_view.dart b/lib/widgets/fullscreen/video_view.dart index e541c6e51..86e693f9a 100644 --- a/lib/widgets/fullscreen/video_view.dart +++ b/lib/widgets/fullscreen/video_view.dart @@ -101,7 +101,7 @@ class AvesVideoState extends State { image: UriImage( uri: entry.uri, mimeType: entry.mimeType, - orientationDegrees: entry.orientationDegrees, + rotationDegrees: entry.rotationDegrees, expectedContentLength: entry.sizeBytes, ), width: entry.width.toDouble(),