rotate/flip improvements (WIP)

This commit is contained in:
Thibault Deckers 2020-10-08 14:51:43 +09:00
parent ff58b64773
commit ae413dd82c
18 changed files with 477 additions and 380 deletions

View file

@ -131,7 +131,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
ContentResolver resolver = activity.getContentResolver(); ContentResolver resolver = activity.getContentResolver();
Bitmap bitmap = resolver.loadThumbnail(entry.uri, new Size(width, height), null); Bitmap bitmap = resolver.loadThumbnail(entry.uri, new Size(width, height), null);
String mimeType = entry.mimeType; String mimeType = entry.mimeType;
if (MimeTypes.DNG.equals(mimeType)) { if (MimeTypes.needRotationAfterContentResolverThumbnail(mimeType)) {
bitmap = rotateBitmap(bitmap, entry.rotationDegrees); bitmap = rotateBitmap(bitmap, entry.rotationDegrees);
} }
return bitmap; return bitmap;
@ -186,7 +186,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
try { try {
Bitmap bitmap = target.get(); Bitmap bitmap = target.get();
String mimeType = entry.mimeType; String mimeType = entry.mimeType;
if (MimeTypes.DNG.equals(mimeType) || MimeTypes.HEIC.equals(mimeType) || MimeTypes.HEIF.equals(mimeType)) { if (MimeTypes.needRotationAfterGlide(mimeType)) {
bitmap = rotateBitmap(bitmap, entry.rotationDegrees); bitmap = rotateBitmap(bitmap, entry.rotationDegrees);
} }
return bitmap; return bitmap;

View file

@ -11,7 +11,6 @@ import android.text.format.Formatter;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.exifinterface.media.ExifInterface; import androidx.exifinterface.media.ExifInterface;
import com.adobe.internal.xmp.XMPException; import com.adobe.internal.xmp.XMPException;
@ -64,11 +63,11 @@ 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_IS_FLIPPED = "isFlipped";
private static final String KEY_ROTATION_DEGREES = "rotationDegrees";
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";
private static final String KEY_VIDEO_ROTATION = "videoRotation";
private static final String KEY_XMP_SUBJECTS = "xmpSubjects"; private static final String KEY_XMP_SUBJECTS = "xmpSubjects";
private static final String KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"; private static final String KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription";
@ -166,10 +165,6 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
} }
} }
private boolean isVideo(@Nullable String mimeType) {
return mimeType != null && mimeType.startsWith(MimeTypes.VIDEO);
}
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");
Uri uri = Uri.parse(call.argument("uri")); Uri uri = Uri.parse(call.argument("uri"));
@ -228,7 +223,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
} }
} }
if (isVideo(mimeType)) { if (MimeTypes.isVideo(mimeType)) {
Map<String, String> videoDir = getVideoAllMetadataByMediaMetadataRetriever(uri); Map<String, String> videoDir = getVideoAllMetadataByMediaMetadataRetriever(uri);
if (!videoDir.isEmpty()) { if (!videoDir.isEmpty()) {
metadataMap.put("Video", videoDir); metadataMap.put("Video", videoDir);
@ -277,8 +272,8 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
Uri uri = Uri.parse(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<>(getCatalogMetadataByMetadataExtractor(uri, mimeType, extension));
if (isVideo(mimeType)) { if (MimeTypes.isVideo(mimeType)) {
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri)); metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri));
} }
@ -286,83 +281,119 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
result.success(metadataMap); result.success(metadataMap);
} }
private Map<String, Object> getCatalogMetadataByImageMetadataReader(Uri uri, String mimeType, String extension) { private Map<String, Object> getCatalogMetadataByMetadataExtractor(Uri uri, String mimeType, String extension) {
Map<String, Object> metadataMap = new HashMap<>(); Map<String, Object> metadataMap = new HashMap<>();
if (!MimeTypes.isSupportedByMetadataExtractor(mimeType)) return metadataMap; boolean foundExif = false;
try (InputStream is = StorageUtils.openInputStream(context, uri)) { if (MimeTypes.isSupportedByMetadataExtractor(mimeType)) {
Metadata metadata = ImageMetadataReader.readMetadata(is); try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
// File type // File type
for (FileTypeDirectory dir : metadata.getDirectoriesOfType(FileTypeDirectory.class)) { for (FileTypeDirectory dir : metadata.getDirectoriesOfType(FileTypeDirectory.class)) {
// `metadata-extractor` sometimes detect the the wrong mime type (e.g. `pef` file as `tiff`) // `metadata-extractor` sometimes detect the the wrong mime type (e.g. `pef` file as `tiff`)
// the content resolver / media store sometimes report the wrong mime type (e.g. `png` file as `jpeg`) // the content resolver / media store sometimes report the wrong mime type (e.g. `png` file as `jpeg`)
// `context.getContentResolver().getType()` sometimes return incorrect value // `context.getContentResolver().getType()` sometimes return incorrect value
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000` // `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) { if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
String detectedMimeType = dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE); String detectedMimeType = dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE);
if (detectedMimeType != null && !detectedMimeType.equals(mimeType)) { if (detectedMimeType != null && !detectedMimeType.equals(mimeType)) {
// file extension is unreliable, but we use it as a tie breaker // file extension is unreliable, but we use it as a tie breaker
String extensionMimeType = MimeTypes.getMimeTypeForExtension(extension.toLowerCase()); String extensionMimeType = MimeTypes.getMimeTypeForExtension(extension.toLowerCase());
if (detectedMimeType.equals(extensionMimeType)) { if (detectedMimeType.equals(extensionMimeType)) {
metadataMap.put(KEY_MIME_TYPE, detectedMimeType); metadataMap.put(KEY_MIME_TYPE, detectedMimeType);
}
} }
} }
} }
}
// EXIF // EXIF
putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL); for (ExifSubIFDDirectory dir : metadata.getDirectoriesOfType(ExifSubIFDDirectory.class)) {
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { putDateFromTag(metadataMap, KEY_DATE_MILLIS, dir, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL);
putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifIFD0Directory.class, ExifIFD0Directory.TAG_DATETIME); }
} for (ExifIFD0Directory dir : metadata.getDirectoriesOfType(ExifIFD0Directory.class)) {
foundExif = true;
// GPS if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
for (GpsDirectory dir : metadata.getDirectoriesOfType(GpsDirectory.class)) { putDateFromTag(metadataMap, KEY_DATE_MILLIS, dir, ExifIFD0Directory.TAG_DATETIME);
GeoLocation geoLocation = dir.getGeoLocation(); }
if (geoLocation != null) { if (dir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
metadataMap.put(KEY_LATITUDE, geoLocation.getLatitude()); int orientation = dir.getInt(ExifIFD0Directory.TAG_ORIENTATION);
metadataMap.put(KEY_LONGITUDE, geoLocation.getLongitude()); metadataMap.put(KEY_IS_FLIPPED, MetadataHelper.isFlippedForExifCode(orientation));
metadataMap.put(KEY_ROTATION_DEGREES, MetadataHelper.getRotationDegreesForExifCode(orientation));
}
} }
}
// XMP // GPS
for (XmpDirectory dir : metadata.getDirectoriesOfType(XmpDirectory.class)) { for (GpsDirectory dir : metadata.getDirectoriesOfType(GpsDirectory.class)) {
XMPMeta xmpMeta = dir.getXMPMeta(); GeoLocation geoLocation = dir.getGeoLocation();
try { if (geoLocation != null) {
if (xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME)) { metadataMap.put(KEY_LATITUDE, geoLocation.getLatitude());
StringBuilder sb = new StringBuilder(); metadataMap.put(KEY_LONGITUDE, geoLocation.getLongitude());
int count = xmpMeta.countArrayItems(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME); }
for (int i = 1; i < count + 1; i++) { }
XMPProperty item = xmpMeta.getArrayItem(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME, i);
sb.append(";").append(item.getValue()); // XMP
for (XmpDirectory dir : metadata.getDirectoriesOfType(XmpDirectory.class)) {
XMPMeta xmpMeta = dir.getXMPMeta();
try {
if (xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME)) {
StringBuilder sb = new StringBuilder();
int count = xmpMeta.countArrayItems(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME);
for (int i = 1; i < count + 1; i++) {
XMPProperty item = xmpMeta.getArrayItem(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME, i);
sb.append(";").append(item.getValue());
}
metadataMap.put(KEY_XMP_SUBJECTS, sb.toString());
} }
metadataMap.put(KEY_XMP_SUBJECTS, sb.toString());
}
putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_TITLE_PROP_NAME); putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_TITLE_PROP_NAME);
if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) { if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_DESCRIPTION_PROP_NAME); putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_DESCRIPTION_PROP_NAME);
} }
} catch (XMPException e) { } catch (XMPException e) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uri, e); Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uri, e);
}
}
// Animated GIF & WEBP
if (MimeTypes.GIF.equals(mimeType)) {
metadataMap.put(KEY_IS_ANIMATED, metadata.containsDirectoryOfType(GifAnimationDirectory.class));
} else if (MimeTypes.WEBP.equals(mimeType)) {
for (WebpDirectory dir : metadata.getDirectoriesOfType(WebpDirectory.class)) {
if (dir.containsTag(WebpDirectory.TAG_IS_ANIMATION)) {
metadataMap.put(KEY_IS_ANIMATED, dir.getBoolean(WebpDirectory.TAG_IS_ANIMATION));
} }
} }
// Animated GIF & WEBP
if (MimeTypes.GIF.equals(mimeType)) {
metadataMap.put(KEY_IS_ANIMATED, metadata.containsDirectoryOfType(GifAnimationDirectory.class));
} else if (MimeTypes.WEBP.equals(mimeType)) {
for (WebpDirectory dir : metadata.getDirectoriesOfType(WebpDirectory.class)) {
if (dir.containsTag(WebpDirectory.TAG_IS_ANIMATION)) {
metadataMap.put(KEY_IS_ANIMATED, dir.getBoolean(WebpDirectory.TAG_IS_ANIMATION));
}
}
}
} catch (Exception | NoClassDefFoundError e) {
Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for uri=" + uri + ", mimeType=" + mimeType, e);
} }
} catch (Exception | NoClassDefFoundError e) {
Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for uri=" + uri + ", mimeType=" + mimeType, e);
} }
if (!foundExif) {
// fallback to read EXIF via ExifInterface
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
ExifInterface exif = new ExifInterface(is);
// TODO TLAD get KEY_DATE_MILLIS from ExifInterface.TAG_DATETIME_ORIGINAL/TAG_DATETIME after Kotlin migration
if (exif.hasAttribute(ExifInterface.TAG_ORIENTATION)) {
int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0);
if (orientation != 0) {
metadataMap.put(KEY_IS_FLIPPED, exif.isFlipped());
metadataMap.put(KEY_ROTATION_DEGREES, exif.getRotationDegrees());
}
}
double[] latLong = exif.getLatLong();
if (latLong != null && latLong.length == 2) {
metadataMap.put(KEY_LATITUDE, latLong[0]);
metadataMap.put(KEY_LONGITUDE, latLong[1]);
}
} catch (IOException e) {
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=" + uri, e);
}
}
return metadataMap; return metadataMap;
} }
@ -383,7 +414,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
} }
} }
if (rotationString != null) { if (rotationString != null) {
metadataMap.put(KEY_VIDEO_ROTATION, Integer.parseInt(rotationString)); metadataMap.put(KEY_ROTATION_DEGREES, Integer.parseInt(rotationString));
} }
if (locationString != null) { if (locationString != null) {
Matcher locationMatcher = VIDEO_LOCATION_PATTERN.matcher(locationString); Matcher locationMatcher = VIDEO_LOCATION_PATTERN.matcher(locationString);
@ -420,7 +451,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
Map<String, Object> metadataMap = new HashMap<>(); Map<String, Object> metadataMap = new HashMap<>();
if (isVideo(mimeType) || !MimeTypes.isSupportedByMetadataExtractor(mimeType)) { if (MimeTypes.isVideo(mimeType) || !MimeTypes.isSupportedByMetadataExtractor(mimeType)) {
result.success(metadataMap); result.success(metadataMap);
return; return;
} }
@ -462,9 +493,9 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
long id = ContentUris.parseId(uri); long id = ContentUris.parseId(uri);
Uri contentUri = uri; Uri contentUri = uri;
if (mimeType.startsWith(MimeTypes.IMAGE)) { if (MimeTypes.isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
} else if (mimeType.startsWith(MimeTypes.VIDEO)) { } else if (MimeTypes.isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id); contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id);
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -536,10 +567,10 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
try { try {
Map<String, String> metadataMap = new HashMap<>(); Map<String, String> metadataMap = new HashMap<>();
for (Map.Entry<String, Integer> kv : MediaMetadataRetrieverHelper.allKeys.entrySet()) { for (Map.Entry<Integer, String> kv : MediaMetadataRetrieverHelper.allKeys.entrySet()) {
String value = retriever.extractMetadata(kv.getValue()); String value = retriever.extractMetadata(kv.getKey());
if (value != null) { if (value != null) {
metadataMap.put(kv.getKey(), value); metadataMap.put(kv.getValue(), value);
} }
} }
result.success(metadataMap); result.success(metadataMap);
@ -625,12 +656,6 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
// convenience methods // convenience methods
private static <T extends Directory> void putDateFromDirectoryTag(Map<String, Object> metadataMap, String key, Metadata metadata, Class<T> dirClass, int tag) {
for (T dir : metadata.getDirectoriesOfType(dirClass)) {
putDateFromTag(metadataMap, key, dir, tag);
}
}
private static void putDateFromTag(Map<String, Object> metadataMap, String key, Directory dir, int tag) { private static void putDateFromTag(Map<String, Object> metadataMap, String key, Directory dir, int tag) {
if (dir.containsTag(tag)) { if (dir.containsTag(tag)) {
metadataMap.put(key, dir.getDate(tag, null, TimeZone.getDefault()).getTime()); metadataMap.put(key, dir.getDate(tag, null, TimeZone.getDefault()).getTime());

View file

@ -16,8 +16,6 @@ import com.bumptech.glide.request.RequestOptions;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.Arrays;
import java.util.List;
import java.util.Map; import java.util.Map;
import deckers.thibault.aves.decoder.VideoThumbnail; import deckers.thibault.aves.decoder.VideoThumbnail;
@ -34,17 +32,6 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
private EventChannel.EventSink eventSink; private EventChannel.EventSink eventSink;
private Handler handler; private Handler handler;
private static final List<String> flutterSupportedTypes = Arrays.asList(
MimeTypes.JPEG,
MimeTypes.PNG,
MimeTypes.GIF,
MimeTypes.WEBP,
MimeTypes.BMP,
MimeTypes.WBMP,
MimeTypes.ICO,
MimeTypes.SVG
);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public ImageByteStreamHandler(Activity activity, Object arguments) { public ImageByteStreamHandler(Activity activity, Object arguments) {
this.activity = activity; this.activity = activity;
@ -84,7 +71,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
// - Android: https://developer.android.com/guide/topics/media/media-formats#image-formats // - Android: https://developer.android.com/guide/topics/media/media-formats#image-formats
// - Glide: https://github.com/bumptech/glide/blob/master/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java // - Glide: https://github.com/bumptech/glide/blob/master/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java
private void getImage() { private void getImage() {
if (mimeType != null && mimeType.startsWith(MimeTypes.VIDEO)) { if (MimeTypes.isVideo(mimeType)) {
RequestOptions options = new RequestOptions() RequestOptions options = new RequestOptions()
.diskCacheStrategy(DiskCacheStrategy.RESOURCE); .diskCacheStrategy(DiskCacheStrategy.RESOURCE);
FutureTarget<Bitmap> target = Glide.with(activity) FutureTarget<Bitmap> target = Glide.with(activity)
@ -108,7 +95,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
} finally { } finally {
Glide.with(activity).clear(target); Glide.with(activity).clear(target);
} }
} else if (!flutterSupportedTypes.contains(mimeType)) { } else if (!MimeTypes.isSupportedByFlutter(mimeType)) {
// we convert the image on platform side first, when Dart Image.memory does not support it // we convert the image on platform side first, when Dart Image.memory does not support it
FutureTarget<Bitmap> target = Glide.with(activity) FutureTarget<Bitmap> target = Glide.with(activity)
.asBitmap() .asBitmap()

View file

@ -167,9 +167,9 @@ public abstract class ImageProvider {
// newURI is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872") // newURI is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872") // but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
contentId = ContentUris.parseId(newUri); contentId = ContentUris.parseId(newUri);
if (mimeType.startsWith(MimeTypes.IMAGE)) { if (MimeTypes.isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId); contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId);
} else if (mimeType.startsWith(MimeTypes.VIDEO)) { } else if (MimeTypes.isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId); contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId);
} }
} }

View file

@ -85,10 +85,10 @@ public class MediaStoreImageProvider extends ImageProvider {
callback.onSuccess(entry); callback.onSuccess(entry);
}; };
NewEntryChecker alwaysValid = (contentId, dateModifiedSecs) -> true; NewEntryChecker alwaysValid = (contentId, dateModifiedSecs) -> true;
if (mimeType.startsWith(MimeTypes.IMAGE)) { if (MimeTypes.isImage(mimeType)) {
Uri contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); Uri contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
entryCount = fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION); entryCount = fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION);
} else if (mimeType.startsWith(MimeTypes.VIDEO)) { } else if (MimeTypes.isVideo(mimeType)) {
Uri contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id); Uri contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id);
entryCount = fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION); entryCount = fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION);
} }
@ -126,10 +126,6 @@ public class MediaStoreImageProvider extends ImageProvider {
int newEntryCount = 0; int newEntryCount = 0;
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; final boolean needDuration = projection == VIDEO_PROJECTION;
try { try {
@ -164,18 +160,15 @@ 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` // 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) // this can happen for specific formats (e.g. for PNG, WEBP)
// or for JPEG that were not properly registered // 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("sourceRotationDegrees", 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);
@ -186,10 +179,8 @@ 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)) if (((width <= 0 || height <= 0) && needSize(mimeType))
|| (rotationDegrees == null && needOrientation)
|| (durationMillis == 0 && needDuration)) { || (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
@ -331,7 +322,7 @@ public class MediaStoreImageProvider extends ImageProvider {
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, destination.relativePath); contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, destination.relativePath);
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName); contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName);
String volumeName = destination.volumeNameForMediaStore; String volumeName = destination.volumeNameForMediaStore;
Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? Uri tableUrl = MimeTypes.isVideo(mimeType) ?
MediaStore.Video.Media.getContentUri(volumeName) : MediaStore.Video.Media.getContentUri(volumeName) :
MediaStore.Images.Media.getContentUri(volumeName); MediaStore.Images.Media.getContentUri(volumeName);
Uri destinationUri = context.getContentResolver().insert(tableUrl, contentValues); Uri destinationUri = context.getContentResolver().insert(tableUrl, contentValues);

View file

@ -26,7 +26,7 @@ class AvesImageEntry(map: Map<String?, Any?>) {
val dateModifiedSecs = toLong(map["dateModifiedSecs"]) val dateModifiedSecs = toLong(map["dateModifiedSecs"])
val isVideo: Boolean val isVideo: Boolean
get() = mimeType.startsWith(MimeTypes.VIDEO) get() = MimeTypes.isVideo(mimeType)
companion object { companion object {
// convenience method // convenience method

View file

@ -6,7 +6,7 @@ import android.content.Context
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build import androidx.exifinterface.media.ExifInterface
import com.drew.imaging.ImageMetadataReader import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.avi.AviDirectory import com.drew.metadata.avi.AviDirectory
import com.drew.metadata.exif.ExifIFD0Directory import com.drew.metadata.exif.ExifIFD0Directory
@ -14,29 +14,35 @@ import com.drew.metadata.jpeg.JpegDirectory
import com.drew.metadata.mp4.Mp4Directory import com.drew.metadata.mp4.Mp4Directory
import com.drew.metadata.mp4.media.Mp4VideoDirectory import com.drew.metadata.mp4.media.Mp4VideoDirectory
import com.drew.metadata.photoshop.PsdHeaderDirectory import com.drew.metadata.photoshop.PsdHeaderDirectory
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeDate
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDateMillis
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeLong
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeString
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeLong
import deckers.thibault.aves.utils.MetadataHelper.getRotationDegreesForExifCode import deckers.thibault.aves.utils.MetadataHelper.getRotationDegreesForExifCode
import deckers.thibault.aves.utils.MetadataHelper.isFlippedForExifCode
import deckers.thibault.aves.utils.MetadataHelper.parseVideoMetadataDate
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import java.io.IOException import java.io.IOException
import java.util.*
class SourceImageEntry { class SourceImageEntry {
val uri: Uri // content or file URI val uri: Uri // content or file URI
var path: String? = null // best effort to get local path var path: String? = null // best effort to get local path
private val sourceMimeType: String private val sourceMimeType: String
var title: String? = null private var title: String? = null
var width: Int? = null var width: Int? = null
var height: Int? = null var height: Int? = null
private var rotationDegrees: Int? = null private var sourceRotationDegrees: Int? = null
private var isFlipped: Boolean? = null private var sizeBytes: Long? = null
var sizeBytes: Long? = null private var dateModifiedSecs: Long? = null
var dateModifiedSecs: Long? = null
private var sourceDateTakenMillis: Long? = null private var sourceDateTakenMillis: Long? = null
private var durationMillis: Long? = null private var durationMillis: Long? = null
private var foundExif: Boolean = false
constructor(uri: Uri, sourceMimeType: String) { constructor(uri: Uri, sourceMimeType: String) {
this.uri = uri this.uri = uri
this.sourceMimeType = sourceMimeType this.sourceMimeType = sourceMimeType
@ -46,9 +52,9 @@ class SourceImageEntry {
uri = Uri.parse(map["uri"] as String) uri = Uri.parse(map["uri"] as String)
path = map["path"] as String? path = map["path"] as String?
sourceMimeType = map["sourceMimeType"] as String sourceMimeType = map["sourceMimeType"] as String
width = map["width"] as Int width = map["width"] as Int?
height = map["height"] as Int height = map["height"] as Int?
rotationDegrees = map["rotationDegrees"] as Int sourceRotationDegrees = map["sourceRotationDegrees"] as Int?
sizeBytes = toLong(map["sizeBytes"]) sizeBytes = toLong(map["sizeBytes"])
title = map["title"] as String? title = map["title"] as String?
dateModifiedSecs = toLong(map["dateModifiedSecs"]) dateModifiedSecs = toLong(map["dateModifiedSecs"])
@ -70,8 +76,7 @@ class SourceImageEntry {
"sourceMimeType" to sourceMimeType, "sourceMimeType" to sourceMimeType,
"width" to width, "width" to width,
"height" to height, "height" to height,
"rotationDegrees" to (rotationDegrees ?: 0), "sourceRotationDegrees" to (sourceRotationDegrees ?: 0),
"isFlipped" to (isFlipped ?: false),
"sizeBytes" to sizeBytes, "sizeBytes" to sizeBytes,
"title" to title, "title" to title,
"dateModifiedSecs" to dateModifiedSecs, "dateModifiedSecs" to dateModifiedSecs,
@ -99,17 +104,14 @@ class SourceImageEntry {
val hasSize: Boolean val hasSize: Boolean
get() = width ?: 0 > 0 && height ?: 0 > 0 get() = width ?: 0 > 0 && height ?: 0 > 0
private val hasOrientation: Boolean
get() = rotationDegrees != null
private val hasDuration: Boolean private val hasDuration: Boolean
get() = durationMillis ?: 0 > 0 get() = durationMillis ?: 0 > 0
private val isImage: Boolean private val isImage: Boolean
get() = sourceMimeType.startsWith(MimeTypes.IMAGE) get() = MimeTypes.isImage(sourceMimeType)
private val isVideo: Boolean private val isVideo: Boolean
get() = sourceMimeType.startsWith(MimeTypes.VIDEO) get() = MimeTypes.isVideo(sourceMimeType)
val isSvg: Boolean val isSvg: Boolean
get() = sourceMimeType == MimeTypes.SVG get() = sourceMimeType == MimeTypes.SVG
@ -119,58 +121,32 @@ class SourceImageEntry {
// finds: width, height, orientation/rotation, date, title, duration // finds: width, height, orientation/rotation, date, title, duration
fun fillPreCatalogMetadata(context: Context): SourceImageEntry { fun fillPreCatalogMetadata(context: Context): SourceImageEntry {
if (isSvg) return this if (isSvg) return this
fillByMediaMetadataRetriever(context) if (isVideo) {
if (hasSize && hasOrientation && (!isVideo || hasDuration)) return this fillVideoByMediaMetadataRetriever(context)
fillByMetadataExtractor(context) if (hasSize && hasDuration) return this
if (hasSize) return this }
if (MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) {
fillByMetadataExtractor(context)
if (hasSize && foundExif) return this
}
if (ExifInterface.isSupportedMimeType(sourceMimeType)) {
fillByExifInterface(context)
if (hasSize) return this
}
fillByBitmapDecode(context) fillByBitmapDecode(context)
return this return this
} }
// expects entry with: uri, mimeType // finds: width, height, orientation, date, duration, title
// finds: width, height, orientation/rotation, date, title, duration private fun fillVideoByMediaMetadataRetriever(context: Context) {
private fun fillByMediaMetadataRetriever(context: Context) {
if (isImage) return
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return
try { try {
var width: String? = null retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) { width = it }
var height: String? = null retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) { height = it }
var rotationDegrees: String? = null retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it }
var durationMillis: String? = null retriever.getSafeLong(MediaMetadataRetriever.METADATA_KEY_DURATION) { durationMillis = it }
if (isImage) { retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { sourceDateTakenMillis = it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { retriever.getSafeString(MediaMetadataRetriever.METADATA_KEY_TITLE) { title = it }
width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH)
height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT)
rotationDegrees = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION)
}
} else if (isVideo) {
width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
rotationDegrees = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
durationMillis = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
}
if (width != null) {
this.width = width.toInt()
}
if (height != null) {
this.height = height.toInt()
}
if (rotationDegrees != null) {
this.rotationDegrees = rotationDegrees.toInt()
}
if (durationMillis != null) {
this.durationMillis = durationMillis.toLong()
}
val dateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE)
val dateMillis = parseVideoMetadataDate(dateString)
// some entries have an invalid default date (19040101T000000.000Z) that is before Epoch time
if (dateMillis > 0) {
sourceDateTakenMillis = dateMillis
}
val title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)
if (title != null) {
this.title = title
}
} catch (e: Exception) { } catch (e: Exception) {
// ignore // ignore
} finally { } finally {
@ -179,10 +155,8 @@ class SourceImageEntry {
} }
} }
// expects entry with: uri, mimeType // finds: width, height, orientation, date, duration
// finds: width, height, orientation, date
private fun fillByMetadataExtractor(context: Context) { private fun fillByMetadataExtractor(context: Context) {
if (!isSupportedByMetadataExtractor(sourceMimeType)) return
try { try {
StorageUtils.openInputStream(context, uri).use { input -> StorageUtils.openInputStream(context, uri).use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
@ -191,62 +165,36 @@ class SourceImageEntry {
// (e.g. PNG registered as JPG) // (e.g. PNG registered as JPG)
if (isVideo) { if (isVideo) {
for (dir in metadata.getDirectoriesOfType(AviDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(AviDirectory::class.java)) {
if (dir.containsTag(AviDirectory.TAG_WIDTH)) { dir.getSafeInt(AviDirectory.TAG_WIDTH) { width = it }
width = dir.getInt(AviDirectory.TAG_WIDTH) dir.getSafeInt(AviDirectory.TAG_HEIGHT) { height = it }
} dir.getSafeLong(AviDirectory.TAG_DURATION) { durationMillis = it }
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 (dir in metadata.getDirectoriesOfType(Mp4VideoDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(Mp4VideoDirectory::class.java)) {
if (dir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) { dir.getSafeInt(Mp4VideoDirectory.TAG_WIDTH) { width = it }
width = dir.getInt(Mp4VideoDirectory.TAG_WIDTH) dir.getSafeInt(Mp4VideoDirectory.TAG_HEIGHT) { height = it }
}
if (dir.containsTag(Mp4VideoDirectory.TAG_HEIGHT)) {
height = dir.getInt(Mp4VideoDirectory.TAG_HEIGHT)
}
} }
for (dir in metadata.getDirectoriesOfType(Mp4Directory::class.java)) { for (dir in metadata.getDirectoriesOfType(Mp4Directory::class.java)) {
if (dir.containsTag(Mp4Directory.TAG_DURATION)) { dir.getSafeInt(Mp4Directory.TAG_ROTATION) { sourceRotationDegrees = it }
durationMillis = dir.getLong(Mp4Directory.TAG_DURATION) dir.getSafeLong(Mp4Directory.TAG_DURATION) { durationMillis = it }
}
} }
} else { } else {
for (dir in metadata.getDirectoriesOfType(JpegDirectory::class.java)) {
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 (dir in metadata.getDirectoriesOfType(PsdHeaderDirectory::class.java)) {
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)
}
}
// EXIF, if defined, should override metadata found in other directories // EXIF, if defined, should override metadata found in other directories
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) { for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
if (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_WIDTH)) { foundExif = true
width = dir.getInt(ExifIFD0Directory.TAG_IMAGE_WIDTH) dir.getSafeInt(ExifIFD0Directory.TAG_IMAGE_WIDTH) { width = it }
dir.getSafeInt(ExifIFD0Directory.TAG_IMAGE_HEIGHT) { height = it }
dir.getSafeInt(ExifIFD0Directory.TAG_ORIENTATION) { sourceRotationDegrees = getRotationDegreesForExifCode(it) }
dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME) { sourceDateTakenMillis = it }
}
if (!foundExif) {
for (dir in metadata.getDirectoriesOfType(JpegDirectory::class.java)) {
dir.getSafeInt(JpegDirectory.TAG_IMAGE_WIDTH) { width = it }
dir.getSafeInt(JpegDirectory.TAG_IMAGE_HEIGHT) { height = it }
} }
if (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_HEIGHT)) { for (dir in metadata.getDirectoriesOfType(PsdHeaderDirectory::class.java)) {
height = dir.getInt(ExifIFD0Directory.TAG_IMAGE_HEIGHT) dir.getSafeInt(PsdHeaderDirectory.TAG_IMAGE_WIDTH) { width = it }
} dir.getSafeInt(PsdHeaderDirectory.TAG_IMAGE_HEIGHT) { height = it }
if (dir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
val exifOrientation = dir.getInt(ExifIFD0Directory.TAG_ORIENTATION)
rotationDegrees = getRotationDegreesForExifCode(exifOrientation)
isFlipped = isFlippedForExifCode(exifOrientation)
}
if (dir.containsTag(ExifIFD0Directory.TAG_DATETIME)) {
sourceDateTakenMillis = dir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).time
} }
} }
} }
@ -258,7 +206,22 @@ class SourceImageEntry {
} }
} }
// expects entry with: uri // finds: width, height, orientation, date
private fun fillByExifInterface(context: Context) {
try {
StorageUtils.openInputStream(context, uri).use { input ->
val exif = ExifInterface(input)
foundExif = true
exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it }
exif.getSafeInt(ExifInterface.TAG_IMAGE_LENGTH, acceptZero = false) { height = it }
exif.getSafeInt(ExifInterface.TAG_ORIENTATION, acceptZero = false) { sourceRotationDegrees = exif.rotationDegrees }
exif.getSafeDate(ExifInterface.TAG_DATETIME) { sourceDateTakenMillis = it }
}
} catch (e: IOException) {
// ignore
}
}
// finds: width, height // finds: width, height
private fun fillByBitmapDecode(context: Context) { private fun fillByBitmapDecode(context: Context) {
try { try {

View file

@ -257,24 +257,22 @@ object ExifInterfaceHelper {
private fun fillMetadataExtractorDir(exif: ExifInterface, metadataExtractorDirs: Map<DirType, Directory>, tags: Map<String, TagMapper?>) { private fun fillMetadataExtractorDir(exif: ExifInterface, metadataExtractorDirs: Map<DirType, Directory>, tags: Map<String, TagMapper?>) {
for (kv in tags) { for (kv in tags) {
val exifInterfaceTag: String = kv.key val exifInterfaceTag: String = kv.key
if (exif.hasAttribute(exifInterfaceTag)) { val mapper = kv.value
if (exif.hasAttribute(exifInterfaceTag) && mapper != null) {
val value: String? = exif.getAttribute(exifInterfaceTag) val value: String? = exif.getAttribute(exifInterfaceTag)
if (value != null && (value != "0" || !neverNullTags.contains(exifInterfaceTag))) { if (value != null && (value != "0" || !neverNullTags.contains(exifInterfaceTag))) {
val mapper = kv.value val obj: Any? = when (mapper.format) {
if (mapper != null) { TagFormat.ASCII, TagFormat.COMMENT, TagFormat.UNDEFINED -> value
val obj: Any? = when (mapper.format) { TagFormat.BYTE -> value.toByteArray()
TagFormat.ASCII, TagFormat.COMMENT, TagFormat.UNDEFINED -> value TagFormat.SHORT -> value.toShortOrNull()
TagFormat.BYTE -> value.toByteArray() TagFormat.LONG -> value.toLongOrNull()
TagFormat.SHORT -> value.toShortOrNull() TagFormat.RATIONAL -> toRational(value)
TagFormat.LONG -> value.toLongOrNull() TagFormat.RATIONAL_ARRAY -> toRationalArray(value)
TagFormat.RATIONAL -> toRational(value) null -> null
TagFormat.RATIONAL_ARRAY -> toRationalArray(value) }
null -> null if (obj != null) {
} val dir = metadataExtractorDirs[mapper.dirType] ?: error("Directory type ${mapper.dirType} does not have a matching Directory instance")
if (obj != null) { dir.setObject(mapper.type, obj)
val dir = metadataExtractorDirs[mapper.dirType] ?: error("Directory type ${mapper.dirType} does not have a matching Directory instance")
dir.setObject(mapper.type, obj)
}
} }
} }
} }
@ -314,6 +312,28 @@ object ExifInterfaceHelper {
if (list.isEmpty()) return null if (list.isEmpty()) return null
return list.toTypedArray() return list.toTypedArray()
} }
// extensions
fun ExifInterface.getSafeInt(tag: String, acceptZero: Boolean = true, save: (value: Int) -> Unit) {
if (this.hasAttribute(tag)) {
val value = this.getAttributeInt(tag, 0)
if (acceptZero || value != 0) {
save(value)
}
}
}
fun ExifInterface.getSafeDate(tag: String, save: (value: Long) -> Unit) {
if (this.hasAttribute(tag)) {
// TODO TLAD parse date with "yyyy:MM:dd HH:mm:ss" or find the original long
val formattedDate = this.getAttribute(tag)
val value = formattedDate?.toLongOrNull()
if (value != null && value > 0) {
save(value)
}
}
}
} }
enum class DirType { enum class DirType {

View file

@ -5,47 +5,71 @@ import android.os.Build
object MediaMetadataRetrieverHelper { object MediaMetadataRetrieverHelper {
@JvmField @JvmField
val allKeys: Map<String, Int> = hashMapOf( val allKeys = hashMapOf(
"METADATA_KEY_ALBUM" to MediaMetadataRetriever.METADATA_KEY_ALBUM, MediaMetadataRetriever.METADATA_KEY_ALBUM to "ALBUM",
"METADATA_KEY_ALBUMARTIST" to MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST, MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST to "ALBUMARTIST",
"METADATA_KEY_ARTIST" to MediaMetadataRetriever.METADATA_KEY_ARTIST, MediaMetadataRetriever.METADATA_KEY_ARTIST to "ARTIST",
"METADATA_KEY_AUTHOR" to MediaMetadataRetriever.METADATA_KEY_AUTHOR, MediaMetadataRetriever.METADATA_KEY_AUTHOR to "AUTHOR",
"METADATA_KEY_BITRATE" to MediaMetadataRetriever.METADATA_KEY_BITRATE, MediaMetadataRetriever.METADATA_KEY_BITRATE to "BITRATE",
"METADATA_KEY_CAPTURE_FRAMERATE" to MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE, MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE to "CAPTURE_FRAMERATE",
"METADATA_KEY_CD_TRACK_NUMBER" to MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER, MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER to "CD_TRACK_NUMBER",
"METADATA_KEY_COLOR_RANGE" to MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE, MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE to "COLOR_RANGE",
"METADATA_KEY_COLOR_STANDARD" to MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD, MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD to "COLOR_STANDARD",
"METADATA_KEY_COLOR_TRANSFER" to MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER, MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER to "COLOR_TRANSFER",
"METADATA_KEY_COMPILATION" to MediaMetadataRetriever.METADATA_KEY_COMPILATION, MediaMetadataRetriever.METADATA_KEY_COMPILATION to "COMPILATION",
"METADATA_KEY_COMPOSER" to MediaMetadataRetriever.METADATA_KEY_COMPOSER, MediaMetadataRetriever.METADATA_KEY_COMPOSER to "COMPOSER",
"METADATA_KEY_DATE" to MediaMetadataRetriever.METADATA_KEY_DATE, MediaMetadataRetriever.METADATA_KEY_DATE to "DATE",
"METADATA_KEY_DISC_NUMBER" to MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER, MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER to "DISC_NUMBER",
"METADATA_KEY_DURATION" to MediaMetadataRetriever.METADATA_KEY_DURATION, MediaMetadataRetriever.METADATA_KEY_DURATION to "DURATION",
"METADATA_KEY_EXIF_LENGTH" to MediaMetadataRetriever.METADATA_KEY_EXIF_LENGTH, MediaMetadataRetriever.METADATA_KEY_EXIF_LENGTH to "EXIF_LENGTH",
"METADATA_KEY_EXIF_OFFSET" to MediaMetadataRetriever.METADATA_KEY_EXIF_OFFSET, MediaMetadataRetriever.METADATA_KEY_EXIF_OFFSET to "EXIF_OFFSET",
"METADATA_KEY_GENRE" to MediaMetadataRetriever.METADATA_KEY_GENRE, MediaMetadataRetriever.METADATA_KEY_GENRE to "GENRE",
"METADATA_KEY_HAS_AUDIO" to MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO, MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO to "HAS_AUDIO",
"METADATA_KEY_HAS_VIDEO" to MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO, MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO to "HAS_VIDEO",
"METADATA_KEY_LOCATION" to MediaMetadataRetriever.METADATA_KEY_LOCATION, MediaMetadataRetriever.METADATA_KEY_LOCATION to "LOCATION",
"METADATA_KEY_MIMETYPE" to MediaMetadataRetriever.METADATA_KEY_MIMETYPE, MediaMetadataRetriever.METADATA_KEY_MIMETYPE to "MIMETYPE",
"METADATA_KEY_NUM_TRACKS" to MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS, MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "NUM_TRACKS",
"METADATA_KEY_TITLE" to MediaMetadataRetriever.METADATA_KEY_TITLE, MediaMetadataRetriever.METADATA_KEY_TITLE to "TITLE",
"METADATA_KEY_VIDEO_HEIGHT" to MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT to "VIDEO_HEIGHT",
"METADATA_KEY_VIDEO_ROTATION" to MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION, MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION to "VIDEO_ROTATION",
"METADATA_KEY_VIDEO_WIDTH" to MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "VIDEO_WIDTH",
"METADATA_KEY_WRITER" to MediaMetadataRetriever.METADATA_KEY_WRITER, MediaMetadataRetriever.METADATA_KEY_WRITER to "WRITER",
"METADATA_KEY_YEAR" to MediaMetadataRetriever.METADATA_KEY_YEAR, MediaMetadataRetriever.METADATA_KEY_YEAR to "YEAR",
).apply { ).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
putAll(hashMapOf( putAll(hashMapOf(
"METADATA_KEY_HAS_IMAGE" to MediaMetadataRetriever.METADATA_KEY_HAS_IMAGE, MediaMetadataRetriever.METADATA_KEY_HAS_IMAGE to "HAS_IMAGE",
"METADATA_KEY_IMAGE_COUNT" to MediaMetadataRetriever.METADATA_KEY_IMAGE_COUNT, MediaMetadataRetriever.METADATA_KEY_IMAGE_COUNT to "IMAGE_COUNT",
"METADATA_KEY_IMAGE_HEIGHT" to MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT, MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT to "IMAGE_HEIGHT",
"METADATA_KEY_IMAGE_PRIMARY" to MediaMetadataRetriever.METADATA_KEY_IMAGE_PRIMARY, MediaMetadataRetriever.METADATA_KEY_IMAGE_PRIMARY to "IMAGE_PRIMARY",
"METADATA_KEY_IMAGE_ROTATION" to MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION, MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION to "IMAGE_ROTATION",
"METADATA_KEY_IMAGE_WIDTH" to MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH, MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH to "IMAGE_WIDTH",
"METADATA_KEY_VIDEO_FRAME_COUNT" to MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT, MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT to "VIDEO_FRAME_COUNT",
)) ))
} }
} }
// extensions
fun MediaMetadataRetriever.getSafeString(tag: Int, save: (value: String) -> Unit) {
val value = this.extractMetadata(tag)
if (value != null) save(value)
}
fun MediaMetadataRetriever.getSafeInt(tag: Int, save: (value: Int) -> Unit) {
val value = this.extractMetadata(tag)?.toIntOrNull()
if (value != null) save(value)
}
fun MediaMetadataRetriever.getSafeLong(tag: Int, save: (value: Long) -> Unit) {
val value = this.extractMetadata(tag)?.toLongOrNull()
if (value != null) save(value)
}
fun MediaMetadataRetriever.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) {
val dateString = this.extractMetadata(tag)
val dateMillis = MetadataHelper.parseVideoMetadataDate(dateString)
// some entries have an invalid default date (19040101T000000.000Z) that is before Epoch time
if (dateMillis > 0) save(dateMillis)
}
} }

View file

@ -0,0 +1,20 @@
package deckers.thibault.aves.utils
import com.drew.metadata.Directory
import java.util.*
object MetadataExtractorHelper {
// extensions
fun Directory.getSafeInt(tag: Int, save: (value: Int) -> Unit) {
if (this.containsTag(tag)) save(this.getInt(tag))
}
fun Directory.getSafeLong(tag: Int, save: (value: Long) -> Unit) {
if (this.containsTag(tag)) save(this.getLong(tag))
}
fun Directory.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) {
if (this.containsTag(tag)) save(this.getDate(tag, null, TimeZone.getDefault()).time)
}
}

View file

@ -3,60 +3,91 @@ package deckers.thibault.aves.utils
import java.util.* import java.util.*
object MimeTypes { object MimeTypes {
const val IMAGE = "image" private const val IMAGE = "image"
// generic raster // generic raster
const val BMP = "image/bmp" private const val BMP = "image/bmp"
const val GIF = "image/gif" const val GIF = "image/gif"
const val HEIC = "image/heic" private const val HEIC = "image/heic"
const val HEIF = "image/heif" private const val HEIF = "image/heif"
const val ICO = "image/x-icon" private const val ICO = "image/x-icon"
const val JPEG = "image/jpeg" private const val JPEG = "image/jpeg"
const val PCX = "image/x-pcx" private const val PCX = "image/x-pcx"
const val PNG = "image/png" private const val PNG = "image/png"
const val PSD = "image/x-photoshop" // aka "image/vnd.adobe.photoshop" private const val PSD = "image/x-photoshop" // aka "image/vnd.adobe.photoshop"
const val TIFF = "image/tiff" private const val TIFF = "image/tiff"
const val WBMP = "image/vnd.wap.wbmp" private const val WBMP = "image/vnd.wap.wbmp"
const val WEBP = "image/webp" const val WEBP = "image/webp"
// raw raster // raw raster
const val ARW = "image/x-sony-arw" private const val ARW = "image/x-sony-arw"
const val CR2 = "image/x-canon-cr2" private const val CR2 = "image/x-canon-cr2"
const val CRW = "image/x-canon-crw" private const val CRW = "image/x-canon-crw"
const val DCR = "image/x-kodak-dcr" private const val DCR = "image/x-kodak-dcr"
const val DNG = "image/x-adobe-dng" private const val DNG = "image/x-adobe-dng"
const val ERF = "image/x-epson-erf" private const val ERF = "image/x-epson-erf"
const val K25 = "image/x-kodak-k25" private const val K25 = "image/x-kodak-k25"
const val KDC = "image/x-kodak-kdc" private const val KDC = "image/x-kodak-kdc"
const val MRW = "image/x-minolta-mrw" private const val MRW = "image/x-minolta-mrw"
const val NEF = "image/x-nikon-nef" private const val NEF = "image/x-nikon-nef"
const val NRW = "image/x-nikon-nrw" private const val NRW = "image/x-nikon-nrw"
const val ORF = "image/x-olympus-orf" private const val ORF = "image/x-olympus-orf"
const val PEF = "image/x-pentax-pef" private const val PEF = "image/x-pentax-pef"
const val RAF = "image/x-fuji-raf" private const val RAF = "image/x-fuji-raf"
const val RAW = "image/x-panasonic-raw" private const val RAW = "image/x-panasonic-raw"
const val RW2 = "image/x-panasonic-rw2" private const val RW2 = "image/x-panasonic-rw2"
const val SR2 = "image/x-sony-sr2" private const val SR2 = "image/x-sony-sr2"
const val SRF = "image/x-sony-srf" private const val SRF = "image/x-sony-srf"
const val SRW = "image/x-samsung-srw" private const val SRW = "image/x-samsung-srw"
const val X3F = "image/x-sigma-x3f" private const val X3F = "image/x-sigma-x3f"
// vector // vector
const val SVG = "image/svg+xml" const val SVG = "image/svg+xml"
const val VIDEO = "video" private const val VIDEO = "video"
const val AVI = "video/avi" private const val AVI = "video/avi"
const val MOV = "video/quicktime" private const val MOV = "video/quicktime"
const val MP2T = "video/mp2t" private const val MP2T = "video/mp2t"
const val MP4 = "video/mp4" private const val MP4 = "video/mp4"
const val WEBM = "video/webm" private 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 @JvmStatic
fun isSupportedByMetadataExtractor(mimeType: String) = !unsupportedMetadataExtractorFormats.contains(mimeType) fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith(IMAGE)
@JvmStatic
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
// as of Flutter v1.22.0
@JvmStatic
fun isSupportedByFlutter(mimeType: String) = when (mimeType) {
JPEG, PNG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true
else -> false
}
// as of metadata-extractor v2.14.0
@JvmStatic
fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) {
WBMP, MP2T, WEBM -> false
else -> true
}
// Glide automatically applies EXIF orientation when decoding images of known formats
// but we need to rotate the decoded bitmap for the other formats
@JvmStatic
fun needRotationAfterGlide(mimeType: String) = when (mimeType) {
DNG, HEIC, HEIF, PNG, WEBP -> true
else -> false
}
// Thumbnails obtained from the Media Store are automatically rotated
// according to EXIF orientation when decoding images of known formats
// but we need to rotate the decoded bitmap for the other formats
@JvmStatic
fun needRotationAfterContentResolverThumbnail(mimeType: String) = when (mimeType) {
DNG, PNG -> true
else -> false
}
@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 rotationDegrees; int sourceRotationDegrees;
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.rotationDegrees, this.sourceRotationDegrees,
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,
rotationDegrees: rotationDegrees, sourceRotationDegrees: sourceRotationDegrees,
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,
rotationDegrees: map['rotationDegrees'] as int ?? 0, sourceRotationDegrees: map['sourceRotationDegrees'] 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,
'rotationDegrees': rotationDegrees, 'sourceRotationDegrees': sourceRotationDegrees,
'sizeBytes': sizeBytes, 'sizeBytes': sizeBytes,
'title': sourceTitle, 'title': sourceTitle,
'dateModifiedSecs': dateModifiedSecs, 'dateModifiedSecs': dateModifiedSecs,
@ -171,10 +171,10 @@ 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 isFlipped => _catalogMetadata?.isFlipped ?? false;
bool get canEdit => path != null; bool get canEdit => path != null;
bool get canPrint => !isVideo; bool get canPrint => !isVideo;
@ -194,7 +194,7 @@ class ImageEntry {
} }
} }
bool get portrait => ((isVideo && isCatalogued) ? _catalogMetadata.videoRotation : rotationDegrees) % 180 == 90; bool get portrait => ((isVideo && isCatalogued) ? _catalogMetadata.rotationDegrees : rotationDegrees) % 180 == 90;
double get displayAspectRatio { double get displayAspectRatio {
if (width == 0 || height == 0) return 1; if (width == 0 || height == 0) return 1;
@ -220,6 +220,13 @@ class ImageEntry {
return _bestDate; return _bestDate;
} }
int get rotationDegrees => catalogMetadata?.rotationDegrees ?? sourceRotationDegrees;
set rotationDegrees(int rotationDegrees) {
sourceRotationDegrees = rotationDegrees;
catalogMetadata?.rotationDegrees = rotationDegrees;
}
int get dateModifiedSecs => _dateModifiedSecs; int get dateModifiedSecs => _dateModifiedSecs;
set dateModifiedSecs(int dateModifiedSecs) { set dateModifiedSecs(int dateModifiedSecs) {
@ -257,7 +264,7 @@ class ImageEntry {
String _bestTitle; String _bestTitle;
String get bestTitle { String get bestTitle {
_bestTitle ??= (_catalogMetadata != null && _catalogMetadata.xmpTitleDescription.isNotEmpty) ? _catalogMetadata.xmpTitleDescription : sourceTitle; _bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription.isNotEmpty) ? _catalogMetadata.xmpTitleDescription : sourceTitle;
return _bestTitle; return _bestTitle;
} }

View file

@ -28,8 +28,10 @@ class DateMetadata {
} }
class CatalogMetadata { class CatalogMetadata {
final int contentId, dateMillis, videoRotation; final int contentId, dateMillis;
final bool isFlipped, isAnimated; final bool isAnimated;
bool isFlipped;
int rotationDegrees;
final String mimeType, xmpSubjects, xmpTitleDescription; final String mimeType, xmpSubjects, xmpTitleDescription;
final double latitude, longitude; final double latitude, longitude;
Address address; Address address;
@ -38,9 +40,9 @@ class CatalogMetadata {
this.contentId, this.contentId,
this.mimeType, this.mimeType,
this.dateMillis, this.dateMillis,
this.isFlipped,
this.isAnimated, this.isAnimated,
this.videoRotation, this.isFlipped,
this.rotationDegrees,
this.xmpSubjects, this.xmpSubjects,
this.xmpTitleDescription, this.xmpTitleDescription,
double latitude, double latitude,
@ -57,9 +59,9 @@ 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, isFlipped: isFlipped,
rotationDegrees: rotationDegrees,
xmpSubjects: xmpSubjects, xmpSubjects: xmpSubjects,
xmpTitleDescription: xmpTitleDescription, xmpTitleDescription: xmpTitleDescription,
latitude: latitude, latitude: latitude,
@ -68,15 +70,16 @@ 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);
final isFlipped = map['isFlipped'] ?? (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, isFlipped: boolAsInteger ? isFlipped != 0 : isFlipped,
// `rotationDegrees` should default to `sourceRotationDegrees`, not 0
rotationDegrees: map['rotationDegrees'],
xmpSubjects: map['xmpSubjects'] ?? '', xmpSubjects: map['xmpSubjects'] ?? '',
xmpTitleDescription: map['xmpTitleDescription'] ?? '', xmpTitleDescription: map['xmpTitleDescription'] ?? '',
latitude: map['latitude'], latitude: map['latitude'],
@ -88,9 +91,9 @@ 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, 'isFlipped': boolAsInteger ? (isFlipped ? 1 : 0) : isFlipped,
'rotationDegrees': rotationDegrees,
'xmpSubjects': xmpSubjects, 'xmpSubjects': xmpSubjects,
'xmpTitleDescription': xmpTitleDescription, 'xmpTitleDescription': xmpTitleDescription,
'latitude': latitude, 'latitude': latitude,
@ -99,7 +102,7 @@ class CatalogMetadata {
@override @override
String toString() { String toString() {
return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isFlipped=$isFlipped, isAnimated=$isAnimated, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, rotationDegrees=$rotationDegrees, 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'
', rotationDegrees INTEGER' ', sourceRotationDegrees INTEGER'
', sizeBytes INTEGER' ', sizeBytes INTEGER'
', title TEXT' ', title TEXT'
', dateModifiedSecs INTEGER' ', dateModifiedSecs INTEGER'
@ -48,9 +48,9 @@ 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' ', isFlipped INTEGER'
', rotationDegrees INTEGER'
', xmpSubjects TEXT' ', xmpSubjects TEXT'
', xmpTitleDescription TEXT' ', xmpTitleDescription TEXT'
', latitude REAL' ', latitude REAL'
@ -74,8 +74,8 @@ class MetadataDb {
// on SQLite <3.25.0, bundled on older Android devices // on SQLite <3.25.0, bundled on older Android devices
while (oldVersion < newVersion) { while (oldVersion < newVersion) {
if (oldVersion == 1) { if (oldVersion == 1) {
// rename column 'orientationDegrees' to 'sourceRotationDegrees'
await db.transaction((txn) async { await db.transaction((txn) async {
// rename column 'orientationDegrees' to 'rotationDegrees'
const newEntryTable = '${entryTable}TEMP'; const newEntryTable = '${entryTable}TEMP';
await db.execute('CREATE TABLE $newEntryTable(' await db.execute('CREATE TABLE $newEntryTable('
'contentId INTEGER PRIMARY KEY' 'contentId INTEGER PRIMARY KEY'
@ -84,20 +84,41 @@ class MetadataDb {
', sourceMimeType TEXT' ', sourceMimeType TEXT'
', width INTEGER' ', width INTEGER'
', height INTEGER' ', height INTEGER'
', rotationDegrees INTEGER' ', sourceRotationDegrees INTEGER'
', sizeBytes INTEGER' ', sizeBytes INTEGER'
', title TEXT' ', title TEXT'
', dateModifiedSecs INTEGER' ', dateModifiedSecs INTEGER'
', sourceDateTakenMillis INTEGER' ', sourceDateTakenMillis INTEGER'
', durationMillis INTEGER' ', durationMillis INTEGER'
')'); ')');
await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,rotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)' await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)'
' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis' ' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis'
' FROM $entryTable;'); ' FROM $entryTable;');
await db.execute('DROP TABLE $entryTable;'); await db.execute('DROP TABLE $entryTable;');
await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;'); await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
}); });
// rename column 'videoRotation' to 'rotationDegrees'
await db.transaction((txn) async {
const newMetadataTable = '${metadataTable}TEMP';
await db.execute('CREATE TABLE $newMetadataTable('
'contentId INTEGER PRIMARY KEY'
', mimeType TEXT'
', dateMillis INTEGER'
', isAnimated INTEGER'
', rotationDegrees INTEGER'
', xmpSubjects TEXT'
', xmpTitleDescription TEXT'
', latitude REAL'
', longitude REAL'
')');
await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,isAnimated,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
' SELECT contentId,mimeType,dateMillis,isAnimated,videoRotation,xmpSubjects,xmpTitleDescription,latitude,longitude'
' FROM $metadataTable;');
await db.execute('DROP TABLE $metadataTable;');
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
});
// new column 'isFlipped' // new column 'isFlipped'
await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;'); await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;');

View file

@ -33,11 +33,11 @@ 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)
// 'isFlipped': flipped according to EXIF orientation (bool)
// 'rotationDegrees': rotation degrees according to EXIF orientation or other metadata (int)
// 'latitude': latitude (double) // 'latitude': latitude (double)
// 'longitude': longitude (double) // 'longitude': longitude (double)
// 'videoRotation': video rotation degrees (int)
// 'xmpSubjects': ';' separated XMP subjects (string) // 'xmpSubjects': ';' separated XMP subjects (string)
// 'xmpTitleDescription': XMP title or XMP description (string) // 'xmpTitleDescription': XMP title or XMP description (string)
final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{ final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{

View file

@ -39,7 +39,8 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_initFutures(); _loadDatabase();
_loadMetadata();
} }
@override @override
@ -100,6 +101,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
InfoRowGroup({ InfoRowGroup({
'width': '${entry.width}', 'width': '${entry.width}',
'height': '${entry.height}', 'height': '${entry.height}',
'sourceRotationDegrees': '${entry.sourceRotationDegrees}',
'rotationDegrees': '${entry.rotationDegrees}', 'rotationDegrees': '${entry.rotationDegrees}',
'portrait': '${entry.portrait}', 'portrait': '${entry.portrait}',
'displayAspectRatio': '${entry.displayAspectRatio}', 'displayAspectRatio': '${entry.displayAspectRatio}',
@ -118,8 +120,8 @@ 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}',
'isFlipped': '${entry.isFlipped}',
'canEdit': '${entry.canEdit}', 'canEdit': '${entry.canEdit}',
'canEditExif': '${entry.canEditExif}', 'canEditExif': '${entry.canEditExif}',
'canPrint': '${entry.canPrint}', 'canPrint': '${entry.canPrint}',
@ -165,10 +167,24 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
} }
Widget _buildDbTabView() { Widget _buildDbTabView() {
final catalog = entry.catalogMetadata;
return ListView( return ListView(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
children: [ children: [
Row(
children: [
Expanded(
child: Text('DB'),
),
SizedBox(width: 8),
RaisedButton(
onPressed: () async {
await metadataDb.removeIds([entry.contentId]);
_loadDatabase();
},
child: Text('Remove from DB'),
),
],
),
FutureBuilder<DateMetadata>( FutureBuilder<DateMetadata>(
future: _dbDateLoader, future: _dbDateLoader,
builder: (context, snapshot) { builder: (context, snapshot) {
@ -202,9 +218,9 @@ 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}', 'isFlipped': '${data.isFlipped}',
'rotationDegrees': '${data.rotationDegrees}',
'latitude': '${data.latitude}', 'latitude': '${data.latitude}',
'longitude': '${data.longitude}', 'longitude': '${data.longitude}',
'xmpSubjects': '${data.xmpSubjects}', 'xmpSubjects': '${data.xmpSubjects}',
@ -237,21 +253,6 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
); );
}, },
), ),
Divider(),
Text('Catalog metadata:${catalog == null ? ' no data' : ''}'),
if (catalog != null)
InfoRowGroup({
'contentId': '${catalog.contentId}',
'mimeType': '${catalog.mimeType}',
'dateMillis': '${catalog.dateMillis}',
'isFlipped': '${catalog.isFlipped}',
'isAnimated': '${catalog.isAnimated}',
'videoRotation': '${catalog.videoRotation}',
'latitude': '${catalog.latitude}',
'longitude': '${catalog.longitude}',
'xmpSubjects': '${catalog.xmpSubjects}',
'xmpTitleDescription': '${catalog.xmpTitleDescription}',
}),
], ],
); );
} }
@ -312,10 +313,14 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
); );
} }
void _initFutures() { void _loadDatabase() {
_dbDateLoader = metadataDb.loadDates().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null)); _dbDateLoader = metadataDb.loadDates().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
_dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null)); _dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
_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));
setState(() {});
}
void _loadMetadata() {
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry); _contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry);
_exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry); _exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry);
_mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry); _mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry);

View file

@ -84,7 +84,7 @@ class BasicSection extends StatelessWidget {
} }
Map<String, String> _buildVideoRows() { Map<String, String> _buildVideoRows() {
final rotation = entry.catalogMetadata?.videoRotation; final rotation = entry.catalogMetadata?.rotationDegrees;
return { return {
'Duration': entry.durationText, 'Duration': entry.durationText,
if (rotation != null) 'Rotation': '$rotation°', if (rotation != null) 'Rotation': '$rotation°',

View file

@ -80,7 +80,7 @@ class AvesVideoState extends State<AvesVideo> {
color: Colors.black, color: Colors.black,
); );
final degree = entry.catalogMetadata?.videoRotation ?? 0; final degree = entry.catalogMetadata?.rotationDegrees ?? 0;
if (degree != 0) { if (degree != 0) {
child = RotatedBox( child = RotatedBox(
quarterTurns: degree ~/ 90, quarterTurns: degree ~/ 90,