diff --git a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java index 3cd19980b..f109c5fb1 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -5,6 +5,7 @@ import android.os.Bundle; import deckers.thibault.aves.channelhandlers.AppAdapterHandler; import deckers.thibault.aves.channelhandlers.ImageDecodeHandler; import deckers.thibault.aves.channelhandlers.MediaStoreStreamHandler; +import deckers.thibault.aves.channelhandlers.MetadataHandler; import io.flutter.app.FlutterActivity; import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.MethodChannel; @@ -22,6 +23,7 @@ public class MainActivity extends FlutterActivity { FlutterView messenger = getFlutterView(); new MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(new AppAdapterHandler(this)); new MethodChannel(messenger, ImageDecodeHandler.CHANNEL).setMethodCallHandler(new ImageDecodeHandler(this, mediaStoreStreamHandler)); + new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler()); new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler); } } diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeHandler.java index 9a3f159a8..457dd81a7 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeHandler.java @@ -8,9 +8,6 @@ import android.net.Uri; import android.provider.Settings; import android.support.annotation.NonNull; -import com.drew.imaging.ImageMetadataReader; -import com.drew.metadata.Metadata; -import com.drew.metadata.exif.ExifSubIFDDirectory; import com.karumi.dexter.Dexter; import com.karumi.dexter.PermissionToken; import com.karumi.dexter.listener.PermissionDeniedResponse; @@ -18,10 +15,6 @@ import com.karumi.dexter.listener.PermissionGrantedResponse; import com.karumi.dexter.listener.PermissionRequest; import com.karumi.dexter.listener.single.PermissionListener; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.InputStream; -import java.util.HashMap; import java.util.Map; import deckers.thibault.aves.model.ImageEntry; @@ -65,10 +58,6 @@ public class ImageDecodeHandler implements MethodChannel.MethodCallHandler { result.success(null); break; } - case "getOverlayMetadata": - String path = call.argument("path"); - getOverlayMetadata(result, path); - break; default: result.notImplemented(); break; @@ -124,31 +113,4 @@ public class ImageDecodeHandler implements MethodChannel.MethodCallHandler { } }).check(); } - - private void getOverlayMetadata(MethodChannel.Result result, String path) { - try (InputStream is = new FileInputStream(path)) { - Metadata metadata = ImageMetadataReader.readMetadata(is); - ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class); - Map metadataMap = new HashMap<>(); - if (directory != null) { - if (directory.containsTag(ExifSubIFDDirectory.TAG_FNUMBER)) { - metadataMap.put("aperture", directory.getDescription(ExifSubIFDDirectory.TAG_FNUMBER)); - } - if (directory.containsTag(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)) { - metadataMap.put("exposureTime", directory.getString(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)); - } - if (directory.containsTag(ExifSubIFDDirectory.TAG_FOCAL_LENGTH)) { - metadataMap.put("focalLength", directory.getDescription(ExifSubIFDDirectory.TAG_FOCAL_LENGTH)); - } - if (directory.containsTag(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)) { - metadataMap.put("iso", "ISO" + directory.getDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)); - } - } - result.success(metadataMap); - } catch (FileNotFoundException e) { - result.error("getOverlayMetadata-filenotfound", "failed to get metadata for path=" + path + " (" + e.getMessage() + ")", null); - } catch (Exception e) { - result.error("getOverlayMetadata-exception", "failed to get metadata for path=" + path, e); - } - } } \ No newline at end of file diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java new file mode 100644 index 000000000..6875358e6 --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java @@ -0,0 +1,112 @@ +package deckers.thibault.aves.channelhandlers; + +import android.support.annotation.NonNull; + +import com.adobe.xmp.XMPException; +import com.adobe.xmp.XMPIterator; +import com.adobe.xmp.XMPMeta; +import com.adobe.xmp.properties.XMPPropertyInfo; +import com.drew.imaging.ImageMetadataReader; +import com.drew.metadata.Directory; +import com.drew.metadata.Metadata; +import com.drew.metadata.Tag; +import com.drew.metadata.exif.ExifSubIFDDirectory; +import com.drew.metadata.xmp.XmpDirectory; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; + +public class MetadataHandler implements MethodChannel.MethodCallHandler { + public static final String CHANNEL = "deckers.thibault/aves/metadata"; + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + switch (call.method) { + case "getOverlayMetadata": + getOverlayMetadata(call, result); + break; + case "getAllMetadata": + getAllMetadata(call, result); + break; + default: + result.notImplemented(); + break; + } + } + + private void getOverlayMetadata(MethodCall call, MethodChannel.Result result) { + String path = call.argument("path"); + try (InputStream is = new FileInputStream(path)) { + Metadata metadata = ImageMetadataReader.readMetadata(is); + ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class); + Map metadataMap = new HashMap<>(); + if (directory != null) { + if (directory.containsTag(ExifSubIFDDirectory.TAG_FNUMBER)) { + metadataMap.put("aperture", directory.getDescription(ExifSubIFDDirectory.TAG_FNUMBER)); + } + if (directory.containsTag(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)) { + metadataMap.put("exposureTime", directory.getString(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)); + } + if (directory.containsTag(ExifSubIFDDirectory.TAG_FOCAL_LENGTH)) { + metadataMap.put("focalLength", directory.getDescription(ExifSubIFDDirectory.TAG_FOCAL_LENGTH)); + } + if (directory.containsTag(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)) { + metadataMap.put("iso", "ISO" + directory.getDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)); + } + } + result.success(metadataMap); + } catch (FileNotFoundException e) { + result.error("getOverlayMetadata-filenotfound", "failed to get metadata for path=" + path + " (" + e.getMessage() + ")", null); + } catch (Exception e) { + result.error("getOverlayMetadata-exception", "failed to get metadata for path=" + path, e); + } + } + + private void getAllMetadata(MethodCall call, MethodChannel.Result result) { + String path = call.argument("path"); + try (InputStream is = new FileInputStream(path)) { + Map> metadataMap = new HashMap<>(); + Metadata metadata = ImageMetadataReader.readMetadata(is); + for (Directory dir : metadata.getDirectories()) { + if (dir.getTagCount() > 0) { + Map dirMap = new HashMap<>(); + // directory name + metadataMap.put(dir.getName(), 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) { + e.printStackTrace(); + } + } + } + } + result.success(metadataMap); + } catch (FileNotFoundException e) { + result.error("getAllMetadata-filenotfound", "failed to get metadata for path=" + path + " (" + e.getMessage() + ")", null); + } catch (Exception e) { + result.error("getAllMetadata-exception", "failed to get metadata for path=" + path, e); + } + } +} \ No newline at end of file diff --git a/lib/model/image_decode_service.dart b/lib/model/image_decode_service.dart index 562d405eb..4b9ee0836 100644 --- a/lib/model/image_decode_service.dart +++ b/lib/model/image_decode_service.dart @@ -41,17 +41,4 @@ class ImageDecodeService { debugPrint('cancelGetImageBytes failed with exception=${e.message}'); } } - - // return map with: 'aperture' 'exposureTime' 'focalLength' 'iso' - static Future getOverlayMetadata(String path) async { - try { - final result = await platform.invokeMethod('getOverlayMetadata', { - 'path': path, - }); - return result as Map; - } on PlatformException catch (e) { - debugPrint('getOverlayMetadata failed with exception=${e.message}'); - } - return Map(); - } } diff --git a/lib/model/metadata_service.dart b/lib/model/metadata_service.dart new file mode 100644 index 000000000..9a37dfa2f --- /dev/null +++ b/lib/model/metadata_service.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class MetadataService { + static const platform = const MethodChannel('deckers.thibault/aves/metadata'); + + // return map with: 'aperture' 'exposureTime' 'focalLength' 'iso' + static Future getOverlayMetadata(String path) async { + try { + final result = await platform.invokeMethod('getOverlayMetadata', { + 'path': path, + }); + return result as Map; + } on PlatformException catch (e) { + debugPrint('getOverlayMetadata failed with exception=${e.message}'); + } + return Map(); + } + + // return Map> + static Future getAllMetadata(String path) async { + try { + final result = await platform.invokeMethod('getAllMetadata', { + 'path': path, + }); + return result as Map; + } on PlatformException catch (e) { + debugPrint('getAllMetadata failed with exception=${e.message}'); + } + return Map(); + } +} diff --git a/lib/widgets/fullscreen/info_page.dart b/lib/widgets/fullscreen/info_page.dart index 1426000ae..b7275ffef 100644 --- a/lib/widgets/fullscreen/info_page.dart +++ b/lib/widgets/fullscreen/info_page.dart @@ -1,13 +1,39 @@ import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/metadata_service.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -class InfoPage extends StatelessWidget { +class InfoPage extends StatefulWidget { final ImageEntry entry; const InfoPage({this.entry}); + @override + State createState() => InfoPageState(); +} + +class InfoPageState extends State { + Future _metadataLoader; + + ImageEntry get entry => widget.entry; + + @override + void initState() { + super.initState(); + initMetadataLoader(); + } + + @override + void didUpdateWidget(InfoPage oldWidget) { + super.didUpdateWidget(oldWidget); + initMetadataLoader(); + } + + initMetadataLoader() { + _metadataLoader = MetadataService.getAllMetadata(entry.path); + } + @override Widget build(BuildContext context) { final date = entry.getBestDate(); @@ -15,18 +41,70 @@ class InfoPage extends StatelessWidget { appBar: AppBar( title: Text('Info'), ), - body: Padding( + body: ListView( padding: EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InfoRow('Title', entry.title), - InfoRow('Date', '${DateFormat.yMMMd().format(date)} – ${DateFormat.Hm().format(date)}'), - InfoRow('Size', formatFilesize(entry.sizeBytes)), - InfoRow('Resolution', '${entry.width} × ${entry.height} (${entry.getMegaPixels()} MP)'), - InfoRow('Path', entry.path), - ], - ), + children: [ + SectionRow('File'), + InfoRow('Title', entry.title), + InfoRow('Date', '${DateFormat.yMMMd().format(date)} – ${DateFormat.Hm().format(date)}'), + InfoRow('Resolution', '${entry.width} × ${entry.height} (${entry.getMegaPixels()} MP)'), + InfoRow('Size', formatFilesize(entry.sizeBytes)), + InfoRow('Path', entry.path), + SectionRow('Location'), + SectionRow('XMP Tags'), + SectionRow('Metadata'), + FutureBuilder( + future: _metadataLoader, + builder: (futureContext, AsyncSnapshot snapshot) { + if (snapshot.hasError) { + return Text(snapshot.error); + } + if (snapshot.connectionState != ConnectionState.done) { + return CircularProgressIndicator(); + } + final metadataMap = snapshot.data.cast(); + final directoryNames = metadataMap.keys.toList()..sort(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: directoryNames.expand( + (directoryName) { + final directory = metadataMap[directoryName]; + final tagKeys = directory.keys.toList()..sort(); + return [ + Padding( + padding: EdgeInsets.symmetric(vertical: 4.0), + child: Text(directoryName, style: TextStyle(fontSize: 18)), + ), + ...tagKeys.map((tagKey) => InfoRow(tagKey, directory[tagKey])), + SizedBox(height: 16), + ]; + }, + ).toList()); + }, + ), + ], + ), + ); + } +} + +class SectionRow extends StatelessWidget { + final String title; + + const SectionRow(this.title); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4.0), + child: Row( + children: [ + Expanded(child: Divider(color: Colors.white70)), + SizedBox(width: 8), + Text(title, style: TextStyle(fontSize: 18)), + SizedBox(width: 8), + Expanded(child: Divider(color: Colors.white70)), + ], ), ); } @@ -39,15 +117,17 @@ class InfoRow extends StatelessWidget { @override Widget build(BuildContext context) { - return Wrap( - children: [ - Text( - label, - style: TextStyle(color: Colors.white70), + return Padding( + padding: EdgeInsets.symmetric(vertical: 4.0), + child: RichText( + text: TextSpan( + style: DefaultTextStyle.of(context).style, + children: [ + TextSpan(text: '$label ', style: TextStyle(color: Colors.white70)), + TextSpan(text: value), + ], ), - SizedBox(width: 8), - Text(value), - ], + ), ); } } diff --git a/lib/widgets/fullscreen/overlay.dart b/lib/widgets/fullscreen/overlay.dart index 3241e9717..fafe2199b 100644 --- a/lib/widgets/fullscreen/overlay.dart +++ b/lib/widgets/fullscreen/overlay.dart @@ -2,7 +2,7 @@ import 'dart:math'; import 'dart:ui'; import 'package:aves/model/android_app_service.dart'; -import 'package:aves/model/image_decode_service.dart'; +import 'package:aves/model/metadata_service.dart'; import 'package:aves/model/image_entry.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -90,7 +90,7 @@ class _FullscreenBottomOverlayState extends State { } initDetailLoader() { - _detailLoader = ImageDecodeService.getOverlayMetadata(entry.path); + _detailLoader = MetadataService.getOverlayMetadata(entry.path); } @override