From 123a4df49511ec579e0b1baa9ade786e85acd7a9 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 9 Oct 2020 00:06:21 +0900 Subject: [PATCH] Kotlin migration (WIP) --- .../aves/channel/calls/MetadataHandler.java | 680 ------------------ .../aves/channel/calls/MetadataHandler.kt | 590 +++++++++++++++ .../thibault/aves/model/SourceImageEntry.kt | 6 +- .../aves/utils/ExifInterfaceHelper.kt | 14 +- .../utils/MediaMetadataRetrieverHelper.kt | 74 +- .../utils/{MetadataHelper.kt => Metadata.kt} | 13 +- .../aves/utils/MetadataExtractorHelper.kt | 13 + .../kotlin/deckers/thibault/aves/utils/XMP.kt | 26 + .../fullscreen/info/basic_section.dart | 2 - 9 files changed, 686 insertions(+), 732 deletions(-) delete mode 100644 android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt rename android/app/src/main/kotlin/deckers/thibault/aves/utils/{MetadataHelper.kt => Metadata.kt} (83%) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/utils/XMP.kt diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java deleted file mode 100644 index 61cfc08ec..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java +++ /dev/null @@ -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 VIDEO_MEDIA_METADATA_KEYS = new HashMap() { - { - 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> 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 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 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 getVideoAllMetadataByMediaMetadataRetriever(Uri uri) { - Map dirMap = new HashMap<>(); - MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri); - if (retriever != null) { - try { - for (Map.Entry 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 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 getCatalogMetadataByMetadataExtractor(Uri uri, String mimeType, String extension) { - Map 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 getVideoCatalogMetadataByMediaMetadataRetriever(Uri uri) { - Map 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 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 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 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 metadataMap = new HashMap<>(); - for (Map.Entry 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 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 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 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 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 metadataMap, String key, Directory dir, int tag) { - if (dir.containsTag(tag)) { - metadataMap.put(key, dir.getDescription(tag)); - } - } - - private static void putLocalizedTextFromXmp(Map 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()); - } - } - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt new file mode 100644 index 000000000..39937bcd7 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -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("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>() + 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 { + val dirMap = HashMap() + 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("mimeType") + val uri = Uri.parse(call.argument("uri")) + val extension = call.argument("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 { + val metadataMap = HashMap() + + 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 { + val metadataMap = HashMap() + 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("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() + 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("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() + 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() + 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() + 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() + 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() + 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("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() + 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" + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt index c4eb7da3b..7c7f3adc6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ExifInterfaceHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ExifInterfaceHelper.kt index 3403f5803..708cee3c6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ExifInterfaceHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ExifInterfaceHelper.kt @@ -216,8 +216,8 @@ object ExifInterfaceHelper { return HashMap>().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, tags: Map) { - 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) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MediaMetadataRetrieverHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MediaMetadataRetrieverHelper.kt index d7008a9e7..ca06f0bed 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MediaMetadataRetrieverHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MediaMetadataRetrieverHelper.kt @@ -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) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/Metadata.kt similarity index 83% rename from android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataHelper.kt rename to android/app/src/main/kotlin/deckers/thibault/aves/utils/Metadata.kt index 5dd264b70..5fbefbb74 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/Metadata.kt @@ -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) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataExtractorHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataExtractorHelper.kt index 88ccce6a9..1a13862a6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataExtractorHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataExtractorHelper.kt @@ -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) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/XMP.kt new file mode 100644 index 000000000..953c3832b --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/XMP.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index c9895e1ed..1d84d2151 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -84,10 +84,8 @@ class BasicSection extends StatelessWidget { } Map _buildVideoRows() { - final rotation = entry.catalogMetadata?.rotationDegrees; return { 'Duration': entry.durationText, - if (rotation != null) 'Rotation': '$rotation°', }; } }