improved metadata initialization from the media store

flipping (WIP)
This commit is contained in:
Thibault Deckers 2020-10-07 13:38:28 +09:00
parent 8fc0a98579
commit 60d16a3e17
22 changed files with 369 additions and 193 deletions

View file

@ -95,17 +95,17 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
} }
private void getImageEntry(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { private void getImageEntry(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
String uriString = call.argument("uri");
String mimeType = call.argument("mimeType"); 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); result.error("getImageEntry-args", "failed because of missing arguments", null);
return; return;
} }
Uri uri = Uri.parse(uriString);
ImageProvider provider = ImageProviderFactory.getProvider(uri); ImageProvider provider = ImageProviderFactory.getProvider(uri);
if (provider == null) { 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; return;
} }
@ -117,7 +117,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
@Override @Override
public void onFailure(Throwable throwable) { 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());
} }
}); });
} }

View file

@ -48,6 +48,7 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import deckers.thibault.aves.utils.ExifInterfaceHelper; import deckers.thibault.aves.utils.ExifInterfaceHelper;
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper;
import deckers.thibault.aves.utils.MetadataHelper; import deckers.thibault.aves.utils.MetadataHelper;
import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.StorageUtils; import deckers.thibault.aves.utils.StorageUtils;
@ -63,6 +64,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
// catalog metadata // catalog metadata
private static final String KEY_MIME_TYPE = "mimeType"; private static final String KEY_MIME_TYPE = "mimeType";
private static final String KEY_DATE_MILLIS = "dateMillis"; 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_IS_ANIMATED = "isAnimated";
private static final String KEY_LATITUDE = "latitude"; private static final String KEY_LATITUDE = "latitude";
private static final String KEY_LONGITUDE = "longitude"; private static final String KEY_LONGITUDE = "longitude";
@ -146,6 +148,9 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
case "getExifInterfaceMetadata": case "getExifInterfaceMetadata":
new Thread(() -> getExifInterfaceMetadata(call, new MethodResultWrapper(result))).start(); new Thread(() -> getExifInterfaceMetadata(call, new MethodResultWrapper(result))).start();
break; break;
case "getMediaMetadataRetrieverMetadata":
new Thread(() -> getMediaMetadataRetrieverMetadata(call, new MethodResultWrapper(result))).start();
break;
case "getEmbeddedPictures": case "getEmbeddedPictures":
new Thread(() -> getEmbeddedPictures(call, new MethodResultWrapper(result))).start(); new Thread(() -> getEmbeddedPictures(call, new MethodResultWrapper(result))).start();
break; break;
@ -167,12 +172,12 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
private void getAllMetadata(MethodCall call, MethodChannel.Result result) { private void getAllMetadata(MethodCall call, MethodChannel.Result result) {
String mimeType = call.argument("mimeType"); String mimeType = call.argument("mimeType");
String uriString = call.argument("uri"); Uri uri = Uri.parse(call.argument("uri"));
Uri uri = Uri.parse(uriString);
Map<String, Map<String, String>> metadataMap = new HashMap<>(); Map<String, Map<String, String>> metadataMap = new HashMap<>();
boolean foundExif = false; boolean foundExif = false;
if (MimeTypes.isSupportedByMetadataExtractor(mimeType)) {
try (InputStream is = StorageUtils.openInputStream(context, uri)) { try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is); Metadata metadata = ImageMetadataReader.readMetadata(is);
for (Directory dir : metadata.getDirectories()) { for (Directory dir : metadata.getDirectories()) {
@ -203,13 +208,14 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
} }
} }
} catch (XMPException e) { } catch (XMPException e) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uriString, e); Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uri, e);
} }
} }
} }
} }
} catch (Exception | NoClassDefFoundError e) { } catch (Exception | NoClassDefFoundError e) {
Log.w(LOG_TAG, "failed to get metadata by ImageMetadataReader for uri=" + uriString, e); Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=" + uri, e);
}
} }
if (!foundExif) { if (!foundExif) {
@ -218,27 +224,27 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
ExifInterface exif = new ExifInterface(is); ExifInterface exif = new ExifInterface(is);
metadataMap.putAll(ExifInterfaceHelper.describeAll(exif)); metadataMap.putAll(ExifInterfaceHelper.describeAll(exif));
} catch (IOException e) { } 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)) { if (isVideo(mimeType)) {
Map<String, String> videoDir = getVideoAllMetadataByMediaMetadataRetriever(uriString); Map<String, String> videoDir = getVideoAllMetadataByMediaMetadataRetriever(uri);
if (!videoDir.isEmpty()) { if (!videoDir.isEmpty()) {
metadataMap.put("Video", videoDir); metadataMap.put("Video", videoDir);
} }
} }
if (metadataMap.isEmpty()) { 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 { } else {
result.success(metadataMap); result.success(metadataMap);
} }
} }
private Map<String, String> getVideoAllMetadataByMediaMetadataRetriever(String uri) { private Map<String, String> getVideoAllMetadataByMediaMetadataRetriever(Uri uri) {
Map<String, String> dirMap = new HashMap<>(); Map<String, String> dirMap = new HashMap<>();
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, Uri.parse(uri)); MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri);
if (retriever != null) { if (retriever != null) {
try { try {
for (Map.Entry<Integer, String> kv : VIDEO_MEDIA_METADATA_KEYS.entrySet()) { for (Map.Entry<Integer, String> kv : VIDEO_MEDIA_METADATA_KEYS.entrySet()) {
@ -268,7 +274,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
private void getCatalogMetadata(MethodCall call, MethodChannel.Result result) { private void getCatalogMetadata(MethodCall call, MethodChannel.Result result) {
String mimeType = call.argument("mimeType"); String mimeType = call.argument("mimeType");
String uri = call.argument("uri"); Uri uri = Uri.parse(call.argument("uri"));
String extension = call.argument("extension"); String extension = call.argument("extension");
Map<String, Object> metadataMap = new HashMap<>(getCatalogMetadataByImageMetadataReader(uri, mimeType, extension)); Map<String, Object> metadataMap = new HashMap<>(getCatalogMetadataByImageMetadataReader(uri, mimeType, extension));
@ -280,13 +286,12 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
result.success(metadataMap); result.success(metadataMap);
} }
private Map<String, Object> getCatalogMetadataByImageMetadataReader(String uri, String mimeType, String extension) { private Map<String, Object> getCatalogMetadataByImageMetadataReader(Uri uri, String mimeType, String extension) {
Map<String, Object> metadataMap = new HashMap<>(); Map<String, Object> metadataMap = new HashMap<>();
// as of metadata-extractor v2.14.0, MP2T/WBMP files are not supported if (!MimeTypes.isSupportedByMetadataExtractor(mimeType)) return metadataMap;
if (MimeTypes.MP2T.equals(mimeType) || MimeTypes.WBMP.equals(mimeType)) return metadataMap;
try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri))) { try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is); Metadata metadata = ImageMetadataReader.readMetadata(is);
// File type // File type
@ -356,14 +361,14 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
} }
} }
} catch (Exception | NoClassDefFoundError e) { } 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; return metadataMap;
} }
private Map<String, Object> getVideoCatalogMetadataByMediaMetadataRetriever(String uri) { private Map<String, Object> getVideoCatalogMetadataByMediaMetadataRetriever(Uri uri) {
Map<String, Object> metadataMap = new HashMap<>(); Map<String, Object> metadataMap = new HashMap<>();
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, Uri.parse(uri)); MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri);
if (retriever != null) { if (retriever != null) {
try { try {
String dateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE); 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) { private void getOverlayMetadata(MethodCall call, MethodChannel.Result result) {
String mimeType = call.argument("mimeType"); String mimeType = call.argument("mimeType");
String uri = call.argument("uri"); Uri uri = Uri.parse(call.argument("uri"));
Map<String, Object> metadataMap = new HashMap<>(); Map<String, Object> metadataMap = new HashMap<>();
if (isVideo(mimeType)) { if (isVideo(mimeType) || !MimeTypes.isSupportedByMetadataExtractor(mimeType)) {
result.success(metadataMap); result.success(metadataMap);
return; return;
} }
try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri))) { try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is); Metadata metadata = ImageMetadataReader.readMetadata(is);
for (ExifSubIFDDirectory directory : metadata.getDirectoriesOfType(ExifSubIFDDirectory.class)) { for (ExifSubIFDDirectory directory : metadata.getDirectoriesOfType(ExifSubIFDDirectory.class)) {
putDescriptionFromTag(metadataMap, KEY_APERTURE, directory, ExifSubIFDDirectory.TAG_FNUMBER); 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) { private void getContentResolverMetadata(MethodCall call, MethodChannel.Result result) {
String mimeType = call.argument("mimeType"); String mimeType = call.argument("mimeType");
String uriString = call.argument("uri"); Uri uri = Uri.parse(call.argument("uri"));
if (mimeType == null || uriString == null) {
result.error("getContentResolverMetadata-args", "failed because of missing arguments", null);
return;
}
Uri uri = Uri.parse(uriString);
long id = ContentUris.parseId(uri); long id = ContentUris.parseId(uri);
Uri contentUri = uri; Uri contentUri = uri;
if (mimeType.startsWith(MimeTypes.IMAGE)) { if (mimeType.startsWith(MimeTypes.IMAGE)) {
@ -509,13 +509,9 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
} }
private void getExifInterfaceMetadata(MethodCall call, MethodChannel.Result result) { private void getExifInterfaceMetadata(MethodCall call, MethodChannel.Result result) {
String uriString = call.argument("uri"); Uri uri = Uri.parse(call.argument("uri"));
if (uriString == null) {
result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null);
return;
}
try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uriString))) { try (InputStream is = StorageUtils.openInputStream(context, uri)) {
ExifInterface exif = new ExifInterface(is); ExifInterface exif = new ExifInterface(is);
Map<String, Object> metadataMap = new HashMap<>(); Map<String, Object> metadataMap = new HashMap<>();
for (String tag : ExifInterfaceHelper.allTags.keySet()) { for (String tag : ExifInterfaceHelper.allTags.keySet()) {
@ -525,12 +521,39 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
} }
result.success(metadataMap); result.success(metadataMap);
} catch (IOException e) { } 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<String, String> metadataMap = new HashMap<>();
for (Map.Entry<String, Integer> 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) { private void getEmbeddedPictures(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Uri uri = Uri.parse(call.argument("uri")); Uri uri = Uri.parse(call.argument("uri"));
List<byte[]> pictures = new ArrayList<>(); List<byte[]> pictures = new ArrayList<>();
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri); MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri);
if (retriever != null) { if (retriever != null) {
@ -551,6 +574,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
private void getExifThumbnails(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { private void getExifThumbnails(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Uri uri = Uri.parse(call.argument("uri")); Uri uri = Uri.parse(call.argument("uri"));
List<byte[]> thumbnails = new ArrayList<>(); List<byte[]> thumbnails = new ArrayList<>();
try (InputStream is = StorageUtils.openInputStream(context, uri)) { try (InputStream is = StorageUtils.openInputStream(context, uri)) {
ExifInterface exif = new ExifInterface(is); ExifInterface exif = new ExifInterface(is);
@ -560,13 +584,20 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
} catch (IOException e) { } catch (IOException e) {
Log.w(LOG_TAG, "failed to extract exif thumbnail with ExifInterface for uri=" + uri, e); Log.w(LOG_TAG, "failed to extract exif thumbnail with ExifInterface for uri=" + uri, e);
} }
result.success(thumbnails); result.success(thumbnails);
} }
private void getXmpThumbnails(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { private void getXmpThumbnails(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
String mimeType = call.argument("mimeType");
Uri uri = Uri.parse(call.argument("uri")); Uri uri = Uri.parse(call.argument("uri"));
if (uri == null || mimeType == null) {
result.error("getXmpThumbnails-args", "failed because of missing arguments", null);
return;
}
List<byte[]> thumbnails = new ArrayList<>(); List<byte[]> thumbnails = new ArrayList<>();
if (MimeTypes.isSupportedByMetadataExtractor(mimeType)) {
try (InputStream is = StorageUtils.openInputStream(context, uri)) { try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is); Metadata metadata = ImageMetadataReader.readMetadata(is);
for (XmpDirectory dir : metadata.getDirectoriesOfType(XmpDirectory.class)) { for (XmpDirectory dir : metadata.getDirectoriesOfType(XmpDirectory.class)) {
@ -588,6 +619,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
} catch (IOException | ImageProcessingException | NoClassDefFoundError e) { } catch (IOException | ImageProcessingException | NoClassDefFoundError e) {
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e); Log.w(LOG_TAG, "failed to extract xmp thumbnail", e);
} }
}
result.success(thumbnails); result.success(thumbnails);
} }

View file

@ -30,7 +30,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
private Activity activity; private Activity activity;
private Uri uri; private Uri uri;
private String mimeType; private String mimeType;
private int orientationDegrees; private int rotationDegrees;
private EventChannel.EventSink eventSink; private EventChannel.EventSink eventSink;
private Handler handler; private Handler handler;
@ -52,7 +52,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
Map<String, Object> argMap = (Map<String, Object>) arguments; Map<String, Object> argMap = (Map<String, Object>) arguments;
this.mimeType = (String) argMap.get("mimeType"); this.mimeType = (String) argMap.get("mimeType");
this.uri = Uri.parse((String) argMap.get("uri")); 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(); Bitmap bitmap = target.get();
if (bitmap != null) { if (bitmap != null) {
// TODO TLAD use exif orientation to rotate & flip? // TODO TLAD use exif orientation to rotate & flip?
bitmap = TransformationUtils.rotateImage(bitmap, orientationDegrees); bitmap = TransformationUtils.rotateImage(bitmap, rotationDegrees);
ByteArrayOutputStream stream = new ByteArrayOutputStream(); ByteArrayOutputStream stream = new ByteArrayOutputStream();
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes // we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG // Bitmap.CompressFormat.PNG is slower than JPEG

View file

@ -23,7 +23,7 @@ public class AvesImageEntry {
this.mimeType = (String) map.get("mimeType"); this.mimeType = (String) map.get("mimeType");
this.width = (Integer) map.get("width"); this.width = (Integer) map.get("width");
this.height = (Integer) map.get("height"); this.height = (Integer) map.get("height");
this.rotationDegrees = (Integer) map.get("orientationDegrees"); this.rotationDegrees = (Integer) map.get("rotationDegrees");
this.dateModifiedSecs = toLong(map.get("dateModifiedSecs")); this.dateModifiedSecs = toLong(map.get("dateModifiedSecs"));
} }

View file

@ -42,6 +42,8 @@ public class SourceImageEntry {
@Nullable @Nullable
public Integer width, height, rotationDegrees; public Integer width, height, rotationDegrees;
@Nullable @Nullable
public Boolean isFlipped;
@Nullable
public Long sizeBytes; public Long sizeBytes;
@Nullable @Nullable
public Long dateModifiedSecs; public Long dateModifiedSecs;
@ -74,7 +76,8 @@ public class SourceImageEntry {
put("sourceMimeType", sourceMimeType); put("sourceMimeType", sourceMimeType);
put("width", width); put("width", width);
put("height", height); 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("sizeBytes", sizeBytes);
put("title", title); put("title", title);
put("dateModifiedSecs", dateModifiedSecs); put("dateModifiedSecs", dateModifiedSecs);
@ -101,6 +104,10 @@ public class SourceImageEntry {
return width != null && width > 0 && height != null && height > 0; return width != null && width > 0 && height != null && height > 0;
} }
public boolean hasOrientation() {
return rotationDegrees != null;
}
private boolean hasDuration() { private boolean hasDuration() {
return durationMillis != null && durationMillis > 0; return durationMillis != null && durationMillis > 0;
} }
@ -122,8 +129,9 @@ public class SourceImageEntry {
// expects entry with: uri, mimeType // expects entry with: uri, mimeType
// finds: width, height, orientation/rotation, date, title, duration // finds: width, height, orientation/rotation, date, title, duration
public SourceImageEntry fillPreCatalogMetadata(@NonNull Context context) { public SourceImageEntry fillPreCatalogMetadata(@NonNull Context context) {
if (isSvg()) return this;
fillByMediaMetadataRetriever(context); fillByMediaMetadataRetriever(context);
if (hasSize() && (!isVideo() || hasDuration())) return this; if (hasSize() && hasOrientation() && (!isVideo() || hasDuration())) return this;
fillByMetadataExtractor(context); fillByMetadataExtractor(context);
if (hasSize()) return this; if (hasSize()) return this;
fillByBitmapDecode(context); fillByBitmapDecode(context);
@ -133,6 +141,8 @@ public class SourceImageEntry {
// expects entry with: uri, mimeType // expects entry with: uri, mimeType
// finds: width, height, orientation/rotation, date, title, duration // finds: width, height, orientation/rotation, date, title, duration
private void fillByMediaMetadataRetriever(@NonNull Context context) { private void fillByMediaMetadataRetriever(@NonNull Context context) {
if (isImage()) return;
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri); MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri);
if (retriever != null) { if (retriever != null) {
try { try {
@ -185,23 +195,25 @@ public class SourceImageEntry {
// expects entry with: uri, mimeType // expects entry with: uri, mimeType
// finds: width, height, orientation, date // finds: width, height, orientation, date
private void fillByMetadataExtractor(@NonNull Context context) { private void fillByMetadataExtractor(@NonNull Context context) {
if (isSvg()) return; if (!MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) return;
try (InputStream is = StorageUtils.openInputStream(context, uri)) { try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is); Metadata metadata = ImageMetadataReader.readMetadata(is);
switch (sourceMimeType) { // do not switch on specific mime types, as the reported mime type could be wrong
case MimeTypes.JPEG: // (e.g. PNG registered as JPG)
for (JpegDirectory dir : metadata.getDirectoriesOfType(JpegDirectory.class)) { if (isVideo()) {
if (dir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) { for (AviDirectory dir : metadata.getDirectoriesOfType(AviDirectory.class)) {
width = dir.getInt(JpegDirectory.TAG_IMAGE_WIDTH); if (dir.containsTag(AviDirectory.TAG_WIDTH)) {
width = dir.getInt(AviDirectory.TAG_WIDTH);
} }
if (dir.containsTag(JpegDirectory.TAG_IMAGE_HEIGHT)) { if (dir.containsTag(AviDirectory.TAG_HEIGHT)) {
height = dir.getInt(JpegDirectory.TAG_IMAGE_HEIGHT); height = dir.getInt(AviDirectory.TAG_HEIGHT);
}
if (dir.containsTag(AviDirectory.TAG_DURATION)) {
durationMillis = dir.getLong(AviDirectory.TAG_DURATION);
} }
} }
break;
case MimeTypes.MP4:
for (Mp4VideoDirectory dir : metadata.getDirectoriesOfType(Mp4VideoDirectory.class)) { for (Mp4VideoDirectory dir : metadata.getDirectoriesOfType(Mp4VideoDirectory.class)) {
if (dir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) { if (dir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) {
width = dir.getInt(Mp4VideoDirectory.TAG_WIDTH); width = dir.getInt(Mp4VideoDirectory.TAG_WIDTH);
@ -215,21 +227,15 @@ public class SourceImageEntry {
durationMillis = dir.getLong(Mp4Directory.TAG_DURATION); durationMillis = dir.getLong(Mp4Directory.TAG_DURATION);
} }
} }
break; } else {
case MimeTypes.AVI: for (JpegDirectory dir : metadata.getDirectoriesOfType(JpegDirectory.class)) {
for (AviDirectory dir : metadata.getDirectoriesOfType(AviDirectory.class)) { if (dir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) {
if (dir.containsTag(AviDirectory.TAG_WIDTH)) { width = dir.getInt(JpegDirectory.TAG_IMAGE_WIDTH);
width = dir.getInt(AviDirectory.TAG_WIDTH);
} }
if (dir.containsTag(AviDirectory.TAG_HEIGHT)) { if (dir.containsTag(JpegDirectory.TAG_IMAGE_HEIGHT)) {
height = dir.getInt(AviDirectory.TAG_HEIGHT); height = dir.getInt(JpegDirectory.TAG_IMAGE_HEIGHT);
}
if (dir.containsTag(AviDirectory.TAG_DURATION)) {
durationMillis = dir.getLong(AviDirectory.TAG_DURATION);
} }
} }
break;
case MimeTypes.PSD:
for (PsdHeaderDirectory dir : metadata.getDirectoriesOfType(PsdHeaderDirectory.class)) { for (PsdHeaderDirectory dir : metadata.getDirectoriesOfType(PsdHeaderDirectory.class)) {
if (dir.containsTag(PsdHeaderDirectory.TAG_IMAGE_WIDTH)) { if (dir.containsTag(PsdHeaderDirectory.TAG_IMAGE_WIDTH)) {
width = dir.getInt(PsdHeaderDirectory.TAG_IMAGE_WIDTH); width = dir.getInt(PsdHeaderDirectory.TAG_IMAGE_WIDTH);
@ -238,9 +244,8 @@ public class SourceImageEntry {
height = dir.getInt(PsdHeaderDirectory.TAG_IMAGE_HEIGHT); height = dir.getInt(PsdHeaderDirectory.TAG_IMAGE_HEIGHT);
} }
} }
break;
}
// EXIF, if defined, should override metadata found in other directories
for (ExifIFD0Directory dir : metadata.getDirectoriesOfType(ExifIFD0Directory.class)) { for (ExifIFD0Directory dir : metadata.getDirectoriesOfType(ExifIFD0Directory.class)) {
if (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_WIDTH)) { if (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_WIDTH)) {
width = dir.getInt(ExifIFD0Directory.TAG_IMAGE_WIDTH); width = dir.getInt(ExifIFD0Directory.TAG_IMAGE_WIDTH);
@ -251,11 +256,13 @@ public class SourceImageEntry {
if (dir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) { if (dir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
int exifOrientation = dir.getInt(ExifIFD0Directory.TAG_ORIENTATION); int exifOrientation = dir.getInt(ExifIFD0Directory.TAG_ORIENTATION);
rotationDegrees = MetadataHelper.getRotationDegreesForExifCode(exifOrientation); rotationDegrees = MetadataHelper.getRotationDegreesForExifCode(exifOrientation);
isFlipped = MetadataHelper.isFlippedForExifCode(exifOrientation);
} }
if (dir.containsTag(ExifIFD0Directory.TAG_DATETIME)) { if (dir.containsTag(ExifIFD0Directory.TAG_DATETIME)) {
sourceDateTakenMillis = dir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime(); sourceDateTakenMillis = dir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime();
} }
} }
}
} catch (IOException | ImageProcessingException | MetadataException | NoClassDefFoundError e) { } catch (IOException | ImageProcessingException | MetadataException | NoClassDefFoundError e) {
// ignore // ignore
} }
@ -264,8 +271,6 @@ public class SourceImageEntry {
// expects entry with: uri // expects entry with: uri
// finds: width, height // finds: width, height
private void fillByBitmapDecode(@NonNull Context context) { private void fillByBitmapDecode(@NonNull Context context) {
if (isSvg()) return;
try (InputStream is = StorageUtils.openInputStream(context, uri)) { try (InputStream is = StorageUtils.openInputStream(context, uri)) {
BitmapFactory.Options options = new BitmapFactory.Options(); BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true; options.inJustDecodeBounds = true;

View file

@ -130,7 +130,8 @@ public abstract class ImageProvider {
// copy the edited temporary file back to the original // copy the edited temporary file back to the original
DocumentFileCompat.fromFile(new File(editablePath)).copyTo(originalDocumentFile); 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) { } catch (IOException e) {
callback.onFailure(e); callback.onFailure(e);
return; return;
@ -147,7 +148,7 @@ public abstract class ImageProvider {
// values.put(MediaStore.MediaColumns.IS_PENDING, 0); // values.put(MediaStore.MediaColumns.IS_PENDING, 0);
// } // }
// // uses MediaStore.Images.Media instead of MediaStore.MediaColumns for APIs < Q // // 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 // // TODO TLAD catch RecoverableSecurityException
// int updatedRowCount = contentResolver.update(uri, values, null, null); // int updatedRowCount = contentResolver.update(uri, values, null, null);
// if (updatedRowCount > 0) { // if (updatedRowCount > 0) {

View file

@ -124,9 +124,14 @@ public class MediaStoreImageProvider extends ImageProvider {
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
private int fetchFrom(final Context context, NewEntryChecker newEntryChecker, NewEntryHandler newEntryHandler, final Uri contentUri, String[] projection) { private int fetchFrom(final Context context, NewEntryChecker newEntryChecker, NewEntryHandler newEntryHandler, final Uri contentUri, String[] projection) {
int newEntryCount = 0; int newEntryCount = 0;
final boolean needDuration = projection == VIDEO_PROJECTION;
final String orderBy = MediaStore.MediaColumns.DATE_MODIFIED + " DESC"; 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 { try {
Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, orderBy); Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, orderBy);
if (cursor != null) { if (cursor != null) {
@ -159,11 +164,18 @@ public class MediaStoreImageProvider extends ImageProvider {
int height = cursor.getInt(heightColumn); int height = cursor.getInt(heightColumn);
final long durationMillis = durationColumn != -1 ? cursor.getLong(durationColumn) : 0; 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<String, Object> entryMap = new HashMap<String, Object>() {{ Map<String, Object> entryMap = new HashMap<String, Object>() {{
put("uri", itemUri.toString()); put("uri", itemUri.toString());
put("path", path); put("path", path);
put("sourceMimeType", mimeType); put("sourceMimeType", mimeType);
put("rotationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0);
put("sizeBytes", cursor.getLong(sizeColumn)); put("sizeBytes", cursor.getLong(sizeColumn));
put("title", cursor.getString(titleColumn)); put("title", cursor.getString(titleColumn));
put("dateModifiedSecs", dateModifiedSecs); put("dateModifiedSecs", dateModifiedSecs);
@ -174,8 +186,11 @@ public class MediaStoreImageProvider extends ImageProvider {
entryMap.put("width", width); entryMap.put("width", width);
entryMap.put("height", height); entryMap.put("height", height);
entryMap.put("durationMillis", durationMillis); 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, // some images are incorrectly registered in the Media Store,
// they are valid but miss some attributes, such as width, height, orientation // they are valid but miss some attributes, such as width, height, orientation
SourceImageEntry entry = new SourceImageEntry(entryMap).fillPreCatalogMetadata(context); SourceImageEntry entry = new SourceImageEntry(entryMap).fillPreCatalogMetadata(context);

View file

@ -0,0 +1,51 @@
package deckers.thibault.aves.utils
import android.media.MediaMetadataRetriever
import android.os.Build
object MediaMetadataRetrieverHelper {
@JvmField
val allKeys: Map<String, Int> = 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,
))
}
}
}

View file

@ -50,6 +50,13 @@ object MimeTypes {
const val MOV = "video/quicktime" const val MOV = "video/quicktime"
const val MP2T = "video/mp2t" const val MP2T = "video/mp2t"
const val MP4 = "video/mp4" 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 @JvmStatic
fun getMimeTypeForExtension(extension: String?): String? = when (extension?.toLowerCase(Locale.ROOT)) { fun getMimeTypeForExtension(extension: String?): String? = when (extension?.toLowerCase(Locale.ROOT)) {

View file

@ -23,7 +23,7 @@ class ImageEntry {
final String sourceMimeType; final String sourceMimeType;
int width; int width;
int height; int height;
int orientationDegrees; int rotationDegrees;
final int sizeBytes; final int sizeBytes;
String sourceTitle; String sourceTitle;
int _dateModifiedSecs; int _dateModifiedSecs;
@ -42,7 +42,7 @@ class ImageEntry {
this.sourceMimeType, this.sourceMimeType,
@required this.width, @required this.width,
@required this.height, @required this.height,
this.orientationDegrees, this.rotationDegrees,
this.sizeBytes, this.sizeBytes,
this.sourceTitle, this.sourceTitle,
int dateModifiedSecs, int dateModifiedSecs,
@ -68,7 +68,7 @@ class ImageEntry {
sourceMimeType: sourceMimeType, sourceMimeType: sourceMimeType,
width: width, width: width,
height: height, height: height,
orientationDegrees: orientationDegrees, rotationDegrees: rotationDegrees,
sizeBytes: sizeBytes, sizeBytes: sizeBytes,
sourceTitle: sourceTitle, sourceTitle: sourceTitle,
dateModifiedSecs: dateModifiedSecs, dateModifiedSecs: dateModifiedSecs,
@ -90,7 +90,7 @@ class ImageEntry {
sourceMimeType: map['sourceMimeType'] as String, sourceMimeType: map['sourceMimeType'] as String,
width: map['width'] as int ?? 0, width: map['width'] as int ?? 0,
height: map['height'] 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, sizeBytes: map['sizeBytes'] as int,
sourceTitle: map['title'] as String, sourceTitle: map['title'] as String,
dateModifiedSecs: map['dateModifiedSecs'] as int, dateModifiedSecs: map['dateModifiedSecs'] as int,
@ -108,7 +108,7 @@ class ImageEntry {
'sourceMimeType': sourceMimeType, 'sourceMimeType': sourceMimeType,
'width': width, 'width': width,
'height': height, 'height': height,
'orientationDegrees': orientationDegrees, 'rotationDegrees': rotationDegrees,
'sizeBytes': sizeBytes, 'sizeBytes': sizeBytes,
'title': sourceTitle, 'title': sourceTitle,
'dateModifiedSecs': dateModifiedSecs, 'dateModifiedSecs': dateModifiedSecs,
@ -171,6 +171,8 @@ class ImageEntry {
bool get isCatalogued => _catalogMetadata != null; bool get isCatalogued => _catalogMetadata != null;
bool get isFlipped => _catalogMetadata?.isFlipped ?? false;
bool get isAnimated => _catalogMetadata?.isAnimated ?? false; bool get isAnimated => _catalogMetadata?.isAnimated ?? false;
bool get canEdit => path != null; 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 { double get displayAspectRatio {
if (width == 0 || height == 0) return 1; if (width == 0 || height == 0) return 1;
@ -369,8 +371,8 @@ class ImageEntry {
if (width is int) this.width = width; if (width is int) this.width = width;
final height = newFields['height']; final height = newFields['height'];
if (height is int) this.height = height; if (height is int) this.height = height;
final orientationDegrees = newFields['orientationDegrees']; final rotationDegrees = newFields['rotationDegrees'];
if (orientationDegrees is int) this.orientationDegrees = orientationDegrees; if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
imageChangeNotifier.notifyListeners(); imageChangeNotifier.notifyListeners();
return true; return true;

View file

@ -29,7 +29,7 @@ class DateMetadata {
class CatalogMetadata { class CatalogMetadata {
final int contentId, dateMillis, videoRotation; final int contentId, dateMillis, videoRotation;
final bool isAnimated; final bool isFlipped, isAnimated;
final String mimeType, xmpSubjects, xmpTitleDescription; final String mimeType, xmpSubjects, xmpTitleDescription;
final double latitude, longitude; final double latitude, longitude;
Address address; Address address;
@ -38,6 +38,7 @@ class CatalogMetadata {
this.contentId, this.contentId,
this.mimeType, this.mimeType,
this.dateMillis, this.dateMillis,
this.isFlipped,
this.isAnimated, this.isAnimated,
this.videoRotation, this.videoRotation,
this.xmpSubjects, this.xmpSubjects,
@ -56,6 +57,7 @@ class CatalogMetadata {
contentId: contentId ?? this.contentId, contentId: contentId ?? this.contentId,
mimeType: mimeType, mimeType: mimeType,
dateMillis: dateMillis, dateMillis: dateMillis,
isFlipped: isFlipped,
isAnimated: isAnimated, isAnimated: isAnimated,
videoRotation: videoRotation, videoRotation: videoRotation,
xmpSubjects: xmpSubjects, xmpSubjects: xmpSubjects,
@ -66,11 +68,13 @@ class CatalogMetadata {
} }
factory CatalogMetadata.fromMap(Map map, {bool boolAsInteger = false}) { factory CatalogMetadata.fromMap(Map map, {bool boolAsInteger = false}) {
final isFlipped = map['isFlipped'] ?? (boolAsInteger ? 0 : false);
final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false); final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false);
return CatalogMetadata( return CatalogMetadata(
contentId: map['contentId'], contentId: map['contentId'],
mimeType: map['mimeType'], mimeType: map['mimeType'],
dateMillis: map['dateMillis'] ?? 0, dateMillis: map['dateMillis'] ?? 0,
isFlipped: boolAsInteger ? isFlipped != 0 : isFlipped,
isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated, isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated,
videoRotation: map['videoRotation'] ?? 0, videoRotation: map['videoRotation'] ?? 0,
xmpSubjects: map['xmpSubjects'] ?? '', xmpSubjects: map['xmpSubjects'] ?? '',
@ -84,6 +88,7 @@ class CatalogMetadata {
'contentId': contentId, 'contentId': contentId,
'mimeType': mimeType, 'mimeType': mimeType,
'dateMillis': dateMillis, 'dateMillis': dateMillis,
'isFlipped': boolAsInteger ? (isFlipped ? 1 : 0) : isFlipped,
'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated, 'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated,
'videoRotation': videoRotation, 'videoRotation': videoRotation,
'xmpSubjects': xmpSubjects, 'xmpSubjects': xmpSubjects,
@ -94,7 +99,7 @@ class CatalogMetadata {
@override @override
String toString() { 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}';
} }
} }

View file

@ -33,7 +33,7 @@ class MetadataDb {
', sourceMimeType TEXT' ', sourceMimeType TEXT'
', width INTEGER' ', width INTEGER'
', height INTEGER' ', height INTEGER'
', orientationDegrees INTEGER' ', rotationDegrees INTEGER'
', sizeBytes INTEGER' ', sizeBytes INTEGER'
', title TEXT' ', title TEXT'
', dateModifiedSecs INTEGER' ', dateModifiedSecs INTEGER'
@ -48,6 +48,7 @@ class MetadataDb {
'contentId INTEGER PRIMARY KEY' 'contentId INTEGER PRIMARY KEY'
', mimeType TEXT' ', mimeType TEXT'
', dateMillis INTEGER' ', dateMillis INTEGER'
', isFlipped INTEGER'
', isAnimated INTEGER' ', isAnimated INTEGER'
', videoRotation INTEGER' ', videoRotation INTEGER'
', xmpSubjects TEXT' ', xmpSubjects TEXT'
@ -68,7 +69,43 @@ class MetadataDb {
', path TEXT' ', 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,
); );
} }

View file

@ -23,7 +23,7 @@ class ImageFileService {
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'width': entry.width, 'width': entry.width,
'height': entry.height, 'height': entry.height,
'orientationDegrees': entry.orientationDegrees, 'rotationDegrees': entry.rotationDegrees,
'dateModifiedSecs': entry.dateModifiedSecs, 'dateModifiedSecs': entry.dateModifiedSecs,
}; };
} }
@ -66,7 +66,7 @@ class ImageFileService {
return null; return null;
} }
static Future<Uint8List> getImage(String uri, String mimeType, {int orientationDegrees, int expectedContentLength, BytesReceivedCallback onBytesReceived}) { static Future<Uint8List> getImage(String uri, String mimeType, {int rotationDegrees, int expectedContentLength, BytesReceivedCallback onBytesReceived}) {
try { try {
final completer = Completer<Uint8List>.sync(); final completer = Completer<Uint8List>.sync();
final sink = _OutputBuffer(); final sink = _OutputBuffer();
@ -74,7 +74,7 @@ class ImageFileService {
byteChannel.receiveBroadcastStream(<String, dynamic>{ byteChannel.receiveBroadcastStream(<String, dynamic>{
'uri': uri, 'uri': uri,
'mimeType': mimeType, 'mimeType': mimeType,
'orientationDegrees': orientationDegrees ?? 0, 'rotationDegrees': rotationDegrees ?? 0,
}).listen( }).listen(
(data) { (data) {
final chunk = data as Uint8List; final chunk = data as Uint8List;
@ -183,7 +183,7 @@ class ImageFileService {
static Future<Map> rotate(ImageEntry entry, {@required bool clockwise}) async { static Future<Map> rotate(ImageEntry entry, {@required bool clockwise}) async {
try { try {
// return map with: 'width' 'height' 'orientationDegrees' (all optional) // return map with: 'width' 'height' 'rotationDegrees' (all optional)
final result = await platform.invokeMethod('rotate', <String, dynamic>{ final result = await platform.invokeMethod('rotate', <String, dynamic>{
'entry': _toPlatformEntryMap(entry), 'entry': _toPlatformEntryMap(entry),
'clockwise': clockwise, 'clockwise': clockwise,

View file

@ -33,6 +33,7 @@ class MetadataService {
// return map with: // return map with:
// 'mimeType': MIME type as reported by metadata extractors, not Media Store (string) // 'mimeType': MIME type as reported by metadata extractors, not Media Store (string)
// 'dateMillis': date taken in milliseconds since Epoch (long) // 'dateMillis': date taken in milliseconds since Epoch (long)
// 'isFlipped': flipped according to EXIF orientation (bool)
// 'isAnimated': animated gif/webp (bool) // 'isAnimated': animated gif/webp (bool)
// 'latitude': latitude (double) // 'latitude': latitude (double)
// 'longitude': longitude (double) // 'longitude': longitude (double)
@ -103,6 +104,19 @@ class MetadataService {
return {}; return {};
} }
static Future<Map> getMediaMetadataRetrieverMetadata(ImageEntry entry) async {
try {
// return map with all data available from the MediaMetadataRetriever
final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{
'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<List<Uint8List>> getEmbeddedPictures(String uri) async { static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
try { try {
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{ final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
@ -127,10 +141,11 @@ class MetadataService {
return []; return [];
} }
static Future<List<Uint8List>> getXmpThumbnails(String uri) async { static Future<List<Uint8List>> getXmpThumbnails(ImageEntry entry) async {
try { try {
final result = await platform.invokeMethod('getXmpThumbnails', <String, dynamic>{ final result = await platform.invokeMethod('getXmpThumbnails', <String, dynamic>{
'uri': uri, 'mimeType': entry.mimeType,
'uri': entry.uri,
}); });
return (result as List).cast<Uint8List>(); return (result as List).cast<Uint8List>();
} on PlatformException catch (e) { } on PlatformException catch (e) {

View file

@ -138,7 +138,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
final imageProvider = UriImage( final imageProvider = UriImage(
uri: entry.uri, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
orientationDegrees: entry.orientationDegrees, rotationDegrees: entry.rotationDegrees,
expectedContentLength: entry.sizeBytes, expectedContentLength: entry.sizeBytes,
); );
if (imageCache.statusForKey(imageProvider).keepAlive) { if (imageCache.statusForKey(imageProvider).keepAlive) {

View file

@ -75,13 +75,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
Future<void> _print(ImageEntry entry) async { Future<void> _print(ImageEntry entry) async {
final uri = entry.uri; final uri = entry.uri;
final mimeType = entry.mimeType; final mimeType = entry.mimeType;
final orientationDegrees = entry.orientationDegrees; final rotationDegrees = entry.rotationDegrees;
final documentName = entry.bestTitle ?? 'Aves'; final documentName = entry.bestTitle ?? 'Aves';
final doc = pdf.Document(title: documentName); final doc = pdf.Document(title: documentName);
PdfImage pdfImage; PdfImage pdfImage;
if (entry.isSvg) { 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) { if (bytes != null && bytes.isNotEmpty) {
final svgRoot = await svg.fromSvgBytes(bytes, uri); final svgRoot = await svg.fromSvgBytes(bytes, uri);
final viewBox = svgRoot.viewport.viewBox; final viewBox = svgRoot.viewport.viewBox;
@ -100,7 +100,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
image: UriImage( image: UriImage(
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
orientationDegrees: orientationDegrees, rotationDegrees: rotationDegrees,
), ),
); );
} }

View file

@ -10,14 +10,14 @@ class UriImage extends ImageProvider<UriImage> {
const UriImage({ const UriImage({
@required this.uri, @required this.uri,
@required this.mimeType, @required this.mimeType,
@required this.orientationDegrees, @required this.rotationDegrees,
this.expectedContentLength, this.expectedContentLength,
this.scale = 1.0, this.scale = 1.0,
}) : assert(uri != null), }) : assert(uri != null),
assert(scale != null); assert(scale != null);
final String uri, mimeType; final String uri, mimeType;
final int orientationDegrees, expectedContentLength; final int rotationDegrees, expectedContentLength;
final double scale; final double scale;
@override @override
@ -46,7 +46,7 @@ class UriImage extends ImageProvider<UriImage> {
final bytes = await ImageFileService.getImage( final bytes = await ImageFileService.getImage(
uri, uri,
mimeType, mimeType,
orientationDegrees: orientationDegrees, rotationDegrees: rotationDegrees,
expectedContentLength: expectedContentLength, expectedContentLength: expectedContentLength,
onBytesReceived: (cumulative, total) { onBytesReceived: (cumulative, total) {
chunkEvents.add(ImageChunkEvent( chunkEvents.add(ImageChunkEvent(

View file

@ -30,7 +30,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
Future<DateMetadata> _dbDateLoader; Future<DateMetadata> _dbDateLoader;
Future<CatalogMetadata> _dbMetadataLoader; Future<CatalogMetadata> _dbMetadataLoader;
Future<AddressDetails> _dbAddressLoader; Future<AddressDetails> _dbAddressLoader;
Future<Map> _contentResolverMetadataLoader, _exifInterfaceMetadataLoader; Future<Map> _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader;
ImageEntry get entry => widget.entry; ImageEntry get entry => widget.entry;
@ -89,25 +89,21 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
'sourceTitle': '${entry.sourceTitle}', 'sourceTitle': '${entry.sourceTitle}',
'sourceMimeType': '${entry.sourceMimeType}', 'sourceMimeType': '${entry.sourceMimeType}',
'mimeType': '${entry.mimeType}', 'mimeType': '${entry.mimeType}',
'mimeTypeAnySubtype': '${entry.mimeTypeAnySubtype}',
}), }),
Divider(), Divider(),
InfoRowGroup({ InfoRowGroup({
'dateModifiedSecs': toDateValue(entry.dateModifiedSecs, factor: 1000), 'dateModifiedSecs': toDateValue(entry.dateModifiedSecs, factor: 1000),
'sourceDateTakenMillis': toDateValue(entry.sourceDateTakenMillis), 'sourceDateTakenMillis': toDateValue(entry.sourceDateTakenMillis),
'bestDate': '${entry.bestDate}', 'bestDate': '${entry.bestDate}',
'monthTaken': '${entry.monthTaken}',
'dayTaken': '${entry.dayTaken}',
}), }),
Divider(), Divider(),
InfoRowGroup({ InfoRowGroup({
'width': '${entry.width}', 'width': '${entry.width}',
'height': '${entry.height}', 'height': '${entry.height}',
'orientationDegrees': '${entry.orientationDegrees}', 'rotationDegrees': '${entry.rotationDegrees}',
'portrait': '${entry.portrait}', 'portrait': '${entry.portrait}',
'displayAspectRatio': '${entry.displayAspectRatio}', 'displayAspectRatio': '${entry.displayAspectRatio}',
'displaySize': '${entry.displaySize}', 'displaySize': '${entry.displaySize}',
'megaPixels': '${entry.megaPixels}',
}), }),
Divider(), Divider(),
InfoRowGroup({ InfoRowGroup({
@ -122,8 +118,10 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
'isPhoto': '${entry.isPhoto}', 'isPhoto': '${entry.isPhoto}',
'isVideo': '${entry.isVideo}', 'isVideo': '${entry.isVideo}',
'isCatalogued': '${entry.isCatalogued}', 'isCatalogued': '${entry.isCatalogued}',
'isFlipped': '${entry.isFlipped}',
'isAnimated': '${entry.isAnimated}', 'isAnimated': '${entry.isAnimated}',
'canEdit': '${entry.canEdit}', 'canEdit': '${entry.canEdit}',
'canEditExif': '${entry.canEditExif}',
'canPrint': '${entry.canPrint}', 'canPrint': '${entry.canPrint}',
'canRotate': '${entry.canRotate}', 'canRotate': '${entry.canRotate}',
'xmpSubjects': '${entry.xmpSubjects}', 'xmpSubjects': '${entry.xmpSubjects}',
@ -204,6 +202,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
InfoRowGroup({ InfoRowGroup({
'mimeType': '${data.mimeType}', 'mimeType': '${data.mimeType}',
'dateMillis': '${data.dateMillis}', 'dateMillis': '${data.dateMillis}',
'isFlipped': '${data.isFlipped}',
'isAnimated': '${data.isAnimated}', 'isAnimated': '${data.isAnimated}',
'videoRotation': '${data.videoRotation}', 'videoRotation': '${data.videoRotation}',
'latitude': '${data.latitude}', 'latitude': '${data.latitude}',
@ -245,6 +244,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
'contentId': '${catalog.contentId}', 'contentId': '${catalog.contentId}',
'mimeType': '${catalog.mimeType}', 'mimeType': '${catalog.mimeType}',
'dateMillis': '${catalog.dateMillis}', 'dateMillis': '${catalog.dateMillis}',
'isFlipped': '${catalog.isFlipped}',
'isAnimated': '${catalog.isAnimated}', 'isAnimated': '${catalog.isAnimated}',
'videoRotation': '${catalog.videoRotation}', 'videoRotation': '${catalog.videoRotation}',
'latitude': '${catalog.latitude}', 'latitude': '${catalog.latitude}',
@ -281,7 +281,8 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
return AvesExpansionTile( return AvesExpansionTile(
title: title, title: title,
children: [ children: [
Padding( Container(
alignment: AlignmentDirectional.topStart,
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup( child: InfoRowGroup(
data, data,
@ -303,6 +304,10 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
future: _exifInterfaceMetadataLoader, future: _exifInterfaceMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'), builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'),
), ),
FutureBuilder<Map>(
future: _mediaMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'),
),
], ],
); );
} }
@ -313,6 +318,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
_dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null)); _dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry); _contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry);
_exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry); _exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry);
_mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry);
setState(() {}); setState(() {});
} }
} }

View file

@ -544,7 +544,7 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
await UriImage( await UriImage(
uri: entry.uri, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
orientationDegrees: entry.orientationDegrees, rotationDegrees: entry.rotationDegrees,
).evict(); ).evict();
// evict low quality thumbnail (without specified extents) // evict low quality thumbnail (without specified extents)
await ThumbnailProvider(entry: entry).evict(); await ThumbnailProvider(entry: entry).evict();

View file

@ -97,12 +97,12 @@ class ImageView extends StatelessWidget {
final uriImage = UriImage( final uriImage = UriImage(
uri: entry.uri, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
orientationDegrees: entry.orientationDegrees, rotationDegrees: entry.rotationDegrees,
expectedContentLength: entry.sizeBytes, expectedContentLength: entry.sizeBytes,
); );
child = PhotoView( child = PhotoView(
// key includes size and orientation to refresh when the image is rotated // 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, imageProvider: uriImage,
// when the full image is ready, we use it in the `loadingBuilder` // 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 // we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation

View file

@ -39,7 +39,7 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
_loader = MetadataService.getExifThumbnails(uri); _loader = MetadataService.getExifThumbnails(uri);
break; break;
case MetadataThumbnailSource.xmp: case MetadataThumbnailSource.xmp:
_loader = MetadataService.getXmpThumbnails(uri); _loader = MetadataService.getXmpThumbnails(entry);
break; break;
} }
} }
@ -50,7 +50,7 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
future: _loader, future: _loader,
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done && snapshot.data.isNotEmpty) { 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; final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Container( return Container(
alignment: AlignmentDirectional.topStart, alignment: AlignmentDirectional.topStart,

View file

@ -101,7 +101,7 @@ class AvesVideoState extends State<AvesVideo> {
image: UriImage( image: UriImage(
uri: entry.uri, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
orientationDegrees: entry.orientationDegrees, rotationDegrees: entry.rotationDegrees,
expectedContentLength: entry.sizeBytes, expectedContentLength: entry.sizeBytes,
), ),
width: entry.width.toDouble(), width: entry.width.toDouble(),