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.XMPException;
|
||||||
import com.adobe.internal.xmp.XMPIterator;
|
import com.adobe.internal.xmp.XMPIterator;
|
||||||
import com.adobe.internal.xmp.XMPMeta;
|
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.XMPProperty;
|
||||||
import com.adobe.internal.xmp.properties.XMPPropertyInfo;
|
import com.adobe.internal.xmp.properties.XMPPropertyInfo;
|
||||||
import com.drew.imaging.ImageMetadataReader;
|
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.GeoLocation;
|
||||||
import com.drew.lang.Rational;
|
import com.drew.lang.Rational;
|
||||||
|
import com.drew.lang.annotations.NotNull;
|
||||||
import com.drew.metadata.Directory;
|
import com.drew.metadata.Directory;
|
||||||
import com.drew.metadata.Metadata;
|
import com.drew.metadata.Metadata;
|
||||||
|
import com.drew.metadata.MetadataException;
|
||||||
import com.drew.metadata.Tag;
|
import com.drew.metadata.Tag;
|
||||||
import com.drew.metadata.exif.ExifIFD0Directory;
|
import com.drew.metadata.exif.ExifIFD0Directory;
|
||||||
|
import com.drew.metadata.exif.ExifReader;
|
||||||
import com.drew.metadata.exif.ExifSubIFDDirectory;
|
import com.drew.metadata.exif.ExifSubIFDDirectory;
|
||||||
|
import com.drew.metadata.exif.ExifThumbnailDirectory;
|
||||||
import com.drew.metadata.exif.GpsDirectory;
|
import com.drew.metadata.exif.GpsDirectory;
|
||||||
import com.drew.metadata.file.FileTypeDirectory;
|
import com.drew.metadata.file.FileTypeDirectory;
|
||||||
import com.drew.metadata.gif.GifAnimationDirectory;
|
import com.drew.metadata.gif.GifAnimationDirectory;
|
||||||
import com.drew.metadata.webp.WebpDirectory;
|
import com.drew.metadata.webp.WebpDirectory;
|
||||||
import com.drew.metadata.xmp.XmpDirectory;
|
import com.drew.metadata.xmp.XmpDirectory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.TimeZone;
|
import java.util.TimeZone;
|
||||||
|
@ -70,9 +82,15 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
||||||
|
|
||||||
// XMP
|
// XMP
|
||||||
private static final String XMP_DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/";
|
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_SUBJECT_PROP_NAME = "dc:subject";
|
||||||
private static final String XMP_TITLE_PROP_NAME = "dc:title";
|
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_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_GENERIC_LANG = "";
|
||||||
private static final String XMP_SPECIFIC_LANG = "en-US";
|
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)
|
// "+51.3328-000.7053+113.474/" (Apple)
|
||||||
private static final Pattern VIDEO_LOCATION_PATTERN = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*");
|
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;
|
private Context context;
|
||||||
|
|
||||||
public MetadataHandler(Context context) {
|
public MetadataHandler(Context context) {
|
||||||
|
@ -129,6 +190,12 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
||||||
case "getContentResolverMetadata":
|
case "getContentResolverMetadata":
|
||||||
new Thread(() -> getContentResolverMetadata(call, new MethodResultWrapper(result))).start();
|
new Thread(() -> getContentResolverMetadata(call, new MethodResultWrapper(result))).start();
|
||||||
break;
|
break;
|
||||||
|
case "getExifThumbnails":
|
||||||
|
new Thread(() -> getExifThumbnails(call, new MethodResultWrapper(result))).start();
|
||||||
|
break;
|
||||||
|
case "getXmpThumbnails":
|
||||||
|
new Thread(() -> getXmpThumbnails(call, new MethodResultWrapper(result))).start();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
result.notImplemented();
|
result.notImplemented();
|
||||||
break;
|
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
|
// convenience methods
|
||||||
|
|
||||||
private static <T extends Directory> void putDateFromDirectoryTag(Map<String, Object> metadataMap, String key, Metadata metadata, Class<T> dirClass, int tag) {
|
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_entry.dart';
|
||||||
import 'package:aves/model/image_metadata.dart';
|
import 'package:aves/model/image_metadata.dart';
|
||||||
import 'package:aves/services/service_policy.dart';
|
import 'package:aves/services/service_policy.dart';
|
||||||
|
@ -86,4 +88,28 @@ class MetadataService {
|
||||||
}
|
}
|
||||||
return {};
|
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/highlight_title.dart';
|
||||||
import 'package:aves/widgets/common/icons.dart';
|
import 'package:aves/widgets/common/icons.dart';
|
||||||
import 'package:aves/widgets/fullscreen/info/info_page.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:collection/collection.dart';
|
||||||
import 'package:expansion_tile_card/expansion_tile_card.dart';
|
import 'package:expansion_tile_card/expansion_tile_card.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -28,10 +29,16 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
String _loadedMetadataUri;
|
String _loadedMetadataUri;
|
||||||
final ValueNotifier<String> _expandedDirectoryNotifier = ValueNotifier(null);
|
final ValueNotifier<String> _expandedDirectoryNotifier = ValueNotifier(null);
|
||||||
|
|
||||||
|
ImageEntry get entry => widget.entry;
|
||||||
|
|
||||||
bool get isVisible => widget.visibleNotifier.value;
|
bool get isVisible => widget.visibleNotifier.value;
|
||||||
|
|
||||||
static const int maxValueLength = 140;
|
static const int maxValueLength = 140;
|
||||||
|
|
||||||
|
// directory names from metadata-extractor
|
||||||
|
static const exifThumbnailDirectory = 'Exif Thumbnail';
|
||||||
|
static const xmpDirectory = 'XMP';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
@ -95,10 +102,13 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
),
|
),
|
||||||
children: [
|
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(
|
Container(
|
||||||
alignment: Alignment.topLeft,
|
alignment: Alignment.topLeft,
|
||||||
padding: EdgeInsets.all(8),
|
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
child: InfoRowGroup(dir.tags),
|
child: InfoRowGroup(dir.tags),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -113,9 +123,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _getMetadata() async {
|
Future<void> _getMetadata() async {
|
||||||
if (_loadedMetadataUri == widget.entry.uri) return;
|
if (_loadedMetadataUri == entry.uri) return;
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
final rawMetadata = await MetadataService.getAllMetadata(widget.entry) ?? {};
|
final rawMetadata = await MetadataService.getAllMetadata(entry) ?? {};
|
||||||
_metadata = rawMetadata.entries.map((dirKV) {
|
_metadata = rawMetadata.entries.map((dirKV) {
|
||||||
final directoryName = dirKV.key as String ?? '';
|
final directoryName = dirKV.key as String ?? '';
|
||||||
final rawTags = dirKV.value as Map ?? {};
|
final rawTags = dirKV.value as Map ?? {};
|
||||||
|
@ -128,7 +138,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
return _MetadataDirectory(directoryName, tags);
|
return _MetadataDirectory(directoryName, tags);
|
||||||
}).toList()
|
}).toList()
|
||||||
..sort((a, b) => compareAsciiUpperCase(a.name, b.name));
|
..sort((a, b) => compareAsciiUpperCase(a.name, b.name));
|
||||||
_loadedMetadataUri = widget.entry.uri;
|
_loadedMetadataUri = entry.uri;
|
||||||
} else {
|
} else {
|
||||||
_metadata = [];
|
_metadata = [];
|
||||||
_loadedMetadataUri = null;
|
_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