Kotlin migration (WIP)

This commit is contained in:
Thibault Deckers 2020-10-09 00:06:21 +09:00
parent ae413dd82c
commit 123a4df495
9 changed files with 686 additions and 732 deletions

View file

@ -1,680 +0,0 @@
package deckers.thibault.aves.channel.calls;
import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.os.Build;
import android.provider.MediaStore;
import android.text.format.Formatter;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.exifinterface.media.ExifInterface;
import com.adobe.internal.xmp.XMPException;
import com.adobe.internal.xmp.XMPIterator;
import com.adobe.internal.xmp.XMPMeta;
import com.adobe.internal.xmp.XMPUtils;
import com.adobe.internal.xmp.properties.XMPProperty;
import com.adobe.internal.xmp.properties.XMPPropertyInfo;
import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.lang.GeoLocation;
import com.drew.lang.Rational;
import com.drew.metadata.Directory;
import com.drew.metadata.Metadata;
import com.drew.metadata.Tag;
import com.drew.metadata.exif.ExifDirectoryBase;
import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.exif.ExifSubIFDDirectory;
import com.drew.metadata.exif.GpsDirectory;
import com.drew.metadata.file.FileTypeDirectory;
import com.drew.metadata.gif.GifAnimationDirectory;
import com.drew.metadata.webp.WebpDirectory;
import com.drew.metadata.xmp.XmpDirectory;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import deckers.thibault.aves.utils.ExifInterfaceHelper;
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper;
import deckers.thibault.aves.utils.MetadataHelper;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
public class MetadataHandler implements MethodChannel.MethodCallHandler {
private static final String LOG_TAG = Utils.createLogTag(MetadataHandler.class);
public static final String CHANNEL = "deckers.thibault/aves/metadata";
// catalog metadata
private static final String KEY_MIME_TYPE = "mimeType";
private static final String KEY_DATE_MILLIS = "dateMillis";
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_XMP_SUBJECTS = "xmpSubjects";
private static final String KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription";
// overlay metadata
private static final String KEY_APERTURE = "aperture";
private static final String KEY_EXPOSURE_TIME = "exposureTime";
private static final String KEY_FOCAL_LENGTH = "focalLength";
private static final String KEY_ISO = "iso";
// XMP
private static final String XMP_DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/";
private static final String XMP_XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/";
private static final String XMP_IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/";
private static final String XMP_SUBJECT_PROP_NAME = "dc:subject";
private static final String XMP_TITLE_PROP_NAME = "dc:title";
private static final String XMP_DESCRIPTION_PROP_NAME = "dc:description";
private static final String XMP_THUMBNAIL_PROP_NAME = "xmp:Thumbnails";
private static final String XMP_THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image";
private static final String XMP_GENERIC_LANG = "";
private static final String XMP_SPECIFIC_LANG = "en-US";
// video metadata keys, from android.media.MediaMetadataRetriever
private static final Map<Integer, String> VIDEO_MEDIA_METADATA_KEYS = new HashMap<Integer, String>() {
{
put(MediaMetadataRetriever.METADATA_KEY_ALBUM, "Album");
put(MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST, "Album Artist");
put(MediaMetadataRetriever.METADATA_KEY_ARTIST, "Artist");
put(MediaMetadataRetriever.METADATA_KEY_AUTHOR, "Author");
put(MediaMetadataRetriever.METADATA_KEY_BITRATE, "Bitrate");
put(MediaMetadataRetriever.METADATA_KEY_COMPOSER, "Composer");
put(MediaMetadataRetriever.METADATA_KEY_DATE, "Date");
put(MediaMetadataRetriever.METADATA_KEY_GENRE, "Content Type");
put(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO, "Has Audio");
put(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO, "Has Video");
put(MediaMetadataRetriever.METADATA_KEY_LOCATION, "Location");
put(MediaMetadataRetriever.METADATA_KEY_MIMETYPE, "MIME Type");
put(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS, "Number of Tracks");
put(MediaMetadataRetriever.METADATA_KEY_TITLE, "Title");
put(MediaMetadataRetriever.METADATA_KEY_WRITER, "Writer");
put(MediaMetadataRetriever.METADATA_KEY_YEAR, "Year");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
put(MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT, "Frame Count");
}
// TODO TLAD comment? category?
}
};
// Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
// Examples:
// "+37.5090+127.0243/" (Samsung)
// "+51.3328-000.7053+113.474/" (Apple)
private static final Pattern VIDEO_LOCATION_PATTERN = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*");
private Context context;
public MetadataHandler(Context context) {
this.context = context;
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
switch (call.method) {
case "getAllMetadata":
new Thread(() -> getAllMetadata(call, new MethodResultWrapper(result))).start();
break;
case "getCatalogMetadata":
new Thread(() -> getCatalogMetadata(call, new MethodResultWrapper(result))).start();
break;
case "getOverlayMetadata":
new Thread(() -> getOverlayMetadata(call, new MethodResultWrapper(result))).start();
break;
case "getContentResolverMetadata":
new Thread(() -> getContentResolverMetadata(call, new MethodResultWrapper(result))).start();
break;
case "getExifInterfaceMetadata":
new Thread(() -> getExifInterfaceMetadata(call, new MethodResultWrapper(result))).start();
break;
case "getMediaMetadataRetrieverMetadata":
new Thread(() -> getMediaMetadataRetrieverMetadata(call, new MethodResultWrapper(result))).start();
break;
case "getEmbeddedPictures":
new Thread(() -> getEmbeddedPictures(call, new MethodResultWrapper(result))).start();
break;
case "getExifThumbnails":
new Thread(() -> getExifThumbnails(call, new MethodResultWrapper(result))).start();
break;
case "getXmpThumbnails":
new Thread(() -> getXmpThumbnails(call, new MethodResultWrapper(result))).start();
break;
default:
result.notImplemented();
break;
}
}
private void getAllMetadata(MethodCall call, MethodChannel.Result result) {
String mimeType = call.argument("mimeType");
Uri uri = Uri.parse(call.argument("uri"));
Map<String, Map<String, String>> metadataMap = new HashMap<>();
boolean foundExif = false;
if (MimeTypes.isSupportedByMetadataExtractor(mimeType)) {
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
for (Directory dir : metadata.getDirectories()) {
if (dir.getTagCount() > 0 && !(dir instanceof FileTypeDirectory)) {
foundExif |= dir instanceof ExifDirectoryBase;
// directory name
String dirName = dir.getName();
Map<String, String> dirMap = Objects.requireNonNull(metadataMap.getOrDefault(dirName, new HashMap<>()));
metadataMap.put(dirName, dirMap);
// tags
for (Tag tag : dir.getTags()) {
dirMap.put(tag.getTagName(), tag.getDescription());
}
if (dir instanceof XmpDirectory) {
try {
XmpDirectory xmpDir = (XmpDirectory) dir;
XMPMeta xmpMeta = xmpDir.getXMPMeta();
xmpMeta.sort();
XMPIterator xmpIterator = xmpMeta.iterator();
while (xmpIterator.hasNext()) {
XMPPropertyInfo prop = (XMPPropertyInfo) xmpIterator.next();
String xmpPath = prop.getPath();
String xmpValue = prop.getValue();
if (xmpPath != null && !xmpPath.isEmpty() && xmpValue != null && !xmpValue.isEmpty()) {
dirMap.put(xmpPath, xmpValue);
}
}
} catch (XMPException e) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uri, e);
}
}
}
}
} catch (Exception | NoClassDefFoundError e) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=" + uri, e);
}
}
if (!foundExif) {
// fallback to read EXIF via ExifInterface
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
ExifInterface exif = new ExifInterface(is);
metadataMap.putAll(ExifInterfaceHelper.describeAll(exif));
} catch (IOException e) {
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=" + uri, e);
}
}
if (MimeTypes.isVideo(mimeType)) {
Map<String, String> videoDir = getVideoAllMetadataByMediaMetadataRetriever(uri);
if (!videoDir.isEmpty()) {
metadataMap.put("Video", videoDir);
}
}
if (metadataMap.isEmpty()) {
result.error("getAllMetadata-failure", "failed to get metadata for uri=" + uri, null);
} else {
result.success(metadataMap);
}
}
private Map<String, String> getVideoAllMetadataByMediaMetadataRetriever(Uri uri) {
Map<String, String> dirMap = new HashMap<>();
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri);
if (retriever != null) {
try {
for (Map.Entry<Integer, String> kv : VIDEO_MEDIA_METADATA_KEYS.entrySet()) {
Integer key = kv.getKey();
String value = retriever.extractMetadata(key);
if (value != null) {
switch (key) {
case MediaMetadataRetriever.METADATA_KEY_BITRATE:
value = Formatter.formatFileSize(context, Long.parseLong(value)) + "/sec";
break;
case MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION:
value += "°";
break;
}
dirMap.put(kv.getValue(), value);
}
}
} catch (Exception e) {
Log.w(LOG_TAG, "failed to get video metadata by MediaMetadataRetriever for uri=" + uri, e);
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release();
}
}
return dirMap;
}
private void getCatalogMetadata(MethodCall call, MethodChannel.Result result) {
String mimeType = call.argument("mimeType");
Uri uri = Uri.parse(call.argument("uri"));
String extension = call.argument("extension");
Map<String, Object> metadataMap = new HashMap<>(getCatalogMetadataByMetadataExtractor(uri, mimeType, extension));
if (MimeTypes.isVideo(mimeType)) {
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri));
}
// report success even when empty
result.success(metadataMap);
}
private Map<String, Object> getCatalogMetadataByMetadataExtractor(Uri uri, String mimeType, String extension) {
Map<String, Object> metadataMap = new HashMap<>();
boolean foundExif = false;
if (MimeTypes.isSupportedByMetadataExtractor(mimeType)) {
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
// File type
for (FileTypeDirectory dir : metadata.getDirectoriesOfType(FileTypeDirectory.class)) {
// `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`)
// `context.getContentResolver().getType()` sometimes return incorrect value
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
String detectedMimeType = dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE);
if (detectedMimeType != null && !detectedMimeType.equals(mimeType)) {
// file extension is unreliable, but we use it as a tie breaker
String extensionMimeType = MimeTypes.getMimeTypeForExtension(extension.toLowerCase());
if (detectedMimeType.equals(extensionMimeType)) {
metadataMap.put(KEY_MIME_TYPE, detectedMimeType);
}
}
}
}
// EXIF
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)) {
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
for (GpsDirectory dir : metadata.getDirectoriesOfType(GpsDirectory.class)) {
GeoLocation geoLocation = dir.getGeoLocation();
if (geoLocation != null) {
metadataMap.put(KEY_LATITUDE, geoLocation.getLatitude());
metadataMap.put(KEY_LONGITUDE, geoLocation.getLongitude());
}
}
// 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());
}
putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_TITLE_PROP_NAME);
if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_DESCRIPTION_PROP_NAME);
}
} catch (XMPException 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));
}
}
}
} 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;
}
private Map<String, Object> getVideoCatalogMetadataByMediaMetadataRetriever(Uri uri) {
Map<String, Object> metadataMap = new HashMap<>();
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri);
if (retriever != null) {
try {
String dateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE);
String rotationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
String locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION);
if (dateString != null) {
long dateMillis = MetadataHelper.parseVideoMetadataDate(dateString);
// some entries have an invalid default date (19040101T000000.000Z) that is before Epoch time
if (dateMillis > 0) {
metadataMap.put(KEY_DATE_MILLIS, dateMillis);
}
}
if (rotationString != null) {
metadataMap.put(KEY_ROTATION_DEGREES, Integer.parseInt(rotationString));
}
if (locationString != null) {
Matcher locationMatcher = VIDEO_LOCATION_PATTERN.matcher(locationString);
if (locationMatcher.find() && locationMatcher.groupCount() >= 2) {
String latitudeString = locationMatcher.group(1);
String longitudeString = locationMatcher.group(2);
if (latitudeString != null && longitudeString != null) {
try {
double latitude = Double.parseDouble(latitudeString);
double longitude = Double.parseDouble(longitudeString);
if (latitude != 0 && longitude != 0) {
metadataMap.put(KEY_LATITUDE, latitude);
metadataMap.put(KEY_LONGITUDE, longitude);
}
} catch (NumberFormatException e) {
// ignore
}
}
}
}
} catch (Exception e) {
Log.w(LOG_TAG, "failed to get catalog metadata by MediaMetadataRetriever for uri=" + uri, e);
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release();
}
}
return metadataMap;
}
private void getOverlayMetadata(MethodCall call, MethodChannel.Result result) {
String mimeType = call.argument("mimeType");
Uri uri = Uri.parse(call.argument("uri"));
Map<String, Object> metadataMap = new HashMap<>();
if (MimeTypes.isVideo(mimeType) || !MimeTypes.isSupportedByMetadataExtractor(mimeType)) {
result.success(metadataMap);
return;
}
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
for (ExifSubIFDDirectory directory : metadata.getDirectoriesOfType(ExifSubIFDDirectory.class)) {
putDescriptionFromTag(metadataMap, KEY_APERTURE, directory, ExifSubIFDDirectory.TAG_FNUMBER);
putDescriptionFromTag(metadataMap, KEY_FOCAL_LENGTH, directory, ExifSubIFDDirectory.TAG_FOCAL_LENGTH);
if (directory.containsTag(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)) {
// TAG_EXPOSURE_TIME as a string is sometimes a ratio, sometimes a decimal
// so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000)
// and process it to make sure the numerator is `1` when the ratio value is less than 1
Rational rational = directory.getRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME);
long num = rational.getNumerator();
long denom = rational.getDenominator();
if (num > denom) {
metadataMap.put(KEY_EXPOSURE_TIME, rational.toSimpleString(true) + "");
} else {
if (num != 1 && num != 0) {
rational = new Rational(1, Math.round(denom / (double) num));
}
metadataMap.put(KEY_EXPOSURE_TIME, rational.toString());
}
}
if (directory.containsTag(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)) {
metadataMap.put(KEY_ISO, "ISO" + directory.getDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT));
}
}
result.success(metadataMap);
} catch (Exception | NoClassDefFoundError e) {
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=" + uri, e.getMessage());
}
}
private void getContentResolverMetadata(MethodCall call, MethodChannel.Result result) {
String mimeType = call.argument("mimeType");
Uri uri = Uri.parse(call.argument("uri"));
long id = ContentUris.parseId(uri);
Uri contentUri = uri;
if (MimeTypes.isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
} else if (MimeTypes.isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentUri = MediaStore.setRequireOriginal(contentUri);
}
Cursor cursor = context.getContentResolver().query(contentUri, null, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
Map<String, Object> metadataMap = new HashMap<>();
int columnCount = cursor.getColumnCount();
String[] columnNames = cursor.getColumnNames();
for (int i = 0; i < columnCount; i++) {
String key = columnNames[i];
try {
switch (cursor.getType(i)) {
case Cursor.FIELD_TYPE_NULL:
default:
metadataMap.put(key, null);
break;
case Cursor.FIELD_TYPE_INTEGER:
metadataMap.put(key, cursor.getLong(i));
break;
case Cursor.FIELD_TYPE_FLOAT:
metadataMap.put(key, cursor.getFloat(i));
break;
case Cursor.FIELD_TYPE_STRING:
metadataMap.put(key, cursor.getString(i));
break;
case Cursor.FIELD_TYPE_BLOB:
metadataMap.put(key, cursor.getBlob(i));
break;
}
} catch (Exception e) {
Log.w(LOG_TAG, "failed to get value for key=" + key, e);
}
}
cursor.close();
result.success(metadataMap);
} else {
result.error("getContentResolverMetadata-null", "failed to get cursor for contentUri=" + contentUri, null);
}
}
private void getExifInterfaceMetadata(MethodCall call, MethodChannel.Result result) {
Uri uri = Uri.parse(call.argument("uri"));
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
ExifInterface exif = new ExifInterface(is);
Map<String, Object> metadataMap = new HashMap<>();
for (String tag : ExifInterfaceHelper.allTags.keySet()) {
if (exif.hasAttribute(tag)) {
metadataMap.put(tag, exif.getAttribute(tag));
}
}
result.success(metadataMap);
} catch (IOException e) {
result.error("getExifInterfaceMetadata-failure", "failed to get exif for uri=" + uri, e.getMessage());
}
}
private void getMediaMetadataRetrieverMetadata(MethodCall call, MethodChannel.Result result) {
Uri uri = Uri.parse(call.argument("uri"));
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri);
if (retriever == null) {
result.error("getMediaMetadataRetrieverMetadata-null", "failed to open retriever for uri=" + uri, null);
return;
}
try {
Map<String, String> metadataMap = new HashMap<>();
for (Map.Entry<Integer, String> kv : MediaMetadataRetrieverHelper.allKeys.entrySet()) {
String value = retriever.extractMetadata(kv.getKey());
if (value != null) {
metadataMap.put(kv.getValue(), value);
}
}
result.success(metadataMap);
} catch (Exception e) {
result.error("getMediaMetadataRetrieverMetadata-failure", "failed to extract metadata for uri=" + uri, e.getMessage());
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release();
}
}
private void getEmbeddedPictures(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Uri uri = Uri.parse(call.argument("uri"));
List<byte[]> pictures = new ArrayList<>();
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri);
if (retriever != null) {
try {
byte[] picture = retriever.getEmbeddedPicture();
if (picture != null) {
pictures.add(picture);
}
} catch (Exception e) {
result.error("getVideoEmbeddedPictures-failure", "failed to get embedded picture for uri=" + uri, e.getMessage());
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release();
}
}
result.success(pictures);
}
private void getExifThumbnails(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Uri uri = Uri.parse(call.argument("uri"));
List<byte[]> thumbnails = new ArrayList<>();
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
ExifInterface exif = new ExifInterface(is);
if (exif.hasThumbnail()) {
thumbnails.add(exif.getThumbnailBytes());
}
} catch (IOException e) {
Log.w(LOG_TAG, "failed to extract exif thumbnail with ExifInterface for uri=" + uri, e);
}
result.success(thumbnails);
}
private void getXmpThumbnails(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
String mimeType = call.argument("mimeType");
Uri uri = Uri.parse(call.argument("uri"));
if (uri == null || mimeType == null) {
result.error("getXmpThumbnails-args", "failed because of missing arguments", null);
return;
}
List<byte[]> thumbnails = new ArrayList<>();
if (MimeTypes.isSupportedByMetadataExtractor(mimeType)) {
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
for (XmpDirectory dir : metadata.getDirectoriesOfType(XmpDirectory.class)) {
XMPMeta xmpMeta = dir.getXMPMeta();
try {
if (xmpMeta.doesPropertyExist(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME)) {
int count = xmpMeta.countArrayItems(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME);
for (int i = 1; i < count + 1; i++) {
XMPProperty image = xmpMeta.getStructField(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME + "[" + i + "]", XMP_IMG_SCHEMA_NS, XMP_THUMBNAIL_IMAGE_PROP_NAME);
if (image != null) {
thumbnails.add(XMPUtils.decodeBase64(image.getValue()));
}
}
}
} catch (XMPException e) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uri, e);
}
}
} catch (IOException | ImageProcessingException | NoClassDefFoundError e) {
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e);
}
}
result.success(thumbnails);
}
// convenience methods
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());
}
}
private static void putDescriptionFromTag(Map<String, Object> metadataMap, String key, Directory dir, int tag) {
if (dir.containsTag(tag)) {
metadataMap.put(key, dir.getDescription(tag));
}
}
private static void putLocalizedTextFromXmp(Map<String, Object> metadataMap, String key, XMPMeta xmpMeta, String propName) throws XMPException {
if (xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, propName)) {
XMPProperty item = xmpMeta.getLocalizedText(XMP_DC_SCHEMA_NS, propName, XMP_GENERIC_LANG, XMP_SPECIFIC_LANG);
// double check retrieved items as the property sometimes is reported to exist but it is actually null
if (item != null) {
metadataMap.put(key, item.getValue());
}
}
}
}

View file

@ -0,0 +1,590 @@
package deckers.thibault.aves.channel.calls
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.text.format.Formatter
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPUtils
import com.adobe.internal.xmp.properties.XMPPropertyInfo
import com.drew.imaging.ImageMetadataReader
import com.drew.imaging.ImageProcessingException
import com.drew.lang.Rational
import com.drew.metadata.exif.ExifDirectoryBase
import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.exif.ExifSubIFDDirectory
import com.drew.metadata.exif.GpsDirectory
import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.gif.GifAnimationDirectory
import com.drew.metadata.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.utils.*
import deckers.thibault.aves.utils.ExifInterfaceHelper.describeAll
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.utils.Metadata.isFlippedForExifCode
import deckers.thibault.aves.utils.Metadata.parseVideoMetadataDate
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeBoolean
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDescription
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeRational
import deckers.thibault.aves.utils.MimeTypes.getMimeTypeForExtension
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.XMP.getSafeLocalizedText
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import java.io.IOException
import java.util.*
import kotlin.math.roundToLong
class MetadataHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getAllMetadata" -> Thread { getAllMetadata(call, MethodResultWrapper(result)) }.start()
"getCatalogMetadata" -> Thread { getCatalogMetadata(call, MethodResultWrapper(result)) }.start()
"getOverlayMetadata" -> Thread { getOverlayMetadata(call, MethodResultWrapper(result)) }.start()
"getContentResolverMetadata" -> Thread { getContentResolverMetadata(call, MethodResultWrapper(result)) }.start()
"getExifInterfaceMetadata" -> Thread { getExifInterfaceMetadata(call, MethodResultWrapper(result)) }.start()
"getMediaMetadataRetrieverMetadata" -> Thread { getMediaMetadataRetrieverMetadata(call, MethodResultWrapper(result)) }.start()
"getEmbeddedPictures" -> Thread { getEmbeddedPictures(call, MethodResultWrapper(result)) }.start()
"getExifThumbnails" -> Thread { getExifThumbnails(call, MethodResultWrapper(result)) }.start()
"getXmpThumbnails" -> Thread { getXmpThumbnails(call, MethodResultWrapper(result)) }.start()
else -> result.notImplemented()
}
}
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
if (mimeType == null || uri == null) {
result.error("getAllMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, MutableMap<String, String>>()
var foundExif = false
var foundXmp = false
if (isSupportedByMetadataExtractor(mimeType)) {
try {
StorageUtils.openInputStream(context, uri).use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
for (dir in metadata.directories.filter { it.tagCount > 0 && it !is FileTypeDirectory }) {
// directory name
val dirName = dir.name ?: ""
val dirMap = metadataMap.getOrDefault(dirName, HashMap())
metadataMap[dirName] = dirMap
// tags
dirMap.putAll(dir.tags.map { Pair(it.tagName, it.description) })
if (dir is XmpDirectory) {
try {
val xmpMeta = dir.xmpMeta.apply { sort() }
for (prop in xmpMeta) {
if (prop is XMPPropertyInfo) {
val path = prop.path
val value = prop.value
if (path?.isNotEmpty() == true && value?.isNotEmpty() == true) {
dirMap[path] = value
}
}
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
}
}
if (!foundExif) {
// fallback to read EXIF via ExifInterface
try {
StorageUtils.openInputStream(context, uri).use { input ->
val allTags = describeAll(ExifInterface(input)).toMutableMap()
if (foundXmp) {
// do not overwrite XMP parsed by metadata-extractor
// with raw XMP found by ExifInterface
allTags.remove(Metadata.DIR_XMP)
}
metadataMap.putAll(allTags.mapValues { it.value.toMutableMap() })
}
} catch (e: IOException) {
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=$uri", e)
}
}
val mediaDir = getAllMetadataByMediaMetadataRetriever(uri)
if (mediaDir.isNotEmpty()) {
metadataMap[Metadata.DIR_MEDIA] = mediaDir
}
if (metadataMap.isNotEmpty()) {
result.success(metadataMap)
} else {
result.error("getAllMetadata-failure", "failed to get metadata for uri=$uri", null)
}
}
private fun getAllMetadataByMediaMetadataRetriever(uri: Uri): MutableMap<String, String> {
val dirMap = HashMap<String, String>()
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return dirMap
try {
for ((code, name) in MediaMetadataRetrieverHelper.allKeys) {
val value = retriever.extractMetadata(code)
if (value != null) {
when (code) {
MediaMetadataRetriever.METADATA_KEY_BITRATE -> Formatter.formatFileSize(context, value.toLong()) + "/sec"
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION -> "$value°"
MediaMetadataRetriever.METADATA_KEY_DURATION -> "$value ms"
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels"
MediaMetadataRetriever.METADATA_KEY_LOCATION, MediaMetadataRetriever.METADATA_KEY_MIMETYPE -> null
else -> value
}?.let { dirMap[name] = it }
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get video metadata by MediaMetadataRetriever for uri=$uri", e)
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
return dirMap
}
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
val extension = call.argument<String>("extension")
if (mimeType == null || uri == null) {
result.error("getCatalogMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, extension))
if (isVideo(mimeType)) {
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri))
}
// report success even when empty
result.success(metadataMap)
}
private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, extension: String?): Map<String, Any> {
val metadataMap = HashMap<String, Any>()
var foundExif = false
if (isSupportedByMetadataExtractor(mimeType)) {
try {
StorageUtils.openInputStream(context, uri).use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
// File type
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
// `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`)
// `context.getContentResolver().getType()` sometimes return incorrect value
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
val detectedMimeType = dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)
if (detectedMimeType != null && detectedMimeType != mimeType) {
// file extension is unreliable, but we use it as a tie breaker
val extensionMimeType = extension?.toLowerCase(Locale.ROOT)?.let { getMimeTypeForExtension(it) }
if (extensionMimeType == null || detectedMimeType == extensionMimeType) {
metadataMap[KEY_MIME_TYPE] = detectedMimeType
}
}
}
}
// EXIF
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
dir.getSafeDateMillis(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
}
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it }
}
dir.getSafeInt(ExifIFD0Directory.TAG_ORIENTATION) {
val orientation = it
metadataMap[KEY_IS_FLIPPED] = isFlippedForExifCode(orientation)
metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation)
}
}
// GPS
for (dir in metadata.getDirectoriesOfType(GpsDirectory::class.java)) {
val geoLocation = dir.geoLocation
if (geoLocation != null) {
metadataMap[KEY_LATITUDE] = geoLocation.latitude
metadataMap[KEY_LONGITUDE] = geoLocation.longitude
}
}
// XMP
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val xmpMeta = dir.xmpMeta
try {
if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) {
val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)
val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME, it).value }
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(separator = ";")
}
xmpMeta.getSafeLocalizedText(XMP.TITLE_PROP_NAME) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
xmpMeta.getSafeLocalizedText(XMP.DESCRIPTION_PROP_NAME) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
// Animated GIF & WEBP
when (mimeType) {
MimeTypes.GIF -> {
metadataMap[KEY_IS_ANIMATED] = metadata.containsDirectoryOfType(GifAnimationDirectory::class.java)
}
MimeTypes.WEBP -> {
for (dir in metadata.getDirectoriesOfType(WebpDirectory::class.java)) {
dir.getSafeBoolean(WebpDirectory.TAG_IS_ANIMATION) { metadataMap[KEY_IS_ANIMATED] = it }
}
}
else -> {
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for uri=$uri, mimeType=$mimeType", e)
} catch (e: NoClassDefFoundError) {
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 {
StorageUtils.openInputStream(context, uri).use { input ->
val exif = ExifInterface(input)
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it }
}
exif.getSafeInt(ExifInterface.TAG_ORIENTATION, acceptZero = false) {
metadataMap[KEY_IS_FLIPPED] = exif.isFlipped
metadataMap[KEY_ROTATION_DEGREES] = exif.rotationDegrees
}
val latLong = exif.latLong
if (latLong != null && latLong.size == 2) {
metadataMap[KEY_LATITUDE] = latLong[0]
metadataMap[KEY_LONGITUDE] = latLong[1]
}
}
} catch (e: IOException) {
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=$uri", e)
}
}
return metadataMap
}
private fun getVideoCatalogMetadataByMediaMetadataRetriever(uri: Uri): Map<String, Any> {
val metadataMap = HashMap<String, Any>()
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return metadataMap
try {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
val dateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE)
if (dateString != null) {
val dateMillis = parseVideoMetadataDate(dateString)
// some entries have an invalid default date (19040101T000000.000Z) that is before Epoch time
if (dateMillis > 0) {
metadataMap[KEY_DATE_MILLIS] = dateMillis
}
}
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
if (locationString != null) {
val locationMatcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
if (locationMatcher.find() && locationMatcher.groupCount() >= 2) {
val latitudeString = locationMatcher.group(1)
val longitudeString = locationMatcher.group(2)
if (latitudeString != null && longitudeString != null) {
try {
val latitude = latitudeString.toDoubleOrNull() ?: 0
val longitude = longitudeString.toDoubleOrNull() ?: 0
if (latitude != 0 && longitude != 0) {
metadataMap[KEY_LATITUDE] = latitude
metadataMap[KEY_LONGITUDE] = longitude
}
} catch (e: NumberFormatException) {
// ignore
}
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get catalog metadata by MediaMetadataRetriever for uri=$uri", e)
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
return metadataMap
}
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
if (mimeType == null || uri == null) {
result.error("getOverlayMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, Any>()
if (isVideo(mimeType) || !isSupportedByMetadataExtractor(mimeType)) {
result.success(metadataMap)
return
}
try {
StorageUtils.openInputStream(context, uri).use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it }
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it }
dir.getSafeDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = "ISO$it" }
dir.getSafeRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME) {
// TAG_EXPOSURE_TIME as a string is sometimes a ratio, sometimes a decimal
// so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000)
// and process it to make sure the numerator is `1` when the ratio value is less than 1
val num = it.numerator
val denom = it.denominator
metadataMap[KEY_EXPOSURE_TIME] = when {
num > denom -> it.toSimpleString(true) + ""
num != 1L && num != 0L -> Rational(1, (denom / num.toDouble()).roundToLong()).toString()
else -> it.toString()
}
}
}
result.success(metadataMap)
}
} catch (e: Exception) {
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message)
} catch (e: NoClassDefFoundError) {
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message)
}
}
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
if (mimeType == null || uri == null) {
result.error("getContentResolverMetadata-args", "failed because of missing arguments", null)
return
}
val id = ContentUris.parseId(uri)
var contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentUri = MediaStore.setRequireOriginal(contentUri)
}
val cursor = context.contentResolver.query(contentUri, null, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
val metadataMap = HashMap<String, Any?>()
val columnCount = cursor.columnCount
val columnNames = cursor.columnNames
for (i in 0 until columnCount) {
val key = columnNames[i]
try {
metadataMap[key] = when (cursor.getType(i)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(i)
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(i)
Cursor.FIELD_TYPE_STRING -> cursor.getString(i)
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(i)
else -> null
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get value for key=$key", e)
}
}
cursor.close()
result.success(metadataMap)
} else {
result.error("getContentResolverMetadata-null", "failed to get cursor for contentUri=$contentUri", null)
}
}
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.parse(call.argument("uri"))
if (uri == null) {
result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null)
return
}
try {
StorageUtils.openInputStream(context, uri).use { input ->
val exif = ExifInterface(input)
val metadataMap = HashMap<String, String?>()
for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) {
metadataMap[tag] = exif.getAttribute(tag)
}
result.success(metadataMap)
}
} catch (e: IOException) {
result.error("getExifInterfaceMetadata-failure", "failed to get exif for uri=$uri", e.message)
}
}
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.parse(call.argument("uri"))
if (uri == null) {
result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
val retriever = StorageUtils.openMetadataRetriever(context, uri)
if (retriever != null) {
try {
for ((code, name) in MediaMetadataRetrieverHelper.allKeys) {
retriever.extractMetadata(code)?.let { metadataMap[name] = it }
}
} catch (e: Exception) {
// ignore
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
}
result.success(metadataMap)
}
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.parse(call.argument("uri"))
if (uri == null) {
result.error("getEmbeddedPictures-args", "failed because of missing arguments", null)
return
}
val pictures = ArrayList<ByteArray>()
val retriever = StorageUtils.openMetadataRetriever(context, uri)
if (retriever != null) {
try {
retriever.embeddedPicture?.let { pictures.add(it) }
} catch (e: Exception) {
// ignore
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
}
result.success(pictures)
}
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.parse(call.argument("uri"))
if (uri == null) {
result.error("getExifThumbnails-args", "failed because of missing arguments", null)
return
}
val thumbnails = ArrayList<ByteArray>()
try {
StorageUtils.openInputStream(context, uri).use { input ->
ExifInterface(input).thumbnailBytes?.let { thumbnails.add(it) }
}
} catch (e: IOException) {
// ignore
}
result.success(thumbnails)
}
private fun getXmpThumbnails(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
if (mimeType == null || uri == null) {
result.error("getXmpThumbnails-args", "failed because of missing arguments", null)
return
}
val thumbnails = ArrayList<ByteArray>()
if (isSupportedByMetadataExtractor(mimeType)) {
try {
StorageUtils.openInputStream(context, uri).use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val xmpMeta = dir.xmpMeta
try {
if (xmpMeta.doesPropertyExist(XMP.XMP_SCHEMA_NS, XMP.THUMBNAIL_PROP_NAME)) {
val count = xmpMeta.countArrayItems(XMP.XMP_SCHEMA_NS, XMP.THUMBNAIL_PROP_NAME)
for (i in 1 until count + 1) {
val structName = "${XMP.THUMBNAIL_PROP_NAME}[$i]"
val image = xmpMeta.getStructField(XMP.XMP_SCHEMA_NS, structName, XMP.IMG_SCHEMA_NS, XMP.THUMBNAIL_IMAGE_PROP_NAME)
if (image != null) {
thumbnails.add(XMPUtils.decodeBase64(image.value))
}
}
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
}
} catch (e: IOException) {
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e)
} catch (e: ImageProcessingException) {
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e)
}
}
result.success(thumbnails)
}
companion object {
private val LOG_TAG = Utils.createLogTag(MetadataHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/metadata"
// catalog metadata
private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_DATE_MILLIS = "dateMillis"
private const val KEY_IS_ANIMATED = "isAnimated"
private const val KEY_IS_FLIPPED = "isFlipped"
private const val KEY_ROTATION_DEGREES = "rotationDegrees"
private const val KEY_LATITUDE = "latitude"
private const val KEY_LONGITUDE = "longitude"
private const val KEY_XMP_SUBJECTS = "xmpSubjects"
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"
// overlay metadata
private const val KEY_APERTURE = "aperture"
private const val KEY_EXPOSURE_TIME = "exposureTime"
private const val KEY_FOCAL_LENGTH = "focalLength"
private const val KEY_ISO = "iso"
}
}

View file

@ -14,7 +14,7 @@ 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.getSafeDateMillis
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDateMillis
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt
@ -23,7 +23,7 @@ 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.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import java.io.IOException
@ -215,7 +215,7 @@ class SourceImageEntry {
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 }
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME) { sourceDateTakenMillis = it }
}
} catch (e: IOException) {
// ignore

View file

@ -216,8 +216,8 @@ object ExifInterfaceHelper {
return HashMap<String, Map<String, String>>().apply {
put("Exif", describeDir(exif, dirs, baseTags))
put("Exif Thumbnail", describeDir(exif, dirs, thumbnailTags))
put("GPS", describeDir(exif, dirs, gpsTags))
put("XMP", describeDir(exif, dirs, xmpTags))
put(Metadata.DIR_GPS, describeDir(exif, dirs, gpsTags))
put(Metadata.DIR_XMP, describeDir(exif, dirs, xmpTags))
put("Exif Raw", describeDir(exif, dirs, rawTags))
}.filterValues { it.isNotEmpty() }
}
@ -227,12 +227,10 @@ object ExifInterfaceHelper {
fillMetadataExtractorDir(exif, metadataExtractorDirs, tags)
for (kv in tags) {
val exifInterfaceTag: String = kv.key
for ((exifInterfaceTag, mapper) in tags) {
if (exif.hasAttribute(exifInterfaceTag)) {
val value: String? = exif.getAttribute(exifInterfaceTag)
if (value != null && (value != "0" || !neverNullTags.contains(exifInterfaceTag))) {
val mapper = kv.value
if (mapper != null) {
val dir = metadataExtractorDirs[mapper.dirType] ?: error("Directory type ${mapper.dirType} does not have a matching Directory instance")
val type = mapper.type
@ -255,9 +253,7 @@ object ExifInterfaceHelper {
}
private fun fillMetadataExtractorDir(exif: ExifInterface, metadataExtractorDirs: Map<DirType, Directory>, tags: Map<String, TagMapper?>) {
for (kv in tags) {
val exifInterfaceTag: String = kv.key
val mapper = kv.value
for ((exifInterfaceTag, mapper) in tags) {
if (exif.hasAttribute(exifInterfaceTag) && mapper != null) {
val value: String? = exif.getAttribute(exifInterfaceTag)
if (value != null && (value != "0" || !neverNullTags.contains(exifInterfaceTag))) {
@ -324,7 +320,7 @@ object ExifInterfaceHelper {
}
}
fun ExifInterface.getSafeDate(tag: String, save: (value: Long) -> Unit) {
fun ExifInterface.getSafeDateMillis(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)

View file

@ -6,45 +6,45 @@ import android.os.Build
object MediaMetadataRetrieverHelper {
@JvmField
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",
MediaMetadataRetriever.METADATA_KEY_ALBUM to "Album",
MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST to "Album Artist",
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 "MIME Type",
MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "Number of 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(
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",
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",
))
}
}
@ -68,7 +68,7 @@ object MediaMetadataRetrieverHelper {
fun MediaMetadataRetriever.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) {
val dateString = this.extractMetadata(tag)
val dateMillis = MetadataHelper.parseVideoMetadataDate(dateString)
val dateMillis = Metadata.parseVideoMetadataDate(dateString)
// some entries have an invalid default date (19040101T000000.000Z) that is before Epoch time
if (dateMillis > 0) save(dateMillis)
}

View file

@ -6,7 +6,18 @@ import java.text.SimpleDateFormat
import java.util.*
import java.util.regex.Pattern
object MetadataHelper {
object Metadata {
// Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
// Examples:
// "+37.5090+127.0243/" (Samsung)
// "+51.3328-000.7053+113.474/" (Apple)
val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*")
// directory names, as shown when listing all metadata
const val DIR_GPS = "GPS" // from metadata-extractor
const val DIR_XMP = "XMP" // from metadata-extractor
const val DIR_MEDIA = "Media"
// interpret EXIF code to angle (0, 90, 180 or 270 degrees)
@JvmStatic
fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) {

View file

@ -1,11 +1,20 @@
package deckers.thibault.aves.utils
import com.drew.lang.Rational
import com.drew.metadata.Directory
import java.util.*
object MetadataExtractorHelper {
// extensions
fun Directory.getSafeDescription(tag: Int, save: (value: String) -> Unit) {
if (this.containsTag(tag)) save(this.getDescription(tag))
}
fun Directory.getSafeBoolean(tag: Int, save: (value: Boolean) -> Unit) {
if (this.containsTag(tag)) save(this.getBoolean(tag))
}
fun Directory.getSafeInt(tag: Int, save: (value: Int) -> Unit) {
if (this.containsTag(tag)) save(this.getInt(tag))
}
@ -14,6 +23,10 @@ object MetadataExtractorHelper {
if (this.containsTag(tag)) save(this.getLong(tag))
}
fun Directory.getSafeRational(tag: Int, save: (value: Rational) -> Unit) {
if (this.containsTag(tag)) save(this.getRational(tag))
}
fun Directory.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) {
if (this.containsTag(tag)) save(this.getDate(tag, null, TimeZone.getDefault()).time)
}

View file

@ -0,0 +1,26 @@
package deckers.thibault.aves.utils
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta
object XMP {
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
const val IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/"
const val SUBJECT_PROP_NAME = "dc:subject"
const val TITLE_PROP_NAME = "dc:title"
const val DESCRIPTION_PROP_NAME = "dc:description"
const val THUMBNAIL_PROP_NAME = "xmp:Thumbnails"
const val THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image"
private const val GENERIC_LANG = ""
private const val SPECIFIC_LANG = "en-US"
@Throws(XMPException::class)
fun XMPMeta.getSafeLocalizedText(propName: String, save: (value: String) -> Unit) {
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) {
val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG)
// double check retrieved items as the property sometimes is reported to exist but it is actually null
if (item != null) save(item.value)
}
}
}

View file

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