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

View file

@ -11,7 +11,6 @@ import android.text.format.Formatter;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.exifinterface.media.ExifInterface;
import com.adobe.internal.xmp.XMPException;
@ -64,11 +63,11 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
// catalog metadata
private static final String KEY_MIME_TYPE = "mimeType";
private static final String KEY_DATE_MILLIS = "dateMillis";
private static final String KEY_IS_FLIPPED = "isFlipped";
private static final String KEY_IS_ANIMATED = "isAnimated";
private static final String KEY_IS_FLIPPED = "isFlipped";
private static final String KEY_ROTATION_DEGREES = "rotationDegrees";
private static final String KEY_LATITUDE = "latitude";
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_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) {
String mimeType = call.argument("mimeType");
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);
if (!videoDir.isEmpty()) {
metadataMap.put("Video", videoDir);
@ -277,8 +272,8 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
Uri uri = Uri.parse(call.argument("uri"));
String extension = call.argument("extension");
Map<String, Object> metadataMap = new HashMap<>(getCatalogMetadataByImageMetadataReader(uri, mimeType, extension));
if (isVideo(mimeType)) {
Map<String, Object> metadataMap = new HashMap<>(getCatalogMetadataByMetadataExtractor(uri, mimeType, extension));
if (MimeTypes.isVideo(mimeType)) {
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri));
}
@ -286,11 +281,12 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
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<>();
if (!MimeTypes.isSupportedByMetadataExtractor(mimeType)) return metadataMap;
boolean foundExif = false;
if (MimeTypes.isSupportedByMetadataExtractor(mimeType)) {
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
@ -313,9 +309,19 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
}
// EXIF
putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL);
for (ExifSubIFDDirectory dir : metadata.getDirectoriesOfType(ExifSubIFDDirectory.class)) {
putDateFromTag(metadataMap, KEY_DATE_MILLIS, dir, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL);
}
for (ExifIFD0Directory dir : metadata.getDirectoriesOfType(ExifIFD0Directory.class)) {
foundExif = true;
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifIFD0Directory.class, ExifIFD0Directory.TAG_DATETIME);
putDateFromTag(metadataMap, KEY_DATE_MILLIS, dir, ExifIFD0Directory.TAG_DATETIME);
}
if (dir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
int orientation = dir.getInt(ExifIFD0Directory.TAG_ORIENTATION);
metadataMap.put(KEY_IS_FLIPPED, MetadataHelper.isFlippedForExifCode(orientation));
metadataMap.put(KEY_ROTATION_DEGREES, MetadataHelper.getRotationDegreesForExifCode(orientation));
}
}
// GPS
@ -363,6 +369,31 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
} 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;
}
@ -383,7 +414,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
}
}
if (rotationString != null) {
metadataMap.put(KEY_VIDEO_ROTATION, Integer.parseInt(rotationString));
metadataMap.put(KEY_ROTATION_DEGREES, Integer.parseInt(rotationString));
}
if (locationString != null) {
Matcher locationMatcher = VIDEO_LOCATION_PATTERN.matcher(locationString);
@ -420,7 +451,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
Map<String, Object> metadataMap = new HashMap<>();
if (isVideo(mimeType) || !MimeTypes.isSupportedByMetadataExtractor(mimeType)) {
if (MimeTypes.isVideo(mimeType) || !MimeTypes.isSupportedByMetadataExtractor(mimeType)) {
result.success(metadataMap);
return;
}
@ -462,9 +493,9 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
long id = ContentUris.parseId(uri);
Uri contentUri = uri;
if (mimeType.startsWith(MimeTypes.IMAGE)) {
if (MimeTypes.isImage(mimeType)) {
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);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -536,10 +567,10 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
try {
Map<String, String> metadataMap = new HashMap<>();
for (Map.Entry<String, Integer> kv : MediaMetadataRetrieverHelper.allKeys.entrySet()) {
String value = retriever.extractMetadata(kv.getValue());
for (Map.Entry<Integer, String> kv : MediaMetadataRetrieverHelper.allKeys.entrySet()) {
String value = retriever.extractMetadata(kv.getKey());
if (value != null) {
metadataMap.put(kv.getKey(), value);
metadataMap.put(kv.getValue(), value);
}
}
result.success(metadataMap);
@ -625,12 +656,6 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
// 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) {
if (dir.containsTag(tag)) {
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.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import deckers.thibault.aves.decoder.VideoThumbnail;
@ -34,17 +32,6 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
private EventChannel.EventSink eventSink;
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")
public ImageByteStreamHandler(Activity activity, Object arguments) {
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
// - Glide: https://github.com/bumptech/glide/blob/master/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java
private void getImage() {
if (mimeType != null && mimeType.startsWith(MimeTypes.VIDEO)) {
if (MimeTypes.isVideo(mimeType)) {
RequestOptions options = new RequestOptions()
.diskCacheStrategy(DiskCacheStrategy.RESOURCE);
FutureTarget<Bitmap> target = Glide.with(activity)
@ -108,7 +95,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
} finally {
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
FutureTarget<Bitmap> target = Glide.with(activity)
.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")
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
contentId = ContentUris.parseId(newUri);
if (mimeType.startsWith(MimeTypes.IMAGE)) {
if (MimeTypes.isImage(mimeType)) {
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);
}
}

View file

@ -85,10 +85,10 @@ public class MediaStoreImageProvider extends ImageProvider {
callback.onSuccess(entry);
};
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);
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);
entryCount = fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION);
}
@ -126,10 +126,6 @@ public class MediaStoreImageProvider extends ImageProvider {
int newEntryCount = 0;
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 {
@ -164,18 +160,15 @@ public class MediaStoreImageProvider extends ImageProvider {
int height = cursor.getInt(heightColumn);
final long durationMillis = durationColumn != -1 ? cursor.getLong(durationColumn) : 0;
Integer rotationDegrees = null;
// check whether the field may be `null` to distinguish it from a legitimate `0`
// this can happen for specific formats (e.g. for PNG, WEBP)
// or for JPEG that were not properly registered
if (orientationColumn != -1 && cursor.getType(orientationColumn) == Cursor.FIELD_TYPE_INTEGER) {
rotationDegrees = cursor.getInt(orientationColumn);
}
Map<String, Object> entryMap = new HashMap<String, Object>() {{
put("uri", itemUri.toString());
put("path", path);
put("sourceMimeType", mimeType);
put("sourceRotationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0);
put("sizeBytes", cursor.getLong(sizeColumn));
put("title", cursor.getString(titleColumn));
put("dateModifiedSecs", dateModifiedSecs);
@ -186,10 +179,8 @@ public class MediaStoreImageProvider extends ImageProvider {
entryMap.put("width", width);
entryMap.put("height", height);
entryMap.put("durationMillis", durationMillis);
entryMap.put("rotationDegrees", rotationDegrees != null ? rotationDegrees : 0);
if (((width <= 0 || height <= 0) && needSize(mimeType))
|| (rotationDegrees == null && needOrientation)
|| (durationMillis == 0 && needDuration)) {
// some images are incorrectly registered in the Media Store,
// they are valid but miss some attributes, such as width, height, orientation
@ -331,7 +322,7 @@ public class MediaStoreImageProvider extends ImageProvider {
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, destination.relativePath);
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName);
String volumeName = destination.volumeNameForMediaStore;
Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ?
Uri tableUrl = MimeTypes.isVideo(mimeType) ?
MediaStore.Video.Media.getContentUri(volumeName) :
MediaStore.Images.Media.getContentUri(volumeName);
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 isVideo: Boolean
get() = mimeType.startsWith(MimeTypes.VIDEO)
get() = MimeTypes.isVideo(mimeType)
companion object {
// convenience method

View file

@ -6,7 +6,7 @@ import android.content.Context
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import androidx.exifinterface.media.ExifInterface
import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.avi.AviDirectory
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.media.Mp4VideoDirectory
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.isFlippedForExifCode
import deckers.thibault.aves.utils.MetadataHelper.parseVideoMetadataDate
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.StorageUtils
import java.io.IOException
import java.util.*
class SourceImageEntry {
val uri: Uri // content or file URI
var path: String? = null // best effort to get local path
private val sourceMimeType: String
var title: String? = null
private var title: String? = null
var width: Int? = null
var height: Int? = null
private var rotationDegrees: Int? = null
private var isFlipped: Boolean? = null
var sizeBytes: Long? = null
var dateModifiedSecs: Long? = null
private var sourceRotationDegrees: Int? = null
private var sizeBytes: Long? = null
private var dateModifiedSecs: Long? = null
private var sourceDateTakenMillis: Long? = null
private var durationMillis: Long? = null
private var foundExif: Boolean = false
constructor(uri: Uri, sourceMimeType: String) {
this.uri = uri
this.sourceMimeType = sourceMimeType
@ -46,9 +52,9 @@ class SourceImageEntry {
uri = Uri.parse(map["uri"] as String)
path = map["path"] as String?
sourceMimeType = map["sourceMimeType"] as String
width = map["width"] as Int
height = map["height"] as Int
rotationDegrees = map["rotationDegrees"] as Int
width = map["width"] as Int?
height = map["height"] as Int?
sourceRotationDegrees = map["sourceRotationDegrees"] as Int?
sizeBytes = toLong(map["sizeBytes"])
title = map["title"] as String?
dateModifiedSecs = toLong(map["dateModifiedSecs"])
@ -70,8 +76,7 @@ class SourceImageEntry {
"sourceMimeType" to sourceMimeType,
"width" to width,
"height" to height,
"rotationDegrees" to (rotationDegrees ?: 0),
"isFlipped" to (isFlipped ?: false),
"sourceRotationDegrees" to (sourceRotationDegrees ?: 0),
"sizeBytes" to sizeBytes,
"title" to title,
"dateModifiedSecs" to dateModifiedSecs,
@ -99,17 +104,14 @@ class SourceImageEntry {
val hasSize: Boolean
get() = width ?: 0 > 0 && height ?: 0 > 0
private val hasOrientation: Boolean
get() = rotationDegrees != null
private val hasDuration: Boolean
get() = durationMillis ?: 0 > 0
private val isImage: Boolean
get() = sourceMimeType.startsWith(MimeTypes.IMAGE)
get() = MimeTypes.isImage(sourceMimeType)
private val isVideo: Boolean
get() = sourceMimeType.startsWith(MimeTypes.VIDEO)
get() = MimeTypes.isVideo(sourceMimeType)
val isSvg: Boolean
get() = sourceMimeType == MimeTypes.SVG
@ -119,58 +121,32 @@ class SourceImageEntry {
// finds: width, height, orientation/rotation, date, title, duration
fun fillPreCatalogMetadata(context: Context): SourceImageEntry {
if (isSvg) return this
fillByMediaMetadataRetriever(context)
if (hasSize && hasOrientation && (!isVideo || hasDuration)) return this
if (isVideo) {
fillVideoByMediaMetadataRetriever(context)
if (hasSize && hasDuration) 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)
return this
}
// expects entry with: uri, mimeType
// finds: width, height, orientation/rotation, date, title, duration
private fun fillByMediaMetadataRetriever(context: Context) {
if (isImage) return
// finds: width, height, orientation, date, duration, title
private fun fillVideoByMediaMetadataRetriever(context: Context) {
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return
try {
var width: String? = null
var height: String? = null
var rotationDegrees: String? = null
var durationMillis: String? = null
if (isImage) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH)
height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT)
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
}
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) { width = it }
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) { height = it }
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it }
retriever.getSafeLong(MediaMetadataRetriever.METADATA_KEY_DURATION) { durationMillis = it }
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { sourceDateTakenMillis = it }
retriever.getSafeString(MediaMetadataRetriever.METADATA_KEY_TITLE) { title = it }
} catch (e: Exception) {
// ignore
} finally {
@ -179,10 +155,8 @@ class SourceImageEntry {
}
}
// expects entry with: uri, mimeType
// finds: width, height, orientation, date
// finds: width, height, orientation, date, duration
private fun fillByMetadataExtractor(context: Context) {
if (!isSupportedByMetadataExtractor(sourceMimeType)) return
try {
StorageUtils.openInputStream(context, uri).use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
@ -191,62 +165,36 @@ class SourceImageEntry {
// (e.g. PNG registered as JPG)
if (isVideo) {
for (dir in metadata.getDirectoriesOfType(AviDirectory::class.java)) {
if (dir.containsTag(AviDirectory.TAG_WIDTH)) {
width = dir.getInt(AviDirectory.TAG_WIDTH)
}
if (dir.containsTag(AviDirectory.TAG_HEIGHT)) {
height = dir.getInt(AviDirectory.TAG_HEIGHT)
}
if (dir.containsTag(AviDirectory.TAG_DURATION)) {
durationMillis = dir.getLong(AviDirectory.TAG_DURATION)
}
dir.getSafeInt(AviDirectory.TAG_WIDTH) { width = it }
dir.getSafeInt(AviDirectory.TAG_HEIGHT) { height = it }
dir.getSafeLong(AviDirectory.TAG_DURATION) { durationMillis = it }
}
for (dir in metadata.getDirectoriesOfType(Mp4VideoDirectory::class.java)) {
if (dir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) {
width = dir.getInt(Mp4VideoDirectory.TAG_WIDTH)
}
if (dir.containsTag(Mp4VideoDirectory.TAG_HEIGHT)) {
height = dir.getInt(Mp4VideoDirectory.TAG_HEIGHT)
}
dir.getSafeInt(Mp4VideoDirectory.TAG_WIDTH) { width = it }
dir.getSafeInt(Mp4VideoDirectory.TAG_HEIGHT) { height = it }
}
for (dir in metadata.getDirectoriesOfType(Mp4Directory::class.java)) {
if (dir.containsTag(Mp4Directory.TAG_DURATION)) {
durationMillis = dir.getLong(Mp4Directory.TAG_DURATION)
}
dir.getSafeInt(Mp4Directory.TAG_ROTATION) { sourceRotationDegrees = it }
dir.getSafeLong(Mp4Directory.TAG_DURATION) { durationMillis = it }
}
} 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
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
if (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_WIDTH)) {
width = dir.getInt(ExifIFD0Directory.TAG_IMAGE_WIDTH)
foundExif = true
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 (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_HEIGHT)) {
height = dir.getInt(ExifIFD0Directory.TAG_IMAGE_HEIGHT)
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_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
for (dir in metadata.getDirectoriesOfType(PsdHeaderDirectory::class.java)) {
dir.getSafeInt(PsdHeaderDirectory.TAG_IMAGE_WIDTH) { width = it }
dir.getSafeInt(PsdHeaderDirectory.TAG_IMAGE_HEIGHT) { height = it }
}
}
}
@ -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
private fun fillByBitmapDecode(context: Context) {
try {

View file

@ -257,11 +257,10 @@ object ExifInterfaceHelper {
private fun fillMetadataExtractorDir(exif: ExifInterface, metadataExtractorDirs: Map<DirType, Directory>, tags: Map<String, TagMapper?>) {
for (kv in tags) {
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)
if (value != null && (value != "0" || !neverNullTags.contains(exifInterfaceTag))) {
val mapper = kv.value
if (mapper != null) {
val obj: Any? = when (mapper.format) {
TagFormat.ASCII, TagFormat.COMMENT, TagFormat.UNDEFINED -> value
TagFormat.BYTE -> value.toByteArray()
@ -279,7 +278,6 @@ object ExifInterfaceHelper {
}
}
}
}
private fun toRational(s: String?): Rational? {
s ?: return null
@ -314,6 +312,28 @@ object ExifInterfaceHelper {
if (list.isEmpty()) return null
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 {

View file

@ -5,47 +5,71 @@ 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,
val allKeys = hashMapOf(
MediaMetadataRetriever.METADATA_KEY_ALBUM to "ALBUM",
MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST to "ALBUMARTIST",
MediaMetadataRetriever.METADATA_KEY_ARTIST to "ARTIST",
MediaMetadataRetriever.METADATA_KEY_AUTHOR to "AUTHOR",
MediaMetadataRetriever.METADATA_KEY_BITRATE to "BITRATE",
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE to "CAPTURE_FRAMERATE",
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER to "CD_TRACK_NUMBER",
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE to "COLOR_RANGE",
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD to "COLOR_STANDARD",
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER to "COLOR_TRANSFER",
MediaMetadataRetriever.METADATA_KEY_COMPILATION to "COMPILATION",
MediaMetadataRetriever.METADATA_KEY_COMPOSER to "COMPOSER",
MediaMetadataRetriever.METADATA_KEY_DATE to "DATE",
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER to "DISC_NUMBER",
MediaMetadataRetriever.METADATA_KEY_DURATION to "DURATION",
MediaMetadataRetriever.METADATA_KEY_EXIF_LENGTH to "EXIF_LENGTH",
MediaMetadataRetriever.METADATA_KEY_EXIF_OFFSET to "EXIF_OFFSET",
MediaMetadataRetriever.METADATA_KEY_GENRE to "GENRE",
MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO to "HAS_AUDIO",
MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO to "HAS_VIDEO",
MediaMetadataRetriever.METADATA_KEY_LOCATION to "LOCATION",
MediaMetadataRetriever.METADATA_KEY_MIMETYPE to "MIMETYPE",
MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "NUM_TRACKS",
MediaMetadataRetriever.METADATA_KEY_TITLE to "TITLE",
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT to "VIDEO_HEIGHT",
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION to "VIDEO_ROTATION",
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "VIDEO_WIDTH",
MediaMetadataRetriever.METADATA_KEY_WRITER to "WRITER",
MediaMetadataRetriever.METADATA_KEY_YEAR to "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,
MediaMetadataRetriever.METADATA_KEY_HAS_IMAGE to "HAS_IMAGE",
MediaMetadataRetriever.METADATA_KEY_IMAGE_COUNT to "IMAGE_COUNT",
MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT to "IMAGE_HEIGHT",
MediaMetadataRetriever.METADATA_KEY_IMAGE_PRIMARY to "IMAGE_PRIMARY",
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION to "IMAGE_ROTATION",
MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH to "IMAGE_WIDTH",
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.*
object MimeTypes {
const val IMAGE = "image"
private const val IMAGE = "image"
// generic raster
const val BMP = "image/bmp"
private const val BMP = "image/bmp"
const val GIF = "image/gif"
const val HEIC = "image/heic"
const val HEIF = "image/heif"
const val ICO = "image/x-icon"
const val JPEG = "image/jpeg"
const val PCX = "image/x-pcx"
const val PNG = "image/png"
const val PSD = "image/x-photoshop" // aka "image/vnd.adobe.photoshop"
const val TIFF = "image/tiff"
const val WBMP = "image/vnd.wap.wbmp"
private const val HEIC = "image/heic"
private const val HEIF = "image/heif"
private const val ICO = "image/x-icon"
private const val JPEG = "image/jpeg"
private const val PCX = "image/x-pcx"
private const val PNG = "image/png"
private const val PSD = "image/x-photoshop" // aka "image/vnd.adobe.photoshop"
private const val TIFF = "image/tiff"
private const val WBMP = "image/vnd.wap.wbmp"
const val WEBP = "image/webp"
// raw raster
const val ARW = "image/x-sony-arw"
const val CR2 = "image/x-canon-cr2"
const val CRW = "image/x-canon-crw"
const val DCR = "image/x-kodak-dcr"
const val DNG = "image/x-adobe-dng"
const val ERF = "image/x-epson-erf"
const val K25 = "image/x-kodak-k25"
const val KDC = "image/x-kodak-kdc"
const val MRW = "image/x-minolta-mrw"
const val NEF = "image/x-nikon-nef"
const val NRW = "image/x-nikon-nrw"
const val ORF = "image/x-olympus-orf"
const val PEF = "image/x-pentax-pef"
const val RAF = "image/x-fuji-raf"
const val RAW = "image/x-panasonic-raw"
const val RW2 = "image/x-panasonic-rw2"
const val SR2 = "image/x-sony-sr2"
const val SRF = "image/x-sony-srf"
const val SRW = "image/x-samsung-srw"
const val X3F = "image/x-sigma-x3f"
private const val ARW = "image/x-sony-arw"
private const val CR2 = "image/x-canon-cr2"
private const val CRW = "image/x-canon-crw"
private const val DCR = "image/x-kodak-dcr"
private const val DNG = "image/x-adobe-dng"
private const val ERF = "image/x-epson-erf"
private const val K25 = "image/x-kodak-k25"
private const val KDC = "image/x-kodak-kdc"
private const val MRW = "image/x-minolta-mrw"
private const val NEF = "image/x-nikon-nef"
private const val NRW = "image/x-nikon-nrw"
private const val ORF = "image/x-olympus-orf"
private const val PEF = "image/x-pentax-pef"
private const val RAF = "image/x-fuji-raf"
private const val RAW = "image/x-panasonic-raw"
private const val RW2 = "image/x-panasonic-rw2"
private const val SR2 = "image/x-sony-sr2"
private const val SRF = "image/x-sony-srf"
private const val SRW = "image/x-samsung-srw"
private const val X3F = "image/x-sigma-x3f"
// vector
const val SVG = "image/svg+xml"
const val VIDEO = "video"
private const val VIDEO = "video"
const val AVI = "video/avi"
const val MOV = "video/quicktime"
const val MP2T = "video/mp2t"
const val MP4 = "video/mp4"
const val WEBM = "video/webm"
// as of metadata-extractor v2.14.0, the following formats are not supported
private val unsupportedMetadataExtractorFormats = listOf(WBMP, MP2T, WEBM)
private const val AVI = "video/avi"
private const val MOV = "video/quicktime"
private const val MP2T = "video/mp2t"
private const val MP4 = "video/mp4"
private const val WEBM = "video/webm"
@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
fun getMimeTypeForExtension(extension: String?): String? = when (extension?.toLowerCase(Locale.ROOT)) {

View file

@ -23,7 +23,7 @@ class ImageEntry {
final String sourceMimeType;
int width;
int height;
int rotationDegrees;
int sourceRotationDegrees;
final int sizeBytes;
String sourceTitle;
int _dateModifiedSecs;
@ -42,7 +42,7 @@ class ImageEntry {
this.sourceMimeType,
@required this.width,
@required this.height,
this.rotationDegrees,
this.sourceRotationDegrees,
this.sizeBytes,
this.sourceTitle,
int dateModifiedSecs,
@ -68,7 +68,7 @@ class ImageEntry {
sourceMimeType: sourceMimeType,
width: width,
height: height,
rotationDegrees: rotationDegrees,
sourceRotationDegrees: sourceRotationDegrees,
sizeBytes: sizeBytes,
sourceTitle: sourceTitle,
dateModifiedSecs: dateModifiedSecs,
@ -90,7 +90,7 @@ class ImageEntry {
sourceMimeType: map['sourceMimeType'] as String,
width: map['width'] as int ?? 0,
height: map['height'] as int ?? 0,
rotationDegrees: map['rotationDegrees'] as int ?? 0,
sourceRotationDegrees: map['sourceRotationDegrees'] as int ?? 0,
sizeBytes: map['sizeBytes'] as int,
sourceTitle: map['title'] as String,
dateModifiedSecs: map['dateModifiedSecs'] as int,
@ -108,7 +108,7 @@ class ImageEntry {
'sourceMimeType': sourceMimeType,
'width': width,
'height': height,
'rotationDegrees': rotationDegrees,
'sourceRotationDegrees': sourceRotationDegrees,
'sizeBytes': sizeBytes,
'title': sourceTitle,
'dateModifiedSecs': dateModifiedSecs,
@ -171,10 +171,10 @@ class ImageEntry {
bool get isCatalogued => _catalogMetadata != null;
bool get isFlipped => _catalogMetadata?.isFlipped ?? false;
bool get isAnimated => _catalogMetadata?.isAnimated ?? false;
bool get isFlipped => _catalogMetadata?.isFlipped ?? false;
bool get canEdit => path != null;
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 {
if (width == 0 || height == 0) return 1;
@ -220,6 +220,13 @@ class ImageEntry {
return _bestDate;
}
int get rotationDegrees => catalogMetadata?.rotationDegrees ?? sourceRotationDegrees;
set rotationDegrees(int rotationDegrees) {
sourceRotationDegrees = rotationDegrees;
catalogMetadata?.rotationDegrees = rotationDegrees;
}
int get dateModifiedSecs => _dateModifiedSecs;
set dateModifiedSecs(int dateModifiedSecs) {
@ -257,7 +264,7 @@ class ImageEntry {
String _bestTitle;
String get bestTitle {
_bestTitle ??= (_catalogMetadata != null && _catalogMetadata.xmpTitleDescription.isNotEmpty) ? _catalogMetadata.xmpTitleDescription : sourceTitle;
_bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription.isNotEmpty) ? _catalogMetadata.xmpTitleDescription : sourceTitle;
return _bestTitle;
}

View file

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

View file

@ -33,11 +33,11 @@ class MetadataService {
// return map with:
// 'mimeType': MIME type as reported by metadata extractors, not Media Store (string)
// 'dateMillis': date taken in milliseconds since Epoch (long)
// 'isFlipped': flipped according to EXIF orientation (bool)
// 'isAnimated': animated gif/webp (bool)
// 'isFlipped': flipped according to EXIF orientation (bool)
// 'rotationDegrees': rotation degrees according to EXIF orientation or other metadata (int)
// 'latitude': latitude (double)
// 'longitude': longitude (double)
// 'videoRotation': video rotation degrees (int)
// 'xmpSubjects': ';' separated XMP subjects (string)
// 'xmpTitleDescription': XMP title or XMP description (string)
final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{

View file

@ -39,7 +39,8 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
@override
void initState() {
super.initState();
_initFutures();
_loadDatabase();
_loadMetadata();
}
@override
@ -100,6 +101,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
InfoRowGroup({
'width': '${entry.width}',
'height': '${entry.height}',
'sourceRotationDegrees': '${entry.sourceRotationDegrees}',
'rotationDegrees': '${entry.rotationDegrees}',
'portrait': '${entry.portrait}',
'displayAspectRatio': '${entry.displayAspectRatio}',
@ -118,8 +120,8 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
'isPhoto': '${entry.isPhoto}',
'isVideo': '${entry.isVideo}',
'isCatalogued': '${entry.isCatalogued}',
'isFlipped': '${entry.isFlipped}',
'isAnimated': '${entry.isAnimated}',
'isFlipped': '${entry.isFlipped}',
'canEdit': '${entry.canEdit}',
'canEditExif': '${entry.canEditExif}',
'canPrint': '${entry.canPrint}',
@ -165,10 +167,24 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
}
Widget _buildDbTabView() {
final catalog = entry.catalogMetadata;
return ListView(
padding: EdgeInsets.all(16),
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>(
future: _dbDateLoader,
builder: (context, snapshot) {
@ -202,9 +218,9 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
InfoRowGroup({
'mimeType': '${data.mimeType}',
'dateMillis': '${data.dateMillis}',
'isFlipped': '${data.isFlipped}',
'isAnimated': '${data.isAnimated}',
'videoRotation': '${data.videoRotation}',
'isFlipped': '${data.isFlipped}',
'rotationDegrees': '${data.rotationDegrees}',
'latitude': '${data.latitude}',
'longitude': '${data.longitude}',
'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));
_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));
setState(() {});
}
void _loadMetadata() {
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry);
_exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry);
_mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry);

View file

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

View file

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