info: show thumbnail embedded in EXIF (in JPEG only) and XMP
This commit is contained in:
parent
981ad62502
commit
d28ff8ec21
4 changed files with 219 additions and 5 deletions
|
@ -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) {
|
||||
|
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
67
lib/widgets/fullscreen/info/metadata_thumbnail.dart
Normal file
67
lib/widgets/fullscreen/info/metadata_thumbnail.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue