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 index 5dd54d6d3..b69a320f3 100644 --- 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 @@ -16,24 +16,36 @@ import androidx.annotation.Nullable; 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.imaging.jpeg.JpegMetadataReader; +import com.drew.imaging.jpeg.JpegSegmentMetadataReader; +import com.drew.imaging.jpeg.JpegSegmentType; import com.drew.lang.GeoLocation; import com.drew.lang.Rational; +import com.drew.lang.annotations.NotNull; import com.drew.metadata.Directory; import com.drew.metadata.Metadata; +import com.drew.metadata.MetadataException; import com.drew.metadata.Tag; import com.drew.metadata.exif.ExifIFD0Directory; +import com.drew.metadata.exif.ExifReader; import com.drew.metadata.exif.ExifSubIFDDirectory; +import com.drew.metadata.exif.ExifThumbnailDirectory; 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; @@ -70,9 +82,15 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { // 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"; @@ -108,6 +126,49 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { // "+51.3328-000.7053+113.474/" (Apple) private static final Pattern VIDEO_LOCATION_PATTERN = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*"); + private static int TAG_THUMBNAIL_DATA = 0x10000; + + // modify metadata-extractor readers to store EXIF thumbnail data + // cf https://github.com/drewnoakes/metadata-extractor/issues/276#issuecomment-677767368 + static { + List allReaders = (List) JpegMetadataReader.ALL_READERS; + for (int n = 0, cnt = allReaders.size(); n < cnt; n++) { + if (allReaders.get(n).getClass() != ExifReader.class) { + continue; + } + + allReaders.set(n, new ExifReader() { + @Override + public void readJpegSegments(@NotNull final Iterable segments, @NotNull final Metadata metadata, @NotNull final JpegSegmentType segmentType) { + super.readJpegSegments(segments, metadata, segmentType); + + for (byte[] segmentBytes : segments) { + // Filter any segments containing unexpected preambles + if (!startsWithJpegExifPreamble(segmentBytes)) { + continue; + } + + // Extract the thumbnail + try { + ExifThumbnailDirectory tnDirectory = metadata.getFirstDirectoryOfType(ExifThumbnailDirectory.class); + if (tnDirectory != null && tnDirectory.containsTag(ExifThumbnailDirectory.TAG_THUMBNAIL_OFFSET)) { + int offset = tnDirectory.getInt(ExifThumbnailDirectory.TAG_THUMBNAIL_OFFSET); + int length = tnDirectory.getInt(ExifThumbnailDirectory.TAG_THUMBNAIL_LENGTH); + + byte[] tnData = new byte[length]; + System.arraycopy(segmentBytes, JPEG_SEGMENT_PREAMBLE.length() + offset, tnData, 0, length); + tnDirectory.setObject(TAG_THUMBNAIL_DATA, tnData); + } + } catch (MetadataException e) { + e.printStackTrace(); + } + } + } + }); + break; + } + } + private Context context; public MetadataHandler(Context context) { @@ -129,6 +190,12 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { case "getContentResolverMetadata": new Thread(() -> getContentResolverMetadata(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; @@ -463,6 +530,50 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { } } + 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)) { + Metadata metadata = ImageMetadataReader.readMetadata(is); + for (ExifThumbnailDirectory dir : metadata.getDirectoriesOfType(ExifThumbnailDirectory.class)) { + byte[] data = (byte[]) dir.getObject(TAG_THUMBNAIL_DATA); + if (data != null) { + thumbnails.add(data); + } + } + } catch (IOException | ImageProcessingException | NoClassDefFoundError e) { + Log.w(LOG_TAG, "failed to extract exif thumbnail", e); + } + result.success(thumbnails); + } + + private void getXmpThumbnails(@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)) { + 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 putDateFromDirectoryTag(Map metadataMap, String key, Metadata metadata, Class dirClass, int tag) { diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index 83cdc40aa..276fc2a34 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:aves/services/service_policy.dart'; @@ -86,4 +88,28 @@ class MetadataService { } return {}; } + + static Future> getExifThumbnails(String uri) async { + try { + final result = await platform.invokeMethod('getExifThumbnails', { + 'uri': uri, + }); + return (result as List).cast(); + } on PlatformException catch (e) { + debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return []; + } + + static Future> getXmpThumbnails(String uri) async { + try { + final result = await platform.invokeMethod('getXmpThumbnails', { + 'uri': uri, + }); + return (result as List).cast(); + } on PlatformException catch (e) { + debugPrint('getXmpThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return []; + } } diff --git a/lib/widgets/fullscreen/info/metadata_section.dart b/lib/widgets/fullscreen/info/metadata_section.dart index c4f369611..38107d084 100644 --- a/lib/widgets/fullscreen/info/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata_section.dart @@ -6,6 +6,7 @@ import 'package:aves/services/metadata_service.dart'; import 'package:aves/widgets/common/highlight_title.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart'; +import 'package:aves/widgets/fullscreen/info/metadata_thumbnail.dart'; import 'package:collection/collection.dart'; import 'package:expansion_tile_card/expansion_tile_card.dart'; import 'package:flutter/material.dart'; @@ -28,10 +29,16 @@ class _MetadataSectionSliverState extends State with Auto String _loadedMetadataUri; final ValueNotifier _expandedDirectoryNotifier = ValueNotifier(null); + ImageEntry get entry => widget.entry; + bool get isVisible => widget.visibleNotifier.value; static const int maxValueLength = 140; + // directory names from metadata-extractor + static const exifThumbnailDirectory = 'Exif Thumbnail'; + static const xmpDirectory = 'XMP'; + @override void initState() { super.initState(); @@ -95,10 +102,13 @@ class _MetadataSectionSliverState extends State with Auto fontSize: 18, ), children: [ - Divider(thickness: 1.0, height: 1.0), + Divider(thickness: 1, height: 1), + SizedBox(height: 4), + if (dir.name == exifThumbnailDirectory) MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry), + if (dir.name == xmpDirectory) MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry), Container( alignment: Alignment.topLeft, - padding: EdgeInsets.all(8), + padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), child: InfoRowGroup(dir.tags), ), ], @@ -113,9 +123,9 @@ class _MetadataSectionSliverState extends State with Auto } Future _getMetadata() async { - if (_loadedMetadataUri == widget.entry.uri) return; + if (_loadedMetadataUri == entry.uri) return; if (isVisible) { - final rawMetadata = await MetadataService.getAllMetadata(widget.entry) ?? {}; + final rawMetadata = await MetadataService.getAllMetadata(entry) ?? {}; _metadata = rawMetadata.entries.map((dirKV) { final directoryName = dirKV.key as String ?? ''; final rawTags = dirKV.value as Map ?? {}; @@ -128,7 +138,7 @@ class _MetadataSectionSliverState extends State with Auto return _MetadataDirectory(directoryName, tags); }).toList() ..sort((a, b) => compareAsciiUpperCase(a.name, b.name)); - _loadedMetadataUri = widget.entry.uri; + _loadedMetadataUri = entry.uri; } else { _metadata = []; _loadedMetadataUri = null; diff --git a/lib/widgets/fullscreen/info/metadata_thumbnail.dart b/lib/widgets/fullscreen/info/metadata_thumbnail.dart new file mode 100644 index 000000000..8599ae9d9 --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata_thumbnail.dart @@ -0,0 +1,67 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/services/metadata_service.dart'; +import 'package:flutter/material.dart'; + +enum MetadataThumbnailSource { exif, xmp } + +class MetadataThumbnails extends StatefulWidget { + final MetadataThumbnailSource source; + final ImageEntry entry; + + const MetadataThumbnails({ + Key key, + @required this.source, + @required this.entry, + }) : super(key: key); + + @override + _MetadataThumbnailsState createState() => _MetadataThumbnailsState(); +} + +class _MetadataThumbnailsState extends State { + Future> _loader; + + @override + void initState() { + super.initState(); + switch (widget.source) { + case MetadataThumbnailSource.exif: + _loader = MetadataService.getExifThumbnails(widget.entry.uri); + break; + case MetadataThumbnailSource.xmp: + _loader = MetadataService.getXmpThumbnails(widget.entry.uri); + break; + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: _loader, + builder: (context, snapshot) { + if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done && snapshot.data.isNotEmpty) { + final turns = (widget.entry.orientationDegrees / 90).round(); + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + return Container( + alignment: AlignmentDirectional.topStart, + padding: EdgeInsets.only(left: 8, top: 8, right: 8, bottom: 4), + child: Wrap( + children: snapshot.data.map((bytes) { + return RotatedBox( + quarterTurns: turns, + child: Image.memory( + bytes, + scale: devicePixelRatio, + ), + ); + }).toList(), + ), + ); + } + return SizedBox.shrink(); + }); + } +}