Kotlin migration (WIP)
This commit is contained in:
parent
ae413dd82c
commit
123a4df495
9 changed files with 686 additions and 732 deletions
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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°',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue