info: show thumbnail embedded in EXIF (in JPEG only) and XMP

This commit is contained in:
Thibault Deckers 2020-09-22 23:30:35 +09:00
parent 981ad62502
commit d28ff8ec21
4 changed files with 219 additions and 5 deletions

View file

@ -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<JpegSegmentMetadataReader> allReaders = (List<JpegSegmentMetadataReader>) 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<byte[]> 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<byte[]> 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<byte[]> 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 <T extends Directory> void putDateFromDirectoryTag(Map<String, Object> metadataMap, String key, Metadata metadata, Class<T> dirClass, int tag) {

View file

@ -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<List<Uint8List>> getExifThumbnails(String uri) async {
try {
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
'uri': uri,
});
return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {
debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
static Future<List<Uint8List>> getXmpThumbnails(String uri) async {
try {
final result = await platform.invokeMethod('getXmpThumbnails', <String, dynamic>{
'uri': uri,
});
return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {
debugPrint('getXmpThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
}

View file

@ -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<MetadataSectionSliver> with Auto
String _loadedMetadataUri;
final ValueNotifier<String> _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<MetadataSectionSliver> 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<MetadataSectionSliver> with Auto
}
Future<void> _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<MetadataSectionSliver> 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;

View file

@ -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<MetadataThumbnails> {
Future<List<Uint8List>> _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<List<Uint8List>>(
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();
});
}
}