From 3ef5cde4dafb946e70a34f437191239c0622962d Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 29 Nov 2020 19:07:35 +0900 Subject: [PATCH 01/27] various minor fixes --- lib/ref/xmp.dart | 5 +- lib/widgets/about/licenses.dart | 1 + .../collection/grid/list_section_layout.dart | 7 +- .../collection/thumbnail_collection.dart | 21 ++++-- lib/widgets/common/scaling.dart | 65 +++++++++---------- lib/widgets/common/tile_extent_manager.dart | 9 +-- .../filter_grids/common/filter_grid_page.dart | 17 +++-- 7 files changed, 73 insertions(+), 52 deletions(-) diff --git a/lib/ref/xmp.dart b/lib/ref/xmp.dart index 55ddc7eda..858eeddc4 100644 --- a/lib/ref/xmp.dart +++ b/lib/ref/xmp.dart @@ -4,11 +4,13 @@ class XMP { // cf https://exiftool.org/TagNames/XMP.html static const Map namespaces = { - 'aux': 'Auxiliary Exif', + 'aux': 'Exif Aux', 'Camera': 'Camera', 'crs': 'Camera Raw Settings', 'dc': 'Dublin Core', 'exif': 'Exif', + 'exifEX': 'Exif Ex', + 'GettyImagesGIFT': 'Getty Images', 'GIMP': 'GIMP', 'illustrator': 'Illustrator', 'Iptc4xmpCore': 'IPTC Core', @@ -19,6 +21,7 @@ class XMP { 'pdfx': 'PDF/X', 'photomechanic': 'Photo Mechanic', 'photoshop': 'Photoshop', + 'plus': 'PLUS', 'tiff': 'TIFF', 'xmp': 'Basic', 'xmpBJ': 'Basic Job Ticket', diff --git a/lib/widgets/about/licenses.dart b/lib/widgets/about/licenses.dart index 3138ee81c..583366ab6 100644 --- a/lib/widgets/about/licenses.dart +++ b/lib/widgets/about/licenses.dart @@ -87,6 +87,7 @@ class _LicensesState extends State { Widget _buildHeader() { return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsetsDirectional.only(start: 8), diff --git a/lib/widgets/collection/grid/list_section_layout.dart b/lib/widgets/collection/grid/list_section_layout.dart index 540beee50..34665e056 100644 --- a/lib/widgets/collection/grid/list_section_layout.dart +++ b/lib/widgets/collection/grid/list_section_layout.dart @@ -3,7 +3,6 @@ import 'dart:math'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/collection/grid/header_generic.dart'; -import 'package:aves/widgets/collection/thumbnail_collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -15,14 +14,14 @@ class SectionedListLayoutProvider extends StatelessWidget { final Widget Function(ImageEntry entry) thumbnailBuilder; final Widget child; - SectionedListLayoutProvider({ + const SectionedListLayoutProvider({ @required this.collection, @required this.scrollableWidth, @required this.tileExtent, + @required this.columnCount, @required this.thumbnailBuilder, @required this.child, - }) : assert(scrollableWidth != 0), - columnCount = max((scrollableWidth / tileExtent).round(), ThumbnailCollection.columnCountMin); + }) : assert(scrollableWidth != 0); @override Widget build(BuildContext context) { diff --git a/lib/widgets/collection/thumbnail_collection.dart b/lib/widgets/collection/thumbnail_collection.dart index d1c1fa12d..59343b08b 100644 --- a/lib/widgets/collection/thumbnail_collection.dart +++ b/lib/widgets/collection/thumbnail_collection.dart @@ -31,9 +31,9 @@ class ThumbnailCollection extends StatelessWidget { final ValueNotifier _isScrollingNotifier = ValueNotifier(false); final GlobalKey _scrollableKey = GlobalKey(); - static const columnCountMin = 2; static const columnCountDefault = 4; static const extentMin = 46.0; + static const spacing = 0.0; @override Widget build(BuildContext context) { @@ -47,11 +47,10 @@ class ThumbnailCollection extends StatelessWidget { final tileExtentManager = TileExtentManager( settingsRouteKey: context.currentRouteName, - columnCountMin: columnCountMin, + extentNotifier: _tileExtentNotifier, columnCountDefault: columnCountDefault, extentMin: extentMin, - extentNotifier: _tileExtentNotifier, - spacing: 0, + spacing: spacing, )..applyTileExtent(viewportSize: viewportSize); final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2; @@ -77,7 +76,18 @@ class ThumbnailCollection extends StatelessWidget { scrollableKey: _scrollableKey, appBarHeightNotifier: _appBarHeightNotifier, viewportSize: viewportSize, - showScaledGrid: true, + gridBuilder: (center, extent, child) => CustomPaint( + // painting the thumbnail half-border on top of the grid yields artifacts, + // so we use a `foregroundPainter` to cover them instead + foregroundPainter: GridPainter( + center: center, + extent: extent, + spacing: tileExtentManager.spacing, + strokeWidth: DecoratedThumbnail.borderWidth * 2, + color: DecoratedThumbnail.borderColor, + ), + child: child, + ), scaledBuilder: (entry, extent) => DecoratedThumbnail( entry: entry, extent: extent, @@ -98,6 +108,7 @@ class ThumbnailCollection extends StatelessWidget { collection: collection, scrollableWidth: viewportSize.width, tileExtent: tileExtent, + columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent), thumbnailBuilder: (entry) => GridThumbnail( key: ValueKey(entry.contentId), collection: collection, diff --git a/lib/widgets/common/scaling.dart b/lib/widgets/common/scaling.dart index 9f1cc5cb1..6e74ed431 100644 --- a/lib/widgets/common/scaling.dart +++ b/lib/widgets/common/scaling.dart @@ -2,7 +2,6 @@ import 'dart:math'; import 'dart:ui' as ui; import 'package:aves/theme/durations.dart'; -import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/tile_extent_manager.dart'; import 'package:flutter/material.dart'; @@ -20,7 +19,7 @@ class GridScaleGestureDetector extends StatefulWidget { final GlobalKey scrollableKey; final ValueNotifier appBarHeightNotifier; final Size viewportSize; - final bool showScaledGrid; + final Widget Function(Offset center, double extent, Widget child) gridBuilder; final Widget Function(T item, double extent) scaledBuilder; final Rect Function(BuildContext context, T item) getScaledItemTileRect; final void Function(T item) onScaled; @@ -31,7 +30,7 @@ class GridScaleGestureDetector extends StatefulWidget { @required this.scrollableKey, @required this.appBarHeightNotifier, @required this.viewportSize, - @required this.showScaledGrid, + this.gridBuilder, @required this.scaledBuilder, @required this.getScaledItemTileRect, @required this.onScaled, @@ -56,10 +55,6 @@ class _GridScaleGestureDetectorState extends State extends State ScaleOverlay( builder: (extent) => widget.scaledBuilder(_metadata.item, extent), center: thumbnailCenter, - gridWidth: gridWidth, - spacing: tileExtentManager.spacing, + viewportWidth: gridWidth, + gridBuilder: widget.gridBuilder, scaledExtentNotifier: _scaledExtentNotifier, - showScaledGrid: widget.showScaledGrid, ), ); Overlay.of(scrollableContext).insert(_overlayEntry); @@ -133,7 +127,16 @@ class _GridScaleGestureDetectorState extends State extends State scaledExtentNotifier; - final bool showScaledGrid; + final Widget Function(Offset center, double extent, Widget child) gridBuilder; const ScaleOverlay({ @required this.builder, @required this.center, - @required this.gridWidth, - @required this.spacing, + @required this.viewportWidth, @required this.scaledExtentNotifier, - @required this.showScaledGrid, + this.gridBuilder, }); @override @@ -180,7 +181,7 @@ class _ScaleOverlayState extends State { Offset get center => widget.center; - double get gridWidth => widget.gridWidth; + double get gridWidth => widget.viewportWidth; @override void initState() { @@ -241,16 +242,7 @@ class _ScaleOverlayState extends State { ), ], ); - if (widget.showScaledGrid) { - child = CustomPaint( - painter: GridPainter( - center: clampedCenter, - extent: extent, - spacing: widget.spacing, - ), - child: child, - ); - } + child = widget.gridBuilder?.call(clampedCenter, extent, child) ?? child; return child; }, ), @@ -263,31 +255,36 @@ class _ScaleOverlayState extends State { class GridPainter extends CustomPainter { final Offset center; final double extent, spacing; + final double strokeWidth; + final Color color; const GridPainter({ @required this.center, @required this.extent, - @required this.spacing, + this.spacing = 0.0, + this.strokeWidth = 1.0, + @required this.color, }); @override void paint(Canvas canvas, Size size) { + final radius = extent * 3; final paint = Paint() - ..strokeWidth = DecoratedThumbnail.borderWidth + ..strokeWidth = strokeWidth ..shader = ui.Gradient.radial( center, - size.width * .7, + radius, [ - DecoratedThumbnail.borderColor, + color, Colors.transparent, ], [ - min(.5, 2 * extent / size.width), + extent / radius, 1, ], ); void draw(Offset topLeft) { - for (var i = -2; i <= 3; i++) { + for (var i = -1; i <= 2; i++) { final ref = (extent + spacing) * i; canvas.drawLine(Offset(0, topLeft.dy + ref), Offset(size.width, topLeft.dy + ref), paint); canvas.drawLine(Offset(topLeft.dx + ref, 0), Offset(topLeft.dx + ref, size.height), paint); diff --git a/lib/widgets/common/tile_extent_manager.dart b/lib/widgets/common/tile_extent_manager.dart index 5001f84a2..a5e3bc1ac 100644 --- a/lib/widgets/common/tile_extent_manager.dart +++ b/lib/widgets/common/tile_extent_manager.dart @@ -6,15 +6,16 @@ import 'package:flutter/widgets.dart'; class TileExtentManager { final String settingsRouteKey; final int columnCountMin, columnCountDefault; - final double spacing, extentMin; + final double spacing, extentMin, extentMax; final ValueNotifier extentNotifier; const TileExtentManager({ @required this.settingsRouteKey, - @required this.columnCountMin, + @required this.extentNotifier, + this.columnCountMin = 2, @required this.columnCountDefault, @required this.extentMin, - @required this.extentNotifier, + this.extentMax = 300, @required this.spacing, }); @@ -46,7 +47,7 @@ class TileExtentManager { return newExtent; } - double _extentMax(Size viewportSize) => (viewportSize.shortestSide - spacing * (columnCountMin - 1)) / columnCountMin; + double _extentMax(Size viewportSize) => min(extentMax, (viewportSize.shortestSide - spacing * (columnCountMin - 1)) / columnCountMin); double _columnCountForExtent(Size viewportSize, double extent) => (viewportSize.width + spacing) / (extent + spacing); diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index c6a3a7862..fe8df8890 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -37,6 +37,8 @@ class FilterGridPage extends StatelessWidget { final ValueNotifier _tileExtentNotifier = ValueNotifier(0); final GlobalKey _scrollableKey = GlobalKey(); + static const columnCountDefault = 2; + static const extentMin = 60.0; static const spacing = 8.0; FilterGridPage({ @@ -71,10 +73,9 @@ class FilterGridPage extends StatelessWidget { final tileExtentManager = TileExtentManager( settingsRouteKey: settingsRouteKey ?? context.currentRouteName, - columnCountMin: 2, - columnCountDefault: 2, - extentMin: 60, extentNotifier: _tileExtentNotifier, + columnCountDefault: columnCountDefault, + extentMin: extentMin, spacing: spacing, )..applyTileExtent(viewportSize: viewportSize); @@ -98,7 +99,15 @@ class FilterGridPage extends StatelessWidget { scrollableKey: _scrollableKey, appBarHeightNotifier: _appBarHeightNotifier, viewportSize: viewportSize, - showScaledGrid: true, + gridBuilder: (center, extent, child) => CustomPaint( + painter: GridPainter( + center: center, + extent: extent, + spacing: tileExtentManager.spacing, + color: Colors.grey.shade700, + ), + child: child, + ), scaledBuilder: (item, extent) { final filter = item.filter; return SizedBox( From 93af6b0d1b2472fa0c40fee6ed029f15a65cc384 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 30 Nov 2020 14:14:39 +0900 Subject: [PATCH 02/27] various minor fixes --- lib/ref/xmp.dart | 4 ++ .../common/identity/highlight_title.dart | 51 +++++++++++-------- .../fullscreen/info/metadata/xmp_tile.dart | 1 + 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/lib/ref/xmp.dart b/lib/ref/xmp.dart index 858eeddc4..932faa4c8 100644 --- a/lib/ref/xmp.dart +++ b/lib/ref/xmp.dart @@ -4,14 +4,17 @@ class XMP { // cf https://exiftool.org/TagNames/XMP.html static const Map namespaces = { + 'adsml-at': 'AdsML', 'aux': 'Exif Aux', 'Camera': 'Camera', 'crs': 'Camera Raw Settings', 'dc': 'Dublin Core', + 'drone-dji': 'DJI Drone', 'exif': 'Exif', 'exifEX': 'Exif Ex', 'GettyImagesGIFT': 'Getty Images', 'GIMP': 'GIMP', + 'GPano': 'Google Photo Sphere', 'illustrator': 'Illustrator', 'Iptc4xmpCore': 'IPTC Core', 'lr': 'Lightroom', @@ -19,6 +22,7 @@ class XMP { 'panorama': 'Panorama', 'pdf': 'PDF', 'pdfx': 'PDF/X', + 'PanoStudioXMP': 'PanoramaStudio', 'photomechanic': 'Photo Mechanic', 'photoshop': 'Photoshop', 'plus': 'PLUS', diff --git a/lib/widgets/common/identity/highlight_title.dart b/lib/widgets/common/identity/highlight_title.dart index f34e69880..c2433a944 100644 --- a/lib/widgets/common/identity/highlight_title.dart +++ b/lib/widgets/common/identity/highlight_title.dart @@ -3,46 +3,55 @@ import 'package:aves/widgets/common/fx/highlight_decoration.dart'; import 'package:flutter/material.dart'; class HighlightTitle extends StatelessWidget { - final String name; + final String title; final Color color; final double fontSize; - final bool enabled; + final bool enabled, selectable; const HighlightTitle( - this.name, { + this.title, { this.color, this.fontSize = 20, this.enabled = true, - }) : assert(name != null); + this.selectable = false, + }) : assert(title != null); static const disabledColor = Colors.grey; @override Widget build(BuildContext context) { + final style = TextStyle( + shadows: [ + Shadow( + color: Colors.black, + offset: Offset(1, 1), + blurRadius: 2, + ) + ], + fontSize: fontSize, + fontFamily: 'Concourse Caps', + ); + return Align( alignment: AlignmentDirectional.centerStart, child: Container( decoration: HighlightDecoration( - color: enabled ? color ?? stringToColor(name) : disabledColor, + color: enabled ? color ?? stringToColor(title) : disabledColor, ), margin: EdgeInsets.symmetric(vertical: 4.0), - child: Text( - name, - style: TextStyle( - shadows: [ - Shadow( - color: Colors.black, - offset: Offset(1, 1), - blurRadius: 2, + child: selectable + ? SelectableText( + title, + style: style, + maxLines: 1, ) - ], - fontSize: fontSize, - fontFamily: 'Concourse Caps', - ), - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ), + : Text( + title, + style: style, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ), ), ); } diff --git a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart index 49d469aff..3de2cdc5a 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart @@ -65,6 +65,7 @@ class XmpDirTile extends StatelessWidget { child: HighlightTitle( title, color: BrandColors.get(title), + selectable: true, ), ), InfoRowGroup(Map.fromEntries(entries), maxValueLength: Constants.infoGroupMaxValueLength), From 8c5a60015157be51bb401e95eaddf583d7dca548 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 30 Nov 2020 15:09:32 +0900 Subject: [PATCH 03/27] catalog: get date from XMP as fallback from Exif --- .../aves/channel/calls/MetadataHandler.kt | 15 +++++++++-- .../thibault/aves/metadata/Metadata.kt | 2 ++ .../deckers/thibault/aves/metadata/XMP.kt | 27 ++++++++++++++++--- android/build.gradle | 2 +- lib/model/image_entry.dart | 2 +- 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index b5f9197cd..ce6ea20ab 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -44,6 +44,7 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString import deckers.thibault.aves.metadata.XMP +import deckers.thibault.aves.metadata.XMP.getSafeDateMillis import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText import deckers.thibault.aves.utils.BitmapUtils import deckers.thibault.aves.utils.BitmapUtils.getBytes @@ -205,6 +206,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { result.success(metadataMap) } + // set `KEY_DATE_MILLIS` from these fields (by precedence): + // - Exif / DATETIME_ORIGINAL + // - Exif / DATETIME + // - XMP / xmp:CreateDate + // set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence): + // - XMP / dc:title + // - XMP / dc:description private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, path: String?): Map { val metadataMap = HashMap() @@ -270,9 +278,12 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME, it).value } metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(separator = ";") } - xmpMeta.getSafeLocalizedText(XMP.TITLE_PROP_NAME) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it } + xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.TITLE_PROP_NAME) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it } if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) { - xmpMeta.getSafeLocalizedText(XMP.DESCRIPTION_PROP_NAME) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it } + xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DESCRIPTION_PROP_NAME) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it } + } + if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { + xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } } } catch (e: XMPException) { Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index dcf422d27..036ac6b18 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -41,6 +41,8 @@ object Metadata { } } + // not sure which standards are used for the different video formats, + // but looks like some form of ISO 8601 `basic format`: // yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)? fun parseVideoMetadataDate(metadataDate: String?): Long { var dateString = metadataDate ?: return 0 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index e68d08f22..40ed49a7c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -4,6 +4,7 @@ import android.util.Log import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPMeta import deckers.thibault.aves.utils.LogUtils +import java.util.* object XMP { private val LOG_TAG = LogUtils.createTag(XMP::class.java) @@ -14,20 +15,38 @@ object XMP { const val SUBJECT_PROP_NAME = "dc:subject" const val TITLE_PROP_NAME = "dc:title" const val DESCRIPTION_PROP_NAME = "dc:description" + const val CREATE_DATE_PROP_NAME = "xmp:CreateDate" const val THUMBNAIL_PROP_NAME = "xmp:Thumbnails" const val THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image" private const val GENERIC_LANG = "" private const val SPECIFIC_LANG = "en-US" - fun XMPMeta.getSafeLocalizedText(propName: String, save: (value: String) -> Unit) { + fun XMPMeta.getSafeLocalizedText(schema: String, propName: String, save: (value: String) -> Unit) { try { - if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) { - val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG) + if (this.doesPropertyExist(schema, propName)) { + val item = this.getLocalizedText(schema, propName, GENERIC_LANG, SPECIFIC_LANG) // double check retrieved items as the property sometimes is reported to exist but it is actually null if (item != null) save(item.value) } } catch (e: XMPException) { - Log.w(LOG_TAG, "failed to get text for XMP propName=$propName", e) + Log.w(LOG_TAG, "failed to get text for XMP schema=$schema, propName=$propName", e) + } + } + + fun XMPMeta.getSafeDateMillis(schema: String, propName: String, save: (value: Long) -> Unit) { + try { + if (this.doesPropertyExist(schema, propName)) { + val item = this.getPropertyDate(schema, propName) + // double check retrieved items as the property sometimes is reported to exist but it is actually null + if (item != null) { + // strip time zone from XMP dates so that we show date/times as local ones + // this aligns with Exif date/times, which are specified without time zones + item.timeZone = TimeZone.getDefault() + save(item.calendar.timeInMillis) + } + } + } catch (e: XMPException) { + Log.w(LOG_TAG, "failed to get text for XMP schema=$schema, propName=$propName", e) } } } \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 82eba64c0..11444be0a 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,6 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.4.10' + ext.kotlin_version = '1.4.20' repositories { google() jcenter() diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 63b78dde1..d00498597 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -299,7 +299,7 @@ class ImageEntry { bool get isLocated => _addressDetails != null; - LatLng get latLng => isCatalogued ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null; + LatLng get latLng => hasGps ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null; String get geoUri { if (!hasGps) return null; From 0d946b5a43a33d72e81bdbd43ed214a9c6c8e0e6 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 30 Nov 2020 19:23:27 +0900 Subject: [PATCH 04/27] guard against large tiff --- .../aves/channel/calls/MetadataHandler.kt | 149 ++++++++++-------- .../thibault/aves/model/SourceImageEntry.kt | 30 +++- .../deckers/thibault/aves/utils/MimeTypes.kt | 19 ++- lib/model/image_entry.dart | 2 +- lib/ref/brand_colors.dart | 3 + lib/ref/xmp.dart | 1 + lib/services/metadata_service.dart | 14 +- .../info/metadata/metadata_thumbnail.dart | 2 +- 8 files changed, 142 insertions(+), 78 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index ce6ea20ab..f152348e0 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -52,6 +52,7 @@ import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isMultimedia +import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern @@ -86,6 +87,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("sizeBytes")?.toLong() if (mimeType == null || uri == null) { result.error("getAllMetadata-args", "failed because of missing arguments", null) return @@ -95,10 +97,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { var foundExif = false var foundXmp = false - if (isSupportedByMetadataExtractor(mimeType)) { + if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { try { StorageUtils.openInputStream(context, uri)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input) + val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java) @@ -138,7 +140,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } - if (!foundExif) { + if (!foundExif && isSupportedByExifInterface(mimeType, sizeBytes)) { // fallback to read EXIF via ExifInterface try { StorageUtils.openInputStream(context, uri)?.use { input -> @@ -192,12 +194,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } val path = call.argument("path") + val sizeBytes = call.argument("sizeBytes")?.toLong() if (mimeType == null || uri == null) { result.error("getCatalogMetadata-args", "failed because of missing arguments", null) return } - val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, path)) + val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes)) if (isVideo(mimeType)) { metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri)) } @@ -213,15 +216,15 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { // set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence): // - XMP / dc:title // - XMP / dc:description - private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, path: String?): Map { + private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, path: String?, sizeBytes: Long?): Map { val metadataMap = HashMap() var foundExif = false - if (isSupportedByMetadataExtractor(mimeType)) { + if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { try { StorageUtils.openInputStream(context, uri)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input) + val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) // File type @@ -311,7 +314,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } - if (!foundExif) { + if (!foundExif && isSupportedByExifInterface(mimeType, sizeBytes)) { // fallback to read EXIF via ExifInterface try { StorageUtils.openInputStream(context, uri)?.use { input -> @@ -371,6 +374,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("sizeBytes")?.toLong() if (mimeType == null || uri == null) { result.error("getOverlayMetadata-args", "failed because of missing arguments", null) return @@ -396,10 +400,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } var foundExif = false - if (isSupportedByMetadataExtractor(mimeType)) { + if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { try { StorageUtils.openInputStream(context, uri)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input) + val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { foundExif = true dir.getSafeRational(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator } @@ -415,7 +419,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } - if (!foundExif) { + if (!foundExif && isSupportedByExifInterface(mimeType, sizeBytes)) { // fallback to read EXIF via ExifInterface try { StorageUtils.openInputStream(context, uri)?.use { input -> @@ -488,26 +492,31 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } - if (uri == null) { + val sizeBytes = call.argument("sizeBytes")?.toLong() + if (mimeType == null || uri == null) { result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null) return } - try { - StorageUtils.openInputStream(context, uri)?.use { input -> - val exif = ExifInterface(input) - val metadataMap = HashMap() - for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) { - metadataMap[tag] = exif.getAttribute(tag) + val metadataMap = HashMap() + if (isSupportedByExifInterface(mimeType, sizeBytes, strict = false)) { + try { + StorageUtils.openInputStream(context, uri)?.use { input -> + val exif = ExifInterface(input) + for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) { + metadataMap[tag] = exif.getAttribute(tag) + } } - result.success(metadataMap) - } ?: result.error("getExifInterfaceMetadata-noinput", "failed to get exif for uri=$uri", null) - } catch (e: Exception) { - // ExifInterface initialization can fail with a RuntimeException - // caused by an internal MediaMetadataRetriever failure - result.error("getExifInterfaceMetadata-failure", "failed to get exif for uri=$uri", e.message) + } catch (e: Exception) { + // ExifInterface initialization can fail with a RuntimeException + // caused by an internal MediaMetadataRetriever failure + result.error("getExifInterfaceMetadata-failure", "failed to get exif for uri=$uri", e.message) + return + } } + result.success(metadataMap) } private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) { @@ -563,46 +572,45 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } - if (uri == null) { + val sizeBytes = call.argument("sizeBytes")?.toLong() + if (mimeType == null || uri == null) { result.error("getMetadataExtractorSummary-args", "failed because of missing arguments", null) return } val metadataMap = HashMap() - try { - StorageUtils.openInputStream(context, uri)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input) - metadataMap["mimeType"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir -> - if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) { - dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) - } else "" - } - metadataMap["typeName"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir -> - if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME)) { - dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME) - } else "" - } - for (dir in metadata.directories) { - val dirName = dir.name ?: "" - var index = 0 - while (metadataMap.containsKey("$dirName ($index)")) index++ - var value = "${dir.tagCount} tags" - dir.parent?.let { value += ", parent: ${it.name}" } - metadataMap["$dirName ($index)"] = value + if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { + try { + StorageUtils.openInputStream(context, uri)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) + metadataMap["mimeType"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir -> + if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) { + dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) + } else "" + } + metadataMap["typeName"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir -> + if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME)) { + dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME) + } else "" + } + for (dir in metadata.directories) { + val dirName = dir.name ?: "" + var index = 0 + while (metadataMap.containsKey("$dirName ($index)")) index++ + var value = "${dir.tagCount} tags" + dir.parent?.let { value += ", parent: ${it.name}" } + metadataMap["$dirName ($index)"] = value + } } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e) + } catch (e: NoClassDefFoundError) { + Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e) } - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e) - } catch (e: NoClassDefFoundError) { - Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e) - } - - if (metadataMap.isNotEmpty()) { - result.success(metadataMap) - } else { - result.error("getMetadataExtractorSummary-failure", "failed to get metadata for uri=$uri", null) } + result.success(metadataMap) } private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) { @@ -628,26 +636,30 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } - if (uri == null) { + val sizeBytes = call.argument("sizeBytes")?.toLong() + if (mimeType == null || uri == null) { result.error("getExifThumbnails-args", "failed because of missing arguments", null) return } val thumbnails = ArrayList() - try { - StorageUtils.openInputStream(context, uri)?.use { input -> - val exif = ExifInterface(input) - val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) - exif.thumbnailBitmap?.let { bitmap -> - TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let { - thumbnails.add(it.getBytes(canHaveAlpha = false, recycle = false)) + if (isSupportedByExifInterface(mimeType, sizeBytes)) { + try { + StorageUtils.openInputStream(context, uri)?.use { input -> + val exif = ExifInterface(input) + val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + exif.thumbnailBitmap?.let { bitmap -> + TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let { + thumbnails.add(it.getBytes(canHaveAlpha = false, recycle = false)) + } } } + } catch (e: Exception) { + // ExifInterface initialization can fail with a RuntimeException + // caused by an internal MediaMetadataRetriever failure } - } catch (e: Exception) { - // ExifInterface initialization can fail with a RuntimeException - // caused by an internal MediaMetadataRetriever failure } result.success(thumbnails) } @@ -655,16 +667,17 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private fun getXmpThumbnails(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("sizeBytes")?.toLong() if (mimeType == null || uri == null) { result.error("getXmpThumbnails-args", "failed because of missing arguments", null) return } val thumbnails = ArrayList() - if (isSupportedByMetadataExtractor(mimeType)) { + if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { try { StorageUtils.openInputStream(context, uri)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input) + val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { val xmpMeta = dir.xmpMeta try { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt index aaf961622..409966f68 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt @@ -27,6 +27,7 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong import deckers.thibault.aves.model.provider.FieldMap import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.StorageUtils +import org.beyka.tiffbitmapfactory.TiffBitmapFactory import java.io.IOException class SourceImageEntry { @@ -129,7 +130,10 @@ class SourceImageEntry { fillByExifInterface(context) } if (!isSized) { - fillByBitmapDecode(context) + when (sourceMimeType) { + MimeTypes.TIFF -> fillByTiffDecode(context) + else -> fillByBitmapDecode(context) + } } return this } @@ -155,11 +159,13 @@ class SourceImageEntry { // finds: width, height, orientation, date, duration private fun fillByMetadataExtractor(context: Context) { // skip raw images because `metadata-extractor` reports the decoded dimensions instead of the raw dimensions - if (!MimeTypes.isSupportedByMetadataExtractor(sourceMimeType) || MimeTypes.isRaw(sourceMimeType)) return + if (!MimeTypes.isSupportedByMetadataExtractor(sourceMimeType, sizeBytes) + || MimeTypes.isRaw(sourceMimeType) + ) return try { StorageUtils.openInputStream(context, uri)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input) + val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) // do not switch on specific mime types, as the reported mime type could be wrong // (e.g. PNG registered as JPG) @@ -207,7 +213,7 @@ class SourceImageEntry { // finds: width, height, orientation, date private fun fillByExifInterface(context: Context) { - if (!ExifInterface.isSupportedMimeType(sourceMimeType)) return; + if (!MimeTypes.isSupportedByExifInterface(sourceMimeType, sizeBytes)) return; try { StorageUtils.openInputStream(context, uri)?.use { input -> @@ -240,6 +246,22 @@ class SourceImageEntry { } } + private fun fillByTiffDecode(context: Context) { + try { + context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> + val options = TiffBitmapFactory.Options().apply { + inJustDecodeBounds = true + } + TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) + width = options.outWidth + height = options.outHeight + } + } catch (e: Exception) { + // ignore + } + } + + companion object { // convenience method private fun toLong(o: Any?): Long? = when (o) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index be9117260..d2d535349 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -1,5 +1,7 @@ package deckers.thibault.aves.utils +import androidx.exifinterface.media.ExifInterface + object MimeTypes { private const val IMAGE = "image" @@ -65,12 +67,25 @@ object MimeTypes { else -> false } - // as of metadata-extractor v2.14.0 - fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) { + // opening large TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1), + // so we define an arbitrary threshold to avoid a crash on launch. + // It is not clear whether it is because of the file itself or its metadata. + private const val tiffSizeBytesMax = 128 * (1 shl 20) // MB + + // as of `metadata-extractor` v2.14.0 + fun isSupportedByMetadataExtractor(mimeType: String, sizeBytes: Long?) = when (mimeType) { WBMP, MP2T, WEBM -> false + TIFF -> sizeBytes != null && sizeBytes < tiffSizeBytesMax else -> true } + // as of `ExifInterface` v1.3.1, `isSupportedMimeType` reports + // no support for TIFF images, but it can actually open them (maybe other formats too) + fun isSupportedByExifInterface(mimeType: String, sizeBytes: Long?, strict: Boolean = true) = when (mimeType) { + TIFF -> sizeBytes != null && sizeBytes < tiffSizeBytesMax + else -> ExifInterface.isSupportedMimeType(mimeType) || !strict + } + // Glide automatically applies EXIF orientation when decoding images of known formats // but we need to rotate the decoded bitmap for the other formats // maybe related to ExifInterface version used by Glide: diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index d00498597..e253bc434 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -173,7 +173,7 @@ class ImageEntry { bool get isSvg => mimeType == MimeTypes.svg; // guess whether this is a photo, according to file type (used as a hint to e.g. display megapixels) - bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg].contains(mimeType) || isRaw; + bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg, MimeTypes.tiff].contains(mimeType) || isRaw; // Android's `BitmapRegionDecoder` documentation states that "only the JPEG and PNG formats are supported" // but in practice (tested on API 25, 27, 29), it successfully decodes the formats listed below, diff --git a/lib/ref/brand_colors.dart b/lib/ref/brand_colors.dart index 1e5c99a91..17db1afd0 100644 --- a/lib/ref/brand_colors.dart +++ b/lib/ref/brand_colors.dart @@ -1,6 +1,7 @@ import 'package:flutter/painting.dart'; class BrandColors { + static const Color adobeAfterEffects = Color(0xFF9A9AFF); static const Color adobeIllustrator = Color(0xFFFF9B00); static const Color adobePhotoshop = Color(0xFF2DAAFF); static const Color android = Color(0xFF3DDC84); @@ -9,6 +10,8 @@ class BrandColors { static Color get(String text) { if (text != null) { switch (text.toLowerCase()) { + case 'after effects': + return adobeAfterEffects; case 'illustrator': return adobeIllustrator; case 'photoshop': diff --git a/lib/ref/xmp.dart b/lib/ref/xmp.dart index 932faa4c8..725728c75 100644 --- a/lib/ref/xmp.dart +++ b/lib/ref/xmp.dart @@ -7,6 +7,7 @@ class XMP { 'adsml-at': 'AdsML', 'aux': 'Exif Aux', 'Camera': 'Camera', + 'creatorAtom': 'After Effects', 'crs': 'Camera Raw Settings', 'dc': 'Dublin Core', 'drone-dji': 'DJI Drone', diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index 43a4998b1..f3e7bfb54 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -17,6 +17,7 @@ class MetadataService { final result = await platform.invokeMethod('getAllMetadata', { 'mimeType': entry.mimeType, 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, }); return result as Map; } on PlatformException catch (e) { @@ -44,6 +45,7 @@ class MetadataService { 'mimeType': entry.mimeType, 'uri': entry.uri, 'path': entry.path, + 'sizeBytes': entry.sizeBytes, }) as Map; result['contentId'] = entry.contentId; return CatalogMetadata.fromMap(result); @@ -69,6 +71,7 @@ class MetadataService { final result = await platform.invokeMethod('getOverlayMetadata', { 'mimeType': entry.mimeType, 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, }) as Map; return OverlayMetadata.fromMap(result); } on PlatformException catch (e) { @@ -108,7 +111,9 @@ class MetadataService { try { // return map with all data available from the `ExifInterface` library final result = await platform.invokeMethod('getExifInterfaceMetadata', { + 'mimeType': entry.mimeType, 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, }) as Map; return result; } on PlatformException catch (e) { @@ -134,7 +139,9 @@ class MetadataService { try { // return map with the mime type and tag count for each directory found by `metadata-extractor` final result = await platform.invokeMethod('getMetadataExtractorSummary', { + 'mimeType': entry.mimeType, 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, }) as Map; return result; } on PlatformException catch (e) { @@ -155,10 +162,12 @@ class MetadataService { return []; } - static Future> getExifThumbnails(String uri) async { + static Future> getExifThumbnails(ImageEntry entry) async { try { final result = await platform.invokeMethod('getExifThumbnails', { - 'uri': uri, + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, }); return (result as List).cast(); } on PlatformException catch (e) { @@ -172,6 +181,7 @@ class MetadataService { final result = await platform.invokeMethod('getXmpThumbnails', { 'mimeType': entry.mimeType, 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, }); return (result as List).cast(); } on PlatformException catch (e) { diff --git a/lib/widgets/fullscreen/info/metadata/metadata_thumbnail.dart b/lib/widgets/fullscreen/info/metadata/metadata_thumbnail.dart index 5a13d63dd..f47024f2a 100644 --- a/lib/widgets/fullscreen/info/metadata/metadata_thumbnail.dart +++ b/lib/widgets/fullscreen/info/metadata/metadata_thumbnail.dart @@ -36,7 +36,7 @@ class _MetadataThumbnailsState extends State { _loader = MetadataService.getEmbeddedPictures(uri); break; case MetadataThumbnailSource.exif: - _loader = MetadataService.getExifThumbnails(uri); + _loader = MetadataService.getExifThumbnails(entry); break; case MetadataThumbnailSource.xmp: _loader = MetadataService.getXmpThumbnails(entry); From 4d9df75c46a974f9feb8065e288c6ab4b8b185d0 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 1 Dec 2020 10:36:12 +0900 Subject: [PATCH 05/27] Viewer: TIFF subsampling & tiling --- .../aves/channel/calls/ImageFileHandler.kt | 27 +++++++++---- .../aves/channel/calls/TiffRegionFetcher.kt | 40 +++++++++++++++++++ .../channel/streams/ImageByteStreamHandler.kt | 15 ++++--- lib/model/image_entry.dart | 4 +- 4 files changed, 69 insertions(+), 17 deletions(-) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/TiffRegionFetcher.kt diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt index cc8c61dd8..6af5ad179 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt @@ -10,6 +10,7 @@ import deckers.thibault.aves.model.provider.FieldMap import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider import deckers.thibault.aves.model.provider.MediaStoreImageProvider +import deckers.thibault.aves.utils.MimeTypes import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler @@ -95,14 +96,24 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { return } - regionFetcher.fetch( - uri, - mimeType, - sampleSize, - Rect(x, y, x + width, y + height), - Size(imageWidth, imageHeight), - result, - ) + val regionRect = Rect(x, y, x + width, y + height) + when (mimeType) { + MimeTypes.TIFF -> TiffRegionFetcher(activity).fetch( + uri, + sampleSize, + regionRect, + page = 0, + result, + ) + else -> regionFetcher.fetch( + uri, + mimeType, + sampleSize, + regionRect, + Size(imageWidth, imageHeight), + result, + ) + } } private suspend fun getImageEntry(call: MethodCall, result: MethodChannel.Result) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/TiffRegionFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/TiffRegionFetcher.kt new file mode 100644 index 000000000..1c88245bb --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/TiffRegionFetcher.kt @@ -0,0 +1,40 @@ +package deckers.thibault.aves.channel.calls + +import android.content.Context +import android.graphics.Rect +import android.net.Uri +import deckers.thibault.aves.utils.BitmapUtils.getBytes +import io.flutter.plugin.common.MethodChannel +import org.beyka.tiffbitmapfactory.DecodeArea +import org.beyka.tiffbitmapfactory.TiffBitmapFactory + +class TiffRegionFetcher internal constructor( + private val context: Context, +) { + fun fetch( + uri: Uri, + sampleSize: Int, + regionRect: Rect, + page: Int = 0, + result: MethodChannel.Result, + ) { + val resolver = context.contentResolver + try { + resolver.openFileDescriptor(uri, "r")?.use { descriptor -> + val options = TiffBitmapFactory.Options().apply { + inDirectoryNumber = page + inSampleSize = sampleSize + inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height()) + } + val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) + if (bitmap != null) { + result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true)) + } else { + result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null) + } + } + } catch (e: Exception) { + result.error("getRegion-tiff-read-exception", "failed to read from uri=$uri page=$page regionRect=$regionRect", e.message) + } + } +} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index a5dba87d9..df6e5177e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -95,7 +95,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen private fun streamImageByGlide(uri: Uri, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) { val target = Glide.with(activity) .asBitmap() - .apply(options) + .apply(glideOptions) .load(uri) .submit() try { @@ -118,7 +118,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen private fun streamVideoByGlide(uri: Uri) { val target = Glide.with(activity) .asBitmap() - .apply(options) + .apply(glideOptions) .load(VideoThumbnail(activity, uri)) .submit() try { @@ -135,7 +135,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen } } - private fun streamTiffImage(uri: Uri) { + private fun streamTiffImage(uri: Uri, page: Int = 0) { val resolver = activity.contentResolver try { var dirCount = 0 @@ -148,18 +148,17 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen } // TODO TLAD handle multipage TIFF - if (dirCount > 0) { - val i = 0 + if (dirCount > page) { resolver.openFileDescriptor(uri, "r")?.use { descriptor -> val options = TiffBitmapFactory.Options().apply { inJustDecodeBounds = false - inDirectoryNumber = i + inDirectoryNumber = page } val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) if (bitmap != null) { success(bitmap.getBytes(canHaveAlpha = true, recycle = true)) } else { - error("streamImage-tiff-null", "failed to get tiff image (dir=$i) from uri=$uri", null) + error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null) } } } @@ -192,7 +191,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen const val bufferSize = 2 shl 17 // 256kB // request a fresh image with the highest quality format - val options = RequestOptions() + val glideOptions = RequestOptions() .format(DecodeFormat.PREFER_ARGB_8888) .diskCacheStrategy(DiskCacheStrategy.NONE) .skipMemoryCache(true) diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index e253bc434..ed1df3392 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -178,7 +178,7 @@ class ImageEntry { // Android's `BitmapRegionDecoder` documentation states that "only the JPEG and PNG formats are supported" // but in practice (tested on API 25, 27, 29), it successfully decodes the formats listed below, // and it actually fails to decode GIF, DNG and animated WEBP. Other formats were not tested. - bool get canTile => + bool get _supportedByBitmapRegionDecoder => [ MimeTypes.heic, MimeTypes.heif, @@ -196,6 +196,8 @@ class ImageEntry { ].contains(mimeType) && !isAnimated; + bool get canTile => _supportedByBitmapRegionDecoder || mimeType == MimeTypes.tiff; + bool get isRaw => MimeTypes.rawImages.contains(mimeType); bool get isVideo => mimeType.startsWith('video'); From 60e7b2c5d936888498a6b28fd4a8ce8f10bbb510 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 1 Dec 2020 13:56:56 +0900 Subject: [PATCH 06/27] various fixes: ocean GPS, ~0 GPS, delayed op feedback --- .../aves/channel/calls/MetadataHandler.kt | 7 +++---- lib/model/image_entry.dart | 13 +++++++++---- lib/model/image_metadata.dart | 17 ++++++++++++----- lib/widgets/common/action_mixins/feedback.dart | 2 +- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index f152348e0..09aea57f2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -353,10 +353,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { if (locationString != null) { val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString) if (matcher.find() && matcher.groupCount() >= 2) { - // keep `0.0` as `0.0`, not `0` - val latitude = matcher.group(1)?.toDoubleOrNull() ?: 0.0 - val longitude = matcher.group(2)?.toDoubleOrNull() ?: 0.0 - if (latitude != 0.0 || longitude != 0.0) { + val latitude = matcher.group(1)?.toDoubleOrNull() + val longitude = matcher.group(2)?.toDoubleOrNull() + if (latitude != null && longitude != null) { metadataMap[KEY_LATITUDE] = latitude metadataMap[KEY_LONGITUDE] = longitude } diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index ed1df3392..2de981063 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -375,12 +375,17 @@ class ImageEntry { : call()); if (addresses != null && addresses.isNotEmpty) { final address = addresses.first; + final cc = address.countryCode; + final cn = address.countryName; + final aa = address.adminArea; addressDetails = AddressDetails( contentId: contentId, - countryCode: address.countryCode, - countryName: address.countryName, - adminArea: address.adminArea, - locality: address.locality, + countryCode: cc, + countryName: cn, + adminArea: aa, + // if country & admin fields are null, it is likely the ocean, + // which is identified by `featureName` but we default to the address line anyway + locality: address.locality ?? (cc == null && cn == null && aa == null ? address.addressLine : null), ); } } catch (error, stackTrace) { diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index 9471d0ec0..ee984acc7 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -34,9 +34,11 @@ class CatalogMetadata { bool isFlipped; int rotationDegrees; final String mimeType, xmpSubjects, xmpTitleDescription; - final double latitude, longitude; + double latitude, longitude; Address address; + static const double _precisionErrorTolerance = 1e-9; + CatalogMetadata({ this.contentId, this.mimeType, @@ -48,10 +50,15 @@ class CatalogMetadata { this.xmpTitleDescription, double latitude, double longitude, - }) - // Geocoder throws an IllegalArgumentException when a coordinate has a funky values like 1.7056881853375E7 - : latitude = latitude == null || latitude < -90.0 || latitude > 90.0 ? null : latitude, - longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude; + }) { + // Geocoder throws an `IllegalArgumentException` when a coordinate has a funky values like `1.7056881853375E7` + // We also exclude zero coordinates, taking into account precision errors (e.g. {5.952380952380953e-11,-2.7777777777777777e-10}), + // but Flutter's `precisionErrorTolerance` (1e-10) is slightly too lenient for this case. + if (latitude != null && longitude != null && (latitude.abs() > _precisionErrorTolerance || longitude.abs() > _precisionErrorTolerance)) { + this.latitude = latitude < -90.0 || latitude > 90.0 ? null : latitude; + this.longitude = longitude < -180.0 || longitude > 180.0 ? null : longitude; + } + } CatalogMetadata copyWith({ @required int contentId, diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index eca65b6c9..ae69e3a7b 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -56,7 +56,7 @@ mixin FeedbackMixin { stream: opStream, builder: (context, snapshot) { Widget child = SizedBox.shrink(); - if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) { + if (!snapshot.hasError) { final percent = processed.length.toDouble() / selection.length; child = CircularPercentIndicator( percent: percent, From 1c415f83dca3e2fe7857389b8f8be1946e34e5d5 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 1 Dec 2020 18:12:29 +0900 Subject: [PATCH 07/27] DB change to merge flags, geotiff identification --- .../aves/channel/calls/MetadataHandler.kt | 27 +++-- .../deckers/thibault/aves/metadata/Geotiff.kt | 27 +++++ .../aves/metadata/MetadataExtractorHelper.kt | 22 ++++ lib/model/image_entry.dart | 2 + lib/model/image_metadata.dart | 24 +++-- lib/model/metadata_db.dart | 83 +++------------ lib/model/metadata_db_upgrade.dart | 100 ++++++++++++++++++ lib/theme/icons.dart | 1 + lib/widgets/collection/thumbnail/overlay.dart | 1 + lib/widgets/common/identity/aves_icons.dart | 14 +++ .../fullscreen/fullscreen_debug_page.dart | 1 + 11 files changed, 216 insertions(+), 86 deletions(-) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/metadata/Geotiff.kt create mode 100644 lib/model/metadata_db_upgrade.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 09aea57f2..46056b88c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -43,6 +43,7 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString +import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff import deckers.thibault.aves.metadata.XMP import deckers.thibault.aves.metadata.XMP.getSafeDateMillis import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText @@ -219,6 +220,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, path: String?, sizeBytes: Long?): Map { val metadataMap = HashMap() + var flags = 0 var foundExif = false if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { @@ -258,7 +260,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } dir.getSafeInt(ExifIFD0Directory.TAG_ORIENTATION) { val orientation = it - metadataMap[KEY_IS_FLIPPED] = isFlippedForExifCode(orientation) + if (isFlippedForExifCode(orientation)) flags = flags or MASK_IS_FLIPPED metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation) } } @@ -293,17 +295,22 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } - // Animated GIF & WEBP + // identification of animated GIF & WEBP, GeoTIFF when (mimeType) { MimeTypes.GIF -> { - metadataMap[KEY_IS_ANIMATED] = metadata.containsDirectoryOfType(GifAnimationDirectory::class.java) + if (metadata.containsDirectoryOfType(GifAnimationDirectory::class.java)) flags = flags or MASK_IS_ANIMATED } MimeTypes.WEBP -> { for (dir in metadata.getDirectoriesOfType(WebpDirectory::class.java)) { - dir.getSafeBoolean(WebpDirectory.TAG_IS_ANIMATION) { metadataMap[KEY_IS_ANIMATED] = it } + dir.getSafeBoolean(WebpDirectory.TAG_IS_ANIMATION) { + if (it) flags = flags or MASK_IS_ANIMATED + } } } - else -> { + MimeTypes.TIFF -> { + for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) { + if (dir.isGeoTiff()) flags = flags or MASK_IS_GEOTIFF + } } } } @@ -324,7 +331,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { exif.getSafeDateMillis(ExifInterface.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it } } exif.getSafeInt(ExifInterface.TAG_ORIENTATION, acceptZero = false) { - metadataMap[KEY_IS_FLIPPED] = exif.isFlipped + if (exif.isFlipped) flags = flags or MASK_IS_FLIPPED metadataMap[KEY_ROTATION_DEGREES] = exif.rotationDegrees } val latLong = exif.latLong @@ -339,6 +346,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=$uri", e) } } + metadataMap[KEY_FLAGS] = flags return metadataMap } @@ -711,14 +719,17 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { // catalog metadata private const val KEY_MIME_TYPE = "mimeType" private const val KEY_DATE_MILLIS = "dateMillis" - private const val KEY_IS_ANIMATED = "isAnimated" - private const val KEY_IS_FLIPPED = "isFlipped" + private const val KEY_FLAGS = "flags" private const val KEY_ROTATION_DEGREES = "rotationDegrees" private const val KEY_LATITUDE = "latitude" private const val KEY_LONGITUDE = "longitude" private const val KEY_XMP_SUBJECTS = "xmpSubjects" private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription" + private const val MASK_IS_ANIMATED = 1 shl 0 + private const val MASK_IS_FLIPPED = 1 shl 1 + private const val MASK_IS_GEOTIFF = 1 shl 2 + // overlay metadata private const val KEY_APERTURE = "aperture" private const val KEY_EXPOSURE_TIME = "exposureTime" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Geotiff.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Geotiff.kt new file mode 100644 index 000000000..5b40d833b --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Geotiff.kt @@ -0,0 +1,27 @@ +package deckers.thibault.aves.metadata + +object Geotiff { + // 33550 + // ModelPixelScaleTag (optional) + val TAG_MODEL_PIXEL_SCALE = 0x830e + + // 33922 + // ModelTiepointTag (conditional) + val TAG_MODEL_TIEPOINT = 0x8482 + + // 34264 + // ModelTransformationTag (conditional) + val TAG_MODEL_TRANSFORMATION = 0x85d8 + + // 34735 + // GeoKeyDirectoryTag (mandatory) + val TAG_GEO_KEY_DIRECTORY = 0x87af + + // 34736 + // GeoDoubleParamsTag (optional) + val TAG_GEO_DOUBLE_PARAMS = 0x87b0 + + // 34737 + // GeoAsciiParamsTag (optional) + val TAG_GEO_ASCII_PARAMS = 0x87b1 +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt index ec60169ba..74c63bf4a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt @@ -2,6 +2,7 @@ package deckers.thibault.aves.metadata import com.drew.lang.Rational import com.drew.metadata.Directory +import com.drew.metadata.exif.ExifIFD0Directory import java.util.* object MetadataExtractorHelper { @@ -34,4 +35,25 @@ object MetadataExtractorHelper { fun Directory.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) { if (this.containsTag(tag)) save(this.getDate(tag, null, TimeZone.getDefault()).time) } + + // geotiff + + /* + cf http://docs.opengeospatial.org/is/19-008r4/19-008r4.html#_underlying_tiff_requirements + - One of ModelTiepointTag or ModelTransformationTag SHALL be included in an Image File Directory (IFD) + - If the ModelTransformationTag is included in an IFD, then a ModelPixelScaleTag SHALL NOT be included + - If the ModelPixelScaleTag is included in an IFD, then a ModelTiepointTag SHALL also be included. + */ + fun ExifIFD0Directory.isGeoTiff(): Boolean { + if (!this.containsTag(Geotiff.TAG_GEO_KEY_DIRECTORY)) return false + + val modelTiepoint = this.containsTag(Geotiff.TAG_MODEL_TIEPOINT) + val modelTransformation = this.containsTag(Geotiff.TAG_MODEL_TRANSFORMATION) + if (!modelTiepoint && !modelTransformation) return false + + val modelPixelScale = this.containsTag(Geotiff.TAG_MODEL_PIXEL_SCALE) + if ((modelTransformation && modelPixelScale) || (modelPixelScale && !modelTiepoint)) return false + + return true + } } \ No newline at end of file diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 2de981063..eb0fc7923 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -206,6 +206,8 @@ class ImageEntry { bool get isAnimated => _catalogMetadata?.isAnimated ?? false; + bool get isGeotiff => _catalogMetadata?.isGeotiff ?? false; + bool get canEdit => path != null; bool get canPrint => !isVideo; diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index ee984acc7..bfc6b84b7 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -30,7 +30,7 @@ class DateMetadata { class CatalogMetadata { final int contentId, dateMillis; - final bool isAnimated; + final bool isAnimated, isGeotiff; bool isFlipped; int rotationDegrees; final String mimeType, xmpSubjects, xmpTitleDescription; @@ -38,6 +38,9 @@ class CatalogMetadata { Address address; static const double _precisionErrorTolerance = 1e-9; + static const isAnimatedMask = 1 << 0; + static const isFlippedMask = 1 << 1; + static const isGeotiffMask = 1 << 2; CatalogMetadata({ this.contentId, @@ -45,6 +48,7 @@ class CatalogMetadata { this.dateMillis, this.isAnimated, this.isFlipped, + this.isGeotiff, this.rotationDegrees, this.xmpSubjects, this.xmpTitleDescription, @@ -69,6 +73,7 @@ class CatalogMetadata { dateMillis: dateMillis, isAnimated: isAnimated, isFlipped: isFlipped, + isGeotiff: isGeotiff, rotationDegrees: rotationDegrees, xmpSubjects: xmpSubjects, xmpTitleDescription: xmpTitleDescription, @@ -77,15 +82,15 @@ class CatalogMetadata { ); } - factory CatalogMetadata.fromMap(Map map, {bool boolAsInteger = false}) { - final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false); - final isFlipped = map['isFlipped'] ?? (boolAsInteger ? 0 : false); + factory CatalogMetadata.fromMap(Map map) { + final flags = map['flags'] ?? 0; return CatalogMetadata( contentId: map['contentId'], mimeType: map['mimeType'], dateMillis: map['dateMillis'] ?? 0, - isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated, - isFlipped: boolAsInteger ? isFlipped != 0 : isFlipped, + isAnimated: flags & isAnimatedMask != 0, + isFlipped: flags & isFlippedMask != 0, + isGeotiff: flags & isGeotiffMask != 0, // `rotationDegrees` should default to `sourceRotationDegrees`, not 0 rotationDegrees: map['rotationDegrees'], xmpSubjects: map['xmpSubjects'] ?? '', @@ -95,12 +100,11 @@ class CatalogMetadata { ); } - Map toMap({bool boolAsInteger = false}) => { + Map toMap() => { 'contentId': contentId, 'mimeType': mimeType, 'dateMillis': dateMillis, - 'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated, - 'isFlipped': boolAsInteger ? (isFlipped ? 1 : 0) : isFlipped, + 'flags': (isAnimated ? isAnimatedMask : 0) | (isFlipped ? isFlippedMask : 0) | (isGeotiff ? isGeotiffMask : 0), 'rotationDegrees': rotationDegrees, 'xmpSubjects': xmpSubjects, 'xmpTitleDescription': xmpTitleDescription, @@ -110,7 +114,7 @@ class CatalogMetadata { @override String toString() { - return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; + return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; } } diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index 681a38605..48a8b5a3b 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; +import 'package:aves/model/metadata_db_upgrade.dart'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart'; import 'package:sqflite/sqflite.dart'; @@ -48,8 +49,7 @@ class MetadataDb { 'contentId INTEGER PRIMARY KEY' ', mimeType TEXT' ', dateMillis INTEGER' - ', isAnimated INTEGER' - ', isFlipped INTEGER' + ', flags INTEGER' ', rotationDegrees INTEGER' ', xmpSubjects TEXT' ', xmpTitleDescription TEXT' @@ -69,65 +69,8 @@ class MetadataDb { ', path TEXT' ')'); }, - onUpgrade: (db, oldVersion, newVersion) async { - // warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported - // on SQLite <3.25.0, bundled on older Android devices - while (oldVersion < newVersion) { - if (oldVersion == 1) { - // rename column 'orientationDegrees' to 'sourceRotationDegrees' - await db.transaction((txn) async { - const newEntryTable = '${entryTable}TEMP'; - await db.execute('CREATE TABLE $newEntryTable(' - 'contentId INTEGER PRIMARY KEY' - ', uri TEXT' - ', path TEXT' - ', sourceMimeType TEXT' - ', width INTEGER' - ', height INTEGER' - ', sourceRotationDegrees INTEGER' - ', sizeBytes INTEGER' - ', title TEXT' - ', dateModifiedSecs INTEGER' - ', sourceDateTakenMillis INTEGER' - ', durationMillis INTEGER' - ')'); - await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)' - ' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis' - ' FROM $entryTable;'); - await db.execute('DROP TABLE $entryTable;'); - await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;'); - }); - - // rename column 'videoRotation' to 'rotationDegrees' - await db.transaction((txn) async { - const newMetadataTable = '${metadataTable}TEMP'; - await db.execute('CREATE TABLE $newMetadataTable(' - 'contentId INTEGER PRIMARY KEY' - ', mimeType TEXT' - ', dateMillis INTEGER' - ', isAnimated INTEGER' - ', rotationDegrees INTEGER' - ', xmpSubjects TEXT' - ', xmpTitleDescription TEXT' - ', latitude REAL' - ', longitude REAL' - ')'); - await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,isAnimated,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)' - ' SELECT contentId,mimeType,dateMillis,isAnimated,videoRotation,xmpSubjects,xmpTitleDescription,latitude,longitude' - ' FROM $metadataTable;'); - await db.rawInsert('UPDATE $newMetadataTable SET rotationDegrees = NULL WHERE rotationDegrees = 0;'); - await db.execute('DROP TABLE $metadataTable;'); - await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;'); - }); - - // new column 'isFlipped' - await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;'); - - oldVersion++; - } - } - }, - version: 2, + onUpgrade: MetadataDbUpgrader.upgradeDb, + version: 3, ); } @@ -238,7 +181,7 @@ class MetadataDb { // final stopwatch = Stopwatch()..start(); final db = await _database; final maps = await db.query(metadataTable); - final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map, boolAsInteger: true)).toList(); + final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList(); // debugPrint('$runtimeType loadMetadataEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries'); return metadataEntries; } @@ -246,11 +189,15 @@ class MetadataDb { Future saveMetadata(Iterable metadataEntries) async { if (metadataEntries == null || metadataEntries.isEmpty) return; final stopwatch = Stopwatch()..start(); - final db = await _database; - final batch = db.batch(); - metadataEntries.where((metadata) => metadata != null).forEach((metadata) => _batchInsertMetadata(batch, metadata)); - await batch.commit(noResult: true); - debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries'); + try { + final db = await _database; + final batch = db.batch(); + metadataEntries.where((metadata) => metadata != null).forEach((metadata) => _batchInsertMetadata(batch, metadata)); + await batch.commit(noResult: true); + debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries'); + } catch (exception, stack) { + debugPrint('$runtimeType failed to save metadata with exception=$exception\n$stack'); + } } Future updateMetadataId(int oldId, CatalogMetadata metadata) async { @@ -273,7 +220,7 @@ class MetadataDb { } batch.insert( metadataTable, - metadata.toMap(boolAsInteger: true), + metadata.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); } diff --git a/lib/model/metadata_db_upgrade.dart b/lib/model/metadata_db_upgrade.dart new file mode 100644 index 000000000..b343eaf12 --- /dev/null +++ b/lib/model/metadata_db_upgrade.dart @@ -0,0 +1,100 @@ +import 'package:aves/model/metadata_db.dart'; +import 'package:flutter/foundation.dart'; +import 'package:sqflite/sqflite.dart'; + +class MetadataDbUpgrader { + static const entryTable = MetadataDb.entryTable; + static const metadataTable = MetadataDb.metadataTable; + + // warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported + // on SQLite <3.25.0, bundled on older Android devices + static Future upgradeDb(Database db, int oldVersion, int newVersion) async { + while (oldVersion < newVersion) { + switch (oldVersion) { + case 1: + await _upgradeFrom1(db); + break; + case 2: + await _upgradeFrom2(db); + break; + } + oldVersion++; + } + } + + static Future _upgradeFrom1(Database db) async { + debugPrint('upgrading DB from v1'); + // rename column 'orientationDegrees' to 'sourceRotationDegrees' + await db.transaction((txn) async { + const newEntryTable = '${entryTable}TEMP'; + await db.execute('CREATE TABLE $newEntryTable(' + 'contentId INTEGER PRIMARY KEY' + ', uri TEXT' + ', path TEXT' + ', sourceMimeType TEXT' + ', width INTEGER' + ', height INTEGER' + ', sourceRotationDegrees INTEGER' + ', sizeBytes INTEGER' + ', title TEXT' + ', dateModifiedSecs INTEGER' + ', sourceDateTakenMillis INTEGER' + ', durationMillis INTEGER' + ')'); + await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)' + ' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis' + ' FROM $entryTable;'); + await db.execute('DROP TABLE $entryTable;'); + await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;'); + }); + + // rename column 'videoRotation' to 'rotationDegrees' + await db.transaction((txn) async { + const newMetadataTable = '${metadataTable}TEMP'; + await db.execute('CREATE TABLE $newMetadataTable(' + 'contentId INTEGER PRIMARY KEY' + ', mimeType TEXT' + ', dateMillis INTEGER' + ', isAnimated INTEGER' + ', rotationDegrees INTEGER' + ', xmpSubjects TEXT' + ', xmpTitleDescription TEXT' + ', latitude REAL' + ', longitude REAL' + ')'); + await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,isAnimated,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)' + ' SELECT contentId,mimeType,dateMillis,isAnimated,videoRotation,xmpSubjects,xmpTitleDescription,latitude,longitude' + ' FROM $metadataTable;'); + await db.rawInsert('UPDATE $newMetadataTable SET rotationDegrees = NULL WHERE rotationDegrees = 0;'); + await db.execute('DROP TABLE $metadataTable;'); + await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;'); + }); + + // new column 'isFlipped' + await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;'); + } + + static Future _upgradeFrom2(Database db) async { + debugPrint('upgrading DB from v2'); + // merge columns 'isAnimated' and 'isFlipped' into 'flags' + await db.transaction((txn) async { + const newMetadataTable = '${metadataTable}TEMP'; + await db.execute('CREATE TABLE $newMetadataTable(' + 'contentId INTEGER PRIMARY KEY' + ', mimeType TEXT' + ', dateMillis INTEGER' + ', flags INTEGER' + ', rotationDegrees INTEGER' + ', xmpSubjects TEXT' + ', xmpTitleDescription TEXT' + ', latitude REAL' + ', longitude REAL' + ')'); + await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)' + ' SELECT contentId,mimeType,dateMillis,ifnull(isAnimated,0)+ifnull(isFlipped,0)*2,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude' + ' FROM $metadataTable;'); + await db.execute('DROP TABLE $metadataTable;'); + await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;'); + }); + } +} diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 6d33fce2b..d198c454a 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -61,6 +61,7 @@ class AIcons { // thumbnail overlay static const IconData animated = Icons.slideshow; + static const IconData geo = Icons.language_outlined; static const IconData play = Icons.play_circle_outline; static const IconData selected = Icons.check_circle_outline; static const IconData unselected = Icons.radio_button_unchecked; diff --git a/lib/widgets/collection/thumbnail/overlay.dart b/lib/widgets/collection/thumbnail/overlay.dart index 6e9179bd3..a834070cc 100644 --- a/lib/widgets/collection/thumbnail/overlay.dart +++ b/lib/widgets/collection/thumbnail/overlay.dart @@ -36,6 +36,7 @@ class ThumbnailEntryOverlay extends StatelessWidget { children: [ if (entry.hasGps && settings.showThumbnailLocation) GpsIcon(iconSize: iconSize), if (entry.isRaw && settings.showThumbnailRaw) RawIcon(iconSize: iconSize), + if (entry.isGeotiff) GeotiffIcon(iconSize: iconSize), if (entry.isAnimated) AnimatedImageIcon(iconSize: iconSize) else if (entry.isVideo) diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index c6ed8fb77..382b26f75 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -45,6 +45,20 @@ class AnimatedImageIcon extends StatelessWidget { } } +class GeotiffIcon extends StatelessWidget { + final double iconSize; + + const GeotiffIcon({Key key, this.iconSize}) : super(key: key); + + @override + Widget build(BuildContext context) { + return OverlayIcon( + icon: AIcons.geo, + size: iconSize, + ); + } +} + class GpsIcon extends StatelessWidget { final double iconSize; diff --git a/lib/widgets/fullscreen/fullscreen_debug_page.dart b/lib/widgets/fullscreen/fullscreen_debug_page.dart index 3c796a253..0afd2e51a 100644 --- a/lib/widgets/fullscreen/fullscreen_debug_page.dart +++ b/lib/widgets/fullscreen/fullscreen_debug_page.dart @@ -96,6 +96,7 @@ class FullscreenDebugPage extends StatelessWidget { 'isVideo': '${entry.isVideo}', 'isCatalogued': '${entry.isCatalogued}', 'isAnimated': '${entry.isAnimated}', + 'isGeotiff': '${entry.isGeotiff}', 'canEdit': '${entry.canEdit}', 'canEditExif': '${entry.canEditExif}', 'canPrint': '${entry.canPrint}', From f205075ac4a91ec8303e12654af27b0114ffc4fc Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 2 Dec 2020 10:49:47 +0900 Subject: [PATCH 08/27] packages upgrade, removed firebase issue obsolete workaround --- android/app/build.gradle | 3 --- pubspec.lock | 12 ++++++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 8880a3e39..775910c2c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -109,9 +109,6 @@ dependencies { implementation 'com.github.deckerst:Android-TiffBitmapFactory:7efb450636' implementation 'com.github.bumptech.glide:glide:4.11.0' - // TODO TLAD remove when this is fixed: https://github.com/firebase/firebase-android-sdk/issues/1662 https://github.com/FirebaseExtended/flutterfire/issues/3990 - implementation 'com.google.firebase:firebase-analytics:18.0.0' - kapt 'androidx.annotation:annotation:1.1.0' kapt 'com.github.bumptech.glide:compiler:4.11.0' diff --git a/pubspec.lock b/pubspec.lock index b83655a87..32888931d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -63,7 +63,7 @@ packages: name: cached_network_image url: "https://pub.dartlang.org" source: hosted - version: "2.3.3" + version: "2.4.1" characters: dependency: transitive description: @@ -200,7 +200,7 @@ packages: name: firebase url: "https://pub.dartlang.org" source: hosted - version: "7.3.2" + version: "7.3.3" firebase_analytics: dependency: "direct main" description: @@ -228,28 +228,28 @@ packages: name: firebase_core url: "https://pub.dartlang.org" source: hosted - version: "0.5.2" + version: "0.5.2+1" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.1.0" firebase_core_web: dependency: transitive description: name: firebase_core_web url: "https://pub.dartlang.org" source: hosted - version: "0.2.1" + version: "0.2.1+1" firebase_crashlytics: dependency: "direct main" description: name: firebase_crashlytics url: "https://pub.dartlang.org" source: hosted - version: "0.2.3" + version: "0.2.3+1" firebase_crashlytics_platform_interface: dependency: transitive description: From 4f7287de027a5d45a728a35e4f7fbcb796f058eb Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 2 Dec 2020 11:09:51 +0900 Subject: [PATCH 09/27] moved debug related functions --- .../deckers/thibault/aves/MainActivity.kt | 1 + .../aves/channel/calls/AppAdapterHandler.kt | 1 - .../aves/channel/calls/DebugHandler.kt | 222 ++++++++++++++++++ .../aves/channel/calls/MetadataHandler.kt | 188 --------------- lib/services/android_app_service.dart | 10 - lib/services/android_debug_service.dart | 91 +++++++ lib/services/metadata_service.dart | 70 ------ lib/widgets/debug/android_env.dart | 4 +- lib/widgets/fullscreen/debug/metadata.dart | 12 +- 9 files changed, 322 insertions(+), 277 deletions(-) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt create mode 100644 lib/services/android_debug_service.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 7827394d0..6ee05d8d1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -41,6 +41,7 @@ class MainActivity : FlutterActivity() { MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this)) MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this)) MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this)) + MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this)) StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) } StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index 7f942d055..98f03e3fe 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -29,7 +29,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { when (call.method) { "getAppIcon" -> GlobalScope.launch { getAppIcon(call, Coresult(result)) } "getAppNames" -> GlobalScope.launch { getAppNames(Coresult(result)) } - "getEnv" -> result.success(System.getenv()) "edit" -> { val title = call.argument("title") val uri = call.argument("uri")?.let { Uri.parse(it) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt new file mode 100644 index 000000000..b470f4875 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -0,0 +1,222 @@ +package deckers.thibault.aves.channel.calls + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.Context +import android.database.Cursor +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.util.Log +import androidx.exifinterface.media.ExifInterface +import com.drew.imaging.ImageMetadataReader +import com.drew.metadata.file.FileTypeDirectory +import deckers.thibault.aves.metadata.ExifInterfaceHelper +import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper +import deckers.thibault.aves.utils.LogUtils +import deckers.thibault.aves.utils.MimeTypes.isImage +import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface +import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor +import deckers.thibault.aves.utils.MimeTypes.isVideo +import deckers.thibault.aves.utils.StorageUtils +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.io.IOException +import java.util.* + +class DebugHandler(private val context: Context) : MethodCallHandler { + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "getEnv" -> result.success(System.getenv()) + "getBitmapFactoryInfo" -> GlobalScope.launch { getBitmapFactoryInfo(call, Coresult(result)) } + "getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) } + "getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) } + "getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) } + "getMetadataExtractorSummary" -> GlobalScope.launch { getMetadataExtractorSummary(call, Coresult(result)) } + else -> result.notImplemented() + } + } + + private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) { + val uri = call.argument("uri")?.let { Uri.parse(it) } + if (uri == null) { + result.error("getBitmapDecoderInfo-args", "failed because of missing arguments", null) + return + } + + val metadataMap = HashMap() + try { + StorageUtils.openInputStream(context, uri)?.use { input -> + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeStream(input, null, options) + options.outMimeType?.let { metadataMap["MimeType"] = it } + options.outWidth.takeIf { it >= 0 }?.let { metadataMap["Width"] = it.toString() } + options.outHeight.takeIf { it >= 0 }?.let { metadataMap["Height"] = it.toString() } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + options.outColorSpace?.let { metadataMap["ColorSpace"] = it.toString() } + options.outConfig?.let { metadataMap["Config"] = it.toString() } + } + } + } catch (e: IOException) { + // ignore + } + result.success(metadataMap) + } + + private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + if (mimeType == null || uri == null) { + result.error("getContentResolverMetadata-args", "failed because of missing arguments", null) + return + } + + var contentUri: Uri = uri + if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) { + try { + val id = ContentUris.parseId(uri) + contentUri = when { + isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) + isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id) + else -> uri + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + contentUri = MediaStore.setRequireOriginal(contentUri) + } + } catch (e: NumberFormatException) { + // ignore + } + } + + val cursor = context.contentResolver.query(contentUri, null, null, null, null) + if (cursor != null && cursor.moveToFirst()) { + val metadataMap = HashMap() + val columnCount = cursor.columnCount + val columnNames = cursor.columnNames + for (i in 0 until columnCount) { + val key = columnNames[i] + try { + metadataMap[key] = when (cursor.getType(i)) { + Cursor.FIELD_TYPE_NULL -> null + Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(i) + Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(i) + Cursor.FIELD_TYPE_STRING -> cursor.getString(i) + Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(i) + else -> null + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get value for key=$key", e) + } + } + cursor.close() + result.success(metadataMap) + } else { + result.error("getContentResolverMetadata-null", "failed to get cursor for contentUri=$contentUri", null) + } + } + + private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("sizeBytes")?.toLong() + if (mimeType == null || uri == null) { + result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null) + return + } + + val metadataMap = HashMap() + if (isSupportedByExifInterface(mimeType, sizeBytes, strict = false)) { + try { + StorageUtils.openInputStream(context, uri)?.use { input -> + val exif = ExifInterface(input) + for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) { + metadataMap[tag] = exif.getAttribute(tag) + } + } + } catch (e: Exception) { + // ExifInterface initialization can fail with a RuntimeException + // caused by an internal MediaMetadataRetriever failure + result.error("getExifInterfaceMetadata-failure", "failed to get exif for uri=$uri", e.message) + return + } + } + result.success(metadataMap) + } + + private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) { + val uri = call.argument("uri")?.let { Uri.parse(it) } + if (uri == null) { + result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null) + return + } + + val metadataMap = HashMap() + val retriever = StorageUtils.openMetadataRetriever(context, uri) + if (retriever != null) { + try { + for ((code, name) in MediaMetadataRetrieverHelper.allKeys) { + retriever.extractMetadata(code)?.let { metadataMap[name] = it } + } + } catch (e: Exception) { + // ignore + } finally { + // cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs + retriever.release() + } + } + result.success(metadataMap) + } + + private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("sizeBytes")?.toLong() + if (mimeType == null || uri == null) { + result.error("getMetadataExtractorSummary-args", "failed because of missing arguments", null) + return + } + + val metadataMap = HashMap() + if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { + try { + StorageUtils.openInputStream(context, uri)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) + metadataMap["mimeType"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir -> + if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) { + dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) + } else "" + } + metadataMap["typeName"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir -> + if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME)) { + dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME) + } else "" + } + for (dir in metadata.directories) { + val dirName = dir.name ?: "" + var index = 0 + while (metadataMap.containsKey("$dirName ($index)")) index++ + var value = "${dir.tagCount} tags" + dir.parent?.let { value += ", parent: ${it.name}" } + metadataMap["$dirName ($index)"] = value + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e) + } catch (e: NoClassDefFoundError) { + Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e) + } + } + result.success(metadataMap) + } + + companion object { + private val LOG_TAG = LogUtils.createTag(DebugHandler::class.java) + const val CHANNEL = "deckers.thibault/aves/debug" + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 46056b88c..91e08c0cc 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -1,14 +1,8 @@ package deckers.thibault.aves.channel.calls -import android.content.ContentResolver -import android.content.ContentUris import android.content.Context -import android.database.Cursor -import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever import android.net.Uri -import android.os.Build -import android.provider.MediaStore import android.util.Log import androidx.exifinterface.media.ExifInterface import com.adobe.internal.xmp.XMPException @@ -25,7 +19,6 @@ 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 deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble @@ -51,7 +44,6 @@ import deckers.thibault.aves.utils.BitmapUtils import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes -import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isMultimedia import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor @@ -63,7 +55,6 @@ import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import java.io.IOException import java.util.* import kotlin.math.roundToLong @@ -73,11 +64,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { "getAllMetadata" -> GlobalScope.launch { getAllMetadata(call, Coresult(result)) } "getCatalogMetadata" -> GlobalScope.launch { getCatalogMetadata(call, Coresult(result)) } "getOverlayMetadata" -> GlobalScope.launch { getOverlayMetadata(call, Coresult(result)) } - "getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) } - "getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) } - "getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) } - "getBitmapFactoryInfo" -> GlobalScope.launch { getBitmapFactoryInfo(call, Coresult(result)) } - "getMetadataExtractorSummary" -> GlobalScope.launch { getMetadataExtractorSummary(call, Coresult(result)) } "getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) } "getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) } "getXmpThumbnails" -> GlobalScope.launch { getXmpThumbnails(call, Coresult(result)) } @@ -446,180 +432,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { result.success(metadataMap) } - private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) { - val mimeType = call.argument("mimeType") - val uri = call.argument("uri")?.let { Uri.parse(it) } - if (mimeType == null || uri == null) { - result.error("getContentResolverMetadata-args", "failed because of missing arguments", null) - return - } - - var contentUri: Uri = uri - if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) { - try { - val id = ContentUris.parseId(uri) - contentUri = when { - isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) - isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id) - else -> uri - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - contentUri = MediaStore.setRequireOriginal(contentUri) - } - } catch (e: NumberFormatException) { - // ignore - } - } - - val cursor = context.contentResolver.query(contentUri, null, null, null, null) - if (cursor != null && cursor.moveToFirst()) { - val metadataMap = HashMap() - val columnCount = cursor.columnCount - val columnNames = cursor.columnNames - for (i in 0 until columnCount) { - val key = columnNames[i] - try { - metadataMap[key] = when (cursor.getType(i)) { - Cursor.FIELD_TYPE_NULL -> null - Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(i) - Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(i) - Cursor.FIELD_TYPE_STRING -> cursor.getString(i) - Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(i) - else -> null - } - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to get value for key=$key", e) - } - } - cursor.close() - result.success(metadataMap) - } else { - result.error("getContentResolverMetadata-null", "failed to get cursor for contentUri=$contentUri", null) - } - } - - private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) { - val mimeType = call.argument("mimeType") - val uri = call.argument("uri")?.let { Uri.parse(it) } - val sizeBytes = call.argument("sizeBytes")?.toLong() - if (mimeType == null || uri == null) { - result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null) - return - } - - val metadataMap = HashMap() - if (isSupportedByExifInterface(mimeType, sizeBytes, strict = false)) { - try { - StorageUtils.openInputStream(context, uri)?.use { input -> - val exif = ExifInterface(input) - for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) { - metadataMap[tag] = exif.getAttribute(tag) - } - } - } catch (e: Exception) { - // ExifInterface initialization can fail with a RuntimeException - // caused by an internal MediaMetadataRetriever failure - result.error("getExifInterfaceMetadata-failure", "failed to get exif for uri=$uri", e.message) - return - } - } - result.success(metadataMap) - } - - private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) { - val uri = call.argument("uri")?.let { Uri.parse(it) } - if (uri == null) { - result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null) - return - } - - val metadataMap = HashMap() - val retriever = StorageUtils.openMetadataRetriever(context, uri) - if (retriever != null) { - try { - for ((code, name) in MediaMetadataRetrieverHelper.allKeys) { - retriever.extractMetadata(code)?.let { metadataMap[name] = it } - } - } catch (e: Exception) { - // ignore - } finally { - // cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs - retriever.release() - } - } - result.success(metadataMap) - } - - private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) { - val uri = call.argument("uri")?.let { Uri.parse(it) } - if (uri == null) { - result.error("getBitmapDecoderInfo-args", "failed because of missing arguments", null) - return - } - - val metadataMap = HashMap() - try { - StorageUtils.openInputStream(context, uri)?.use { input -> - val options = BitmapFactory.Options().apply { - inJustDecodeBounds = true - } - BitmapFactory.decodeStream(input, null, options) - options.outMimeType?.let { metadataMap["MimeType"] = it } - options.outWidth.takeIf { it >= 0 }?.let { metadataMap["Width"] = it.toString() } - options.outHeight.takeIf { it >= 0 }?.let { metadataMap["Height"] = it.toString() } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - options.outColorSpace?.let { metadataMap["ColorSpace"] = it.toString() } - options.outConfig?.let { metadataMap["Config"] = it.toString() } - } - } - } catch (e: IOException) { - // ignore - } - result.success(metadataMap) - } - - private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) { - val mimeType = call.argument("mimeType") - val uri = call.argument("uri")?.let { Uri.parse(it) } - val sizeBytes = call.argument("sizeBytes")?.toLong() - if (mimeType == null || uri == null) { - result.error("getMetadataExtractorSummary-args", "failed because of missing arguments", null) - return - } - - val metadataMap = HashMap() - if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { - try { - StorageUtils.openInputStream(context, uri)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) - metadataMap["mimeType"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir -> - if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) { - dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) - } else "" - } - metadataMap["typeName"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir -> - if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME)) { - dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME) - } else "" - } - for (dir in metadata.directories) { - val dirName = dir.name ?: "" - var index = 0 - while (metadataMap.containsKey("$dirName ($index)")) index++ - var value = "${dir.tagCount} tags" - dir.parent?.let { value += ", parent: ${it.name}" } - metadataMap["$dirName ($index)"] = value - } - } - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e) - } catch (e: NoClassDefFoundError) { - Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e) - } - } - result.success(metadataMap) - } - private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) { val uri = call.argument("uri")?.let { Uri.parse(it) } if (uri == null) { diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index 7549d69af..bed486206 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -31,16 +31,6 @@ class AndroidAppService { return null; } - static Future getEnv() async { - try { - final result = await platform.invokeMethod('getEnv'); - return result as Map; - } on PlatformException catch (e) { - debugPrint('getEnv failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); - } - return {}; - } - static Future edit(String uri, String mimeType) async { try { return await platform.invokeMethod('edit', { diff --git a/lib/services/android_debug_service.dart b/lib/services/android_debug_service.dart new file mode 100644 index 000000000..301eccc32 --- /dev/null +++ b/lib/services/android_debug_service.dart @@ -0,0 +1,91 @@ +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'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class AndroidDebugService { + static const platform = MethodChannel('deckers.thibault/aves/debug'); + + static Future getEnv() async { + try { + final result = await platform.invokeMethod('getEnv'); + return result as Map; + } on PlatformException catch (e) { + debugPrint('getEnv failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + } + return {}; + } + + static Future getBitmapFactoryInfo(ImageEntry entry) async { + try { + // return map with all data available when decoding image bounds with `BitmapFactory` + final result = await platform.invokeMethod('getBitmapFactoryInfo', { + 'uri': entry.uri, + }) as Map; + return result; + } on PlatformException catch (e) { + debugPrint('getBitmapFactoryInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return {}; + } + + static Future getContentResolverMetadata(ImageEntry entry) async { + try { + // return map with all data available from the content resolver + final result = await platform.invokeMethod('getContentResolverMetadata', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + }) as Map; + return result; + } on PlatformException catch (e) { + debugPrint('getContentResolverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return {}; + } + + static Future getExifInterfaceMetadata(ImageEntry entry) async { + try { + // return map with all data available from the `ExifInterface` library + final result = await platform.invokeMethod('getExifInterfaceMetadata', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + }) as Map; + return result; + } on PlatformException catch (e) { + debugPrint('getExifInterfaceMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return {}; + } + + static Future getMediaMetadataRetrieverMetadata(ImageEntry entry) async { + try { + // return map with all data available from `MediaMetadataRetriever` + final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', { + 'uri': entry.uri, + }) as Map; + return result; + } on PlatformException catch (e) { + debugPrint('getMediaMetadataRetrieverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return {}; + } + + static Future getMetadataExtractorSummary(ImageEntry entry) async { + try { + // return map with the mime type and tag count for each directory found by `metadata-extractor` + final result = await platform.invokeMethod('getMetadataExtractorSummary', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + }) as Map; + return result; + } on PlatformException catch (e) { + debugPrint('getMetadataExtractorSummary failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return {}; + } +} diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index f3e7bfb54..1a0cbcee3 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -80,76 +80,6 @@ class MetadataService { return null; } - static Future getBitmapFactoryInfo(ImageEntry entry) async { - try { - // return map with all data available when decoding image bounds with `BitmapFactory` - final result = await platform.invokeMethod('getBitmapFactoryInfo', { - 'uri': entry.uri, - }) as Map; - return result; - } on PlatformException catch (e) { - debugPrint('getBitmapFactoryInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); - } - return {}; - } - - static Future getContentResolverMetadata(ImageEntry entry) async { - try { - // return map with all data available from the content resolver - final result = await platform.invokeMethod('getContentResolverMetadata', { - 'mimeType': entry.mimeType, - 'uri': entry.uri, - }) as Map; - return result; - } on PlatformException catch (e) { - debugPrint('getContentResolverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); - } - return {}; - } - - static Future getExifInterfaceMetadata(ImageEntry entry) async { - try { - // return map with all data available from the `ExifInterface` library - final result = await platform.invokeMethod('getExifInterfaceMetadata', { - 'mimeType': entry.mimeType, - 'uri': entry.uri, - 'sizeBytes': entry.sizeBytes, - }) as Map; - return result; - } on PlatformException catch (e) { - debugPrint('getExifInterfaceMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); - } - return {}; - } - - static Future getMediaMetadataRetrieverMetadata(ImageEntry entry) async { - try { - // return map with all data available from `MediaMetadataRetriever` - final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', { - 'uri': entry.uri, - }) as Map; - return result; - } on PlatformException catch (e) { - debugPrint('getMediaMetadataRetrieverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); - } - return {}; - } - - static Future getMetadataExtractorSummary(ImageEntry entry) async { - try { - // return map with the mime type and tag count for each directory found by `metadata-extractor` - final result = await platform.invokeMethod('getMetadataExtractorSummary', { - 'mimeType': entry.mimeType, - 'uri': entry.uri, - 'sizeBytes': entry.sizeBytes, - }) as Map; - return result; - } on PlatformException catch (e) { - debugPrint('getMetadataExtractorSummary failed with code=${e.code}, exception=${e.message}, details=${e.details}'); - } - return {}; - } - static Future> getEmbeddedPictures(String uri) async { try { final result = await platform.invokeMethod('getEmbeddedPictures', { diff --git a/lib/widgets/debug/android_env.dart b/lib/widgets/debug/android_env.dart index e590c9cfe..b831572b0 100644 --- a/lib/widgets/debug/android_env.dart +++ b/lib/widgets/debug/android_env.dart @@ -1,6 +1,6 @@ import 'dart:collection'; -import 'package:aves/services/android_app_service.dart'; +import 'package:aves/services/android_debug_service.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:flutter/material.dart'; @@ -16,7 +16,7 @@ class _DebugAndroidEnvironmentSectionState extends State { } void _loadMetadata() { - _bitmapFactoryLoader = MetadataService.getBitmapFactoryInfo(entry); - _contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry); - _exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry); - _mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry); - _metadataExtractorLoader = MetadataService.getMetadataExtractorSummary(entry); + _bitmapFactoryLoader = AndroidDebugService.getBitmapFactoryInfo(entry); + _contentResolverMetadataLoader = AndroidDebugService.getContentResolverMetadata(entry); + _exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry); + _mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry); + _metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry); setState(() {}); } From 24a20d10daf4dbffde975272ef42831df598007f Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 2 Dec 2020 11:38:39 +0900 Subject: [PATCH 10/27] catalog: get tags from IPTC as fallback from XMP --- .../thibault/aves/channel/calls/MetadataHandler.kt | 14 +++++++++++++- lib/services/android_debug_service.dart | 4 ---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 91e08c0cc..bd5042f8f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -17,6 +17,7 @@ import com.drew.metadata.exif.ExifSubIFDDirectory import com.drew.metadata.exif.GpsDirectory import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.gif.GifAnimationDirectory +import com.drew.metadata.iptc.IptcDirectory import com.drew.metadata.webp.WebpDirectory import com.drew.metadata.xmp.XmpDirectory import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll @@ -203,6 +204,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { // set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence): // - XMP / dc:title // - XMP / dc:description + // set `KEY_XMP_SUBJECTS` from these fields (by precedence): + // - XMP / dc:subject + // - IPTC / keywords private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, path: String?, sizeBytes: Long?): Map { val metadataMap = HashMap() @@ -267,7 +271,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) { val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME) val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME, it).value } - metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(separator = ";") + metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(separator = XMP_SUBJECTS_SEPARATOR) } xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.TITLE_PROP_NAME) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it } if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) { @@ -281,6 +285,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } + // XMP fallback to IPTC + if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) { + for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) { + dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(separator = XMP_SUBJECTS_SEPARATOR) } + } + } + // identification of animated GIF & WEBP, GeoTIFF when (mimeType) { MimeTypes.GIF -> { @@ -541,6 +552,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private const val MASK_IS_ANIMATED = 1 shl 0 private const val MASK_IS_FLIPPED = 1 shl 1 private const val MASK_IS_GEOTIFF = 1 shl 2 + private const val XMP_SUBJECTS_SEPARATOR = ";" // overlay metadata private const val KEY_APERTURE = "aperture" diff --git a/lib/services/android_debug_service.dart b/lib/services/android_debug_service.dart index 301eccc32..e1dae2621 100644 --- a/lib/services/android_debug_service.dart +++ b/lib/services/android_debug_service.dart @@ -1,8 +1,4 @@ -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'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; From 556798dd7b8daf69ace6a2a4b17323186a3dcd09 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 2 Dec 2020 13:15:22 +0900 Subject: [PATCH 11/27] info: added GeoTIFF tag names --- .../aves/channel/calls/MetadataHandler.kt | 15 +++++- .../deckers/thibault/aves/metadata/Geotiff.kt | 47 ++++++++++++++----- lib/widgets/collection/thumbnail/raster.dart | 2 +- lib/widgets/fullscreen/image_view.dart | 1 - 4 files changed, 51 insertions(+), 14 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index bd5042f8f..8880ea2b2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -25,6 +25,7 @@ import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational +import deckers.thibault.aves.metadata.Geotiff import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription @@ -45,6 +46,7 @@ import deckers.thibault.aves.utils.BitmapUtils import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes +import deckers.thibault.aves.utils.MimeTypes.TIFF import deckers.thibault.aves.utils.MimeTypes.isMultimedia import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor @@ -102,7 +104,18 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { metadataMap[dirName] = dirMap // tags - dirMap.putAll(dir.tags.map { Pair(it.tagName, it.description) }) + if (mimeType == TIFF && dir is ExifIFD0Directory) { + dirMap.putAll(dir.tags.map { + val name = if (it.hasTagName()) { + it.tagName + } else { + Geotiff.getTagName(it.tagType) ?: it.tagName + } + Pair(name, it.description) + }) + } else { + dirMap.putAll(dir.tags.map { Pair(it.tagName, it.description) }) + } if (dir is XmpDirectory) { try { val xmpMeta = dir.xmpMeta.apply { sort() } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Geotiff.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Geotiff.kt index 5b40d833b..28a09ef25 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Geotiff.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Geotiff.kt @@ -1,27 +1,52 @@ package deckers.thibault.aves.metadata object Geotiff { - // 33550 // ModelPixelScaleTag (optional) - val TAG_MODEL_PIXEL_SCALE = 0x830e + // Tag = 33550 (830E.H) + // Type = DOUBLE + // Count = 3 + const val TAG_MODEL_PIXEL_SCALE = 0x830e - // 33922 // ModelTiepointTag (conditional) - val TAG_MODEL_TIEPOINT = 0x8482 + // Tag = 33922 (8482.H) + // Type = DOUBLE + // Count = 6*K, K = number of tiepoints + const val TAG_MODEL_TIEPOINT = 0x8482 - // 34264 // ModelTransformationTag (conditional) - val TAG_MODEL_TRANSFORMATION = 0x85d8 + // Tag = 34264 (85D8.H) + // Type = DOUBLE + // Count = 16 + const val TAG_MODEL_TRANSFORMATION = 0x85d8 - // 34735 // GeoKeyDirectoryTag (mandatory) - val TAG_GEO_KEY_DIRECTORY = 0x87af + // Tag = 34735 (87AF.H) + // Type = UNSIGNED SHORT + // Count = variable, >= 4 + const val TAG_GEO_KEY_DIRECTORY = 0x87af - // 34736 // GeoDoubleParamsTag (optional) - val TAG_GEO_DOUBLE_PARAMS = 0x87b0 + // Tag = 34736 (87BO.H) + // Type = DOUBLE + // Count = variable + const val TAG_GEO_DOUBLE_PARAMS = 0x87b0 - // 34737 // GeoAsciiParamsTag (optional) + // Tag = 34737 (87B1.H) + // Type = ASCII + // Count = variable val TAG_GEO_ASCII_PARAMS = 0x87b1 + + private val tagNameMap = hashMapOf( + TAG_GEO_ASCII_PARAMS to "Geo Ascii Params", + TAG_GEO_DOUBLE_PARAMS to "Geo Double Params", + TAG_GEO_KEY_DIRECTORY to "Geo Key Directory", + TAG_MODEL_PIXEL_SCALE to "Model Pixel Scale", + TAG_MODEL_TIEPOINT to "Model Tiepoint", + TAG_MODEL_TRANSFORMATION to "Model Transformation", + ) + + fun getTagName(tag: Int): String? { + return tagNameMap[tag] + } } \ No newline at end of file diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index 573caa1dd..68471ae2b 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -145,7 +145,7 @@ class _ThumbnailRasterImageState extends State { tag: heroTag, flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) { ImageProvider heroImageProvider = _fastThumbnailProvider; - if (!entry.isVideo && !entry.isSvg) { + if (!entry.isVideo) { final imageProvider = UriImage( uri: entry.uri, mimeType: entry.mimeType, diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index c5ee6c31d..3a9b22719 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -205,7 +205,6 @@ class _ImageViewState extends State { mimeType: entry.mimeType, colorFilter: colorFilter, ), - placeholderBuilder: (context) => _loadingBuilder(context, fastThumbnailProvider), ), backgroundDecoration: backgroundDecoration, controller: _photoViewController, From 2832351710a48ef4873b0955cb588a2dfeab9a95 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 3 Dec 2020 21:25:26 +0900 Subject: [PATCH 12/27] info: open embedded GImage/GAudio/GDepth media --- .../aves/channel/calls/AppAdapterHandler.kt | 28 ++-- .../aves/channel/calls/DebugHandler.kt | 11 ++ .../aves/channel/calls/MetadataHandler.kt | 84 +++++++++++- .../deckers/thibault/aves/metadata/XMP.kt | 26 ++++ .../thibault/aves/model/SourceImageEntry.kt | 2 +- .../app/src/main/res/xml/provider_paths.xml | 6 + lib/model/image_entry.dart | 2 +- lib/ref/mime_types.dart | 4 + lib/ref/xmp.dart | 15 ++- lib/services/android_app_service.dart | 18 ++- lib/services/android_debug_service.dart | 10 ++ lib/services/metadata_service.dart | 15 +++ .../collection/entry_set_action_delegate.dart | 2 +- lib/widgets/debug/android_dirs.dart | 47 +++++++ lib/widgets/debug/app_debug_page.dart | 2 + .../fullscreen/entry_action_delegate.dart | 2 +- lib/widgets/fullscreen/fullscreen_page.dart | 2 +- lib/widgets/fullscreen/info/common.dart | 52 ++++++-- .../fullscreen/info/metadata/xmp_tile.dart | 121 +++++++++++++++--- 19 files changed, 392 insertions(+), 57 deletions(-) create mode 100644 lib/widgets/debug/android_dirs.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index 98f03e3fe..c134c5c16 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -153,7 +153,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { val intent = Intent(Intent.ACTION_EDIT) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - .setDataAndType(uri, mimeType) + .setDataAndType(getShareableUri(uri), mimeType) return safeStartActivityChooser(title, intent) } @@ -162,7 +162,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { val intent = Intent(Intent.ACTION_VIEW) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - .setDataAndType(uri, mimeType) + .setDataAndType(getShareableUri(uri), mimeType) return safeStartActivityChooser(title, intent) } @@ -178,7 +178,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { val intent = Intent(Intent.ACTION_ATTACH_DATA) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - .setDataAndType(uri, mimeType) + .setDataAndType(getShareableUri(uri), mimeType) return safeStartActivityChooser(title, intent) } @@ -186,15 +186,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { val intent = Intent(Intent.ACTION_SEND) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .setType(mimeType) - when (uri.scheme?.toLowerCase(Locale.ROOT)) { - ContentResolver.SCHEME_FILE -> { - val path = uri.path ?: return false - val applicationId = context.applicationContext.packageName - val apkUri = FileProvider.getUriForFile(context, "$applicationId.fileprovider", File(path)) - intent.putExtra(Intent.EXTRA_STREAM, apkUri) - } - else -> intent.putExtra(Intent.EXTRA_STREAM, uri) - } + .putExtra(Intent.EXTRA_STREAM, getShareableUri(uri)) return safeStartActivityChooser(title, intent) } @@ -251,6 +243,18 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { return false } + private fun getShareableUri(uri: Uri): Uri? { + return when (uri.scheme?.toLowerCase(Locale.ROOT)) { + ContentResolver.SCHEME_FILE -> { + uri.path?.let { path -> + val applicationId = context.applicationContext.packageName + FileProvider.getUriForFile(context, "$applicationId.fileprovider", File(path)) + } + } + else -> uri + } + } + companion object { private val LOG_TAG = createTag(AppAdapterHandler::class.java) const val CHANNEL = "deckers.thibault/aves/app" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index b470f4875..dcdb1fecc 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -31,6 +31,7 @@ import java.util.* class DebugHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { + "getContextDirs" -> result.success(getContextDirs()) "getEnv" -> result.success(System.getenv()) "getBitmapFactoryInfo" -> GlobalScope.launch { getBitmapFactoryInfo(call, Coresult(result)) } "getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) } @@ -41,6 +42,16 @@ class DebugHandler(private val context: Context) : MethodCallHandler { } } + private fun getContextDirs() = hashMapOf( + "dataDir" to context.dataDir, + "cacheDir" to context.cacheDir, + "codeCacheDir" to context.codeCacheDir, + "filesDir" to context.filesDir, + "noBackupFilesDir" to context.noBackupFilesDir, + "obbDir" to context.obbDir, + "externalCacheDir" to context.externalCacheDir, + ).mapValues { it.value?.path } + private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) { val uri = call.argument("uri")?.let { Uri.parse(it) } if (uri == null) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 8880ea2b2..3f42b6d31 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -42,11 +42,14 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff import deckers.thibault.aves.metadata.XMP import deckers.thibault.aves.metadata.XMP.getSafeDateMillis import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText +import deckers.thibault.aves.model.provider.FieldMap +import deckers.thibault.aves.model.provider.FileImageProvider +import deckers.thibault.aves.model.provider.ImageProvider import deckers.thibault.aves.utils.BitmapUtils import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes -import deckers.thibault.aves.utils.MimeTypes.TIFF +import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isMultimedia import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor @@ -58,6 +61,7 @@ import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import java.io.File import java.util.* import kotlin.math.roundToLong @@ -70,6 +74,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { "getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) } "getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) } "getXmpThumbnails" -> GlobalScope.launch { getXmpThumbnails(call, Coresult(result)) } + "extractXmpDataProp" -> GlobalScope.launch { extractXmpDataProp(call, Coresult(result)) } else -> result.notImplemented() } } @@ -104,7 +109,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { metadataMap[dirName] = dirMap // tags - if (mimeType == TIFF && dir is ExifIFD0Directory) { + if (mimeType == MimeTypes.TIFF && dir is ExifIFD0Directory) { dirMap.putAll(dir.tags.map { val name = if (it.hasTagName()) { it.tagName @@ -118,13 +123,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } if (dir is XmpDirectory) { try { - val xmpMeta = dir.xmpMeta.apply { sort() } - for (prop in xmpMeta) { + for (prop in dir.xmpMeta) { if (prop is XMPPropertyInfo) { val path = prop.path - val value = prop.value - if (path?.isNotEmpty() == true && value?.isNotEmpty() == true) { - dirMap[path] = value + if (path?.isNotEmpty() == true) { + val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value + if (value?.isNotEmpty() == true) { + dirMap[path] = value + } } } } @@ -548,6 +554,70 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { result.success(thumbnails) } + private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("sizeBytes")?.toLong() + val dataPropPath = call.argument("propPath") + if (mimeType == null || uri == null || dataPropPath == null) { + result.error("extractXmpDataProp-args", "failed because of missing arguments", null) + return + } + + if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { + try { + StorageUtils.openInputStream(context, uri)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) + // data can be large and stored in "Extended XMP", + // which is returned as a second XMP directory + val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java) + try { + val ns = XMP.namespaceForDataPath(dataPropPath) + val mimePropPath = XMP.mimeTypePathForDataPath(dataPropPath) + val embedMimeType = xmpDirs.map { it.xmpMeta.getPropertyString(ns, mimePropPath) }.first { it != null } + val embedBytes = xmpDirs.map { it.xmpMeta.getPropertyBase64(ns, dataPropPath) }.first { it != null } + val embedFile = File.createTempFile("aves", null, context.cacheDir).apply { + deleteOnExit() + outputStream().use { outputStream -> + embedBytes.inputStream().use { inputStream -> + inputStream.copyTo(outputStream) + } + } + } + val embedUri = Uri.fromFile(embedFile) + val embedFields: FieldMap = hashMapOf( + "uri" to embedUri.toString(), + "mimeType" to embedMimeType, + ) + if (isImage(embedMimeType) || isVideo(embedMimeType)) { + GlobalScope.launch { + FileImageProvider().fetchSingle(context, embedUri, embedMimeType, object : ImageProvider.ImageOpCallback { + override fun onSuccess(fields: FieldMap) { + embedFields.putAll(fields) + result.success(embedFields) + } + + override fun onFailure(throwable: Throwable) = result.error("extractXmpDataProp-failure", "failed to get entry for uri=$embedUri mime=$embedMimeType", throwable.message) + }) + } + } else { + result.success(embedFields) + } + return + } catch (e: XMPException) { + result.error("extractXmpDataProp-args", "failed to read XMP directory for uri=$uri prop=$dataPropPath", e.message) + return + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to extract file from XMP", e) + } catch (e: NoClassDefFoundError) { + Log.w(LOG_TAG, "failed to extract file from XMP", e) + } + } + result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null) + } + companion object { private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java) const val CHANNEL = "deckers.thibault/aves/metadata" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 40ed49a7c..cd49d4ea2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -12,15 +12,41 @@ object XMP { const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/" const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/" const val IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/" + const val SUBJECT_PROP_NAME = "dc:subject" const val TITLE_PROP_NAME = "dc:title" const val DESCRIPTION_PROP_NAME = "dc:description" const val CREATE_DATE_PROP_NAME = "xmp:CreateDate" const val THUMBNAIL_PROP_NAME = "xmp:Thumbnails" const val THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image" + private const val GENERIC_LANG = "" private const val SPECIFIC_LANG = "en-US" + // embedded media data properties + + private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/" + private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/" + private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/" + + private const val GAUDIO_DATA_PROP_NAME = "GAudio:Data" + private const val GDEPTH_DATA_PROP_NAME = "GDepth:Data" + private const val GIMAGE_DATA_PROP_NAME = "GImage:Data" + + private val dataProps = hashMapOf( + GAUDIO_DATA_PROP_NAME to GAUDIO_SCHEMA_NS, + GDEPTH_DATA_PROP_NAME to GDEPTH_SCHEMA_NS, + GIMAGE_DATA_PROP_NAME to GIMAGE_SCHEMA_NS, + ) + + fun isDataPath(path: String) = dataProps.containsKey(path) + + fun namespaceForDataPath(path: String) = dataProps[path] + + fun mimeTypePathForDataPath(dataPropPath: String) = dataPropPath.replace("Data", "Mime") + + // extensions + fun XMPMeta.getSafeLocalizedText(schema: String, propName: String, save: (value: String) -> Unit) { try { if (this.doesPropertyExist(schema, propName)) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt index 409966f68..87509a39e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt @@ -213,7 +213,7 @@ class SourceImageEntry { // finds: width, height, orientation, date private fun fillByExifInterface(context: Context) { - if (!MimeTypes.isSupportedByExifInterface(sourceMimeType, sizeBytes)) return; + if (!MimeTypes.isSupportedByExifInterface(sourceMimeType, sizeBytes)) return try { StorageUtils.openInputStream(context, uri)?.use { input -> diff --git a/android/app/src/main/res/xml/provider_paths.xml b/android/app/src/main/res/xml/provider_paths.xml index fafa14f89..2a7a21e70 100644 --- a/android/app/src/main/res/xml/provider_paths.xml +++ b/android/app/src/main/res/xml/provider_paths.xml @@ -3,4 +3,10 @@ + + + \ No newline at end of file diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index eb0fc7923..73d2d9c79 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -200,7 +200,7 @@ class ImageEntry { bool get isRaw => MimeTypes.rawImages.contains(mimeType); - bool get isVideo => mimeType.startsWith('video'); + bool get isVideo => MimeTypes.isVideo(mimeType); bool get isCatalogued => _catalogMetadata != null; diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index 537d81121..21af7155f 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -41,4 +41,8 @@ class MimeTypes { // groups static const List rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f]; + + static bool isImage(String mimeType) => mimeType.startsWith('image'); + + static bool isVideo(String mimeType) => mimeType.startsWith('video'); } diff --git a/lib/ref/xmp.dart b/lib/ref/xmp.dart index 725728c75..7874edbb5 100644 --- a/lib/ref/xmp.dart +++ b/lib/ref/xmp.dart @@ -1,5 +1,5 @@ class XMP { - static const namespaceSeparator = ':'; + static const propNamespaceSeparator = ':'; static const structFieldSeparator = '/'; // cf https://exiftool.org/TagNames/XMP.html @@ -15,7 +15,11 @@ class XMP { 'exifEX': 'Exif Ex', 'GettyImagesGIFT': 'Getty Images', 'GIMP': 'GIMP', - 'GPano': 'Google Photo Sphere', + 'GAudio': 'Google Audio', + 'GDepth': 'Google Depth', + 'GFocus': 'Google Focus', + 'GImage': 'Google Image', + 'GPano': 'Google Panorama', 'illustrator': 'Illustrator', 'Iptc4xmpCore': 'IPTC Core', 'lr': 'Lightroom', @@ -35,4 +39,11 @@ class XMP { 'xmpRights': 'Rights Management', 'xmpTPg': 'Paged-Text', }; + + // TODO TLAD 'xmp:Thumbnails[\d]/Image' + static const dataProps = [ + 'GAudio:Data', + 'GDepth:Data', + 'GImage:Data', + ]; } diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index bed486206..016cffb27 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -81,7 +81,7 @@ class AndroidAppService { return false; } - static Future share(Iterable entries) async { + static Future shareEntries(Iterable entries) async { // loosen mime type to a generic one, so we can share with badly defined apps // e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats final urisByMimeType = groupBy(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); @@ -91,7 +91,21 @@ class AndroidAppService { 'urisByMimeType': urisByMimeType, }); } on PlatformException catch (e) { - debugPrint('share failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + debugPrint('shareEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return false; + } + + static Future shareSingle(String uri, String mimeType) async { + try { + return await platform.invokeMethod('share', { + 'title': 'Share via:', + 'urisByMimeType': { + mimeType: [uri] + }, + }); + } on PlatformException catch (e) { + debugPrint('shareSingle failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } return false; } diff --git a/lib/services/android_debug_service.dart b/lib/services/android_debug_service.dart index e1dae2621..c1bd79575 100644 --- a/lib/services/android_debug_service.dart +++ b/lib/services/android_debug_service.dart @@ -5,6 +5,16 @@ import 'package:flutter/services.dart'; class AndroidDebugService { static const platform = MethodChannel('deckers.thibault/aves/debug'); + static Future getContextDirs() async { + try { + final result = await platform.invokeMethod('getContextDirs'); + return result as Map; + } on PlatformException catch (e) { + debugPrint('getContextDirs failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + } + return {}; + } + static Future getEnv() async { try { final result = await platform.invokeMethod('getEnv'); diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index 1a0cbcee3..c09845310 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -119,4 +119,19 @@ class MetadataService { } return []; } + + static Future extractXmpDataProp(ImageEntry entry, String propPath) async { + try { + final result = await platform.invokeMethod('extractXmpDataProp', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + 'propPath': propPath, + }); + return result; + } on PlatformException catch (e) { + debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return null; + } } diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 9ecf90e61..2d132398d 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -34,7 +34,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware _showDeleteDialog(context); break; case EntryAction.share: - AndroidAppService.share(selection).then((success) { + AndroidAppService.shareEntries(selection).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; diff --git a/lib/widgets/debug/android_dirs.dart b/lib/widgets/debug/android_dirs.dart new file mode 100644 index 000000000..9f2f02129 --- /dev/null +++ b/lib/widgets/debug/android_dirs.dart @@ -0,0 +1,47 @@ +import 'dart:collection'; + +import 'package:aves/services/android_debug_service.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; +import 'package:aves/widgets/fullscreen/info/common.dart'; +import 'package:flutter/material.dart'; + +class DebugAndroidDirSection extends StatefulWidget { + @override + _DebugAndroidDirSectionState createState() => _DebugAndroidDirSectionState(); +} + +class _DebugAndroidDirSectionState extends State with AutomaticKeepAliveClientMixin { + Future _loader; + + @override + void initState() { + super.initState(); + _loader = AndroidDebugService.getContextDirs(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + + return AvesExpansionTile( + title: 'Android Dir', + children: [ + Padding( + padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), + child: FutureBuilder( + future: _loader, + builder: (context, snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); + final data = SplayTreeMap.of(snapshot.data.map((k, v) => MapEntry(k.toString(), v?.toString() ?? 'null'))); + return InfoRowGroup(data); + }, + ), + ), + ], + ); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index a3c609ab7..dd4fe617e 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -2,6 +2,7 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/debug/android_dirs.dart'; import 'package:aves/widgets/debug/android_env.dart'; import 'package:aves/widgets/debug/cache.dart'; import 'package:aves/widgets/debug/database.dart'; @@ -41,6 +42,7 @@ class AppDebugPageState extends State { padding: EdgeInsets.all(8), children: [ _buildGeneralTabView(), + DebugAndroidDirSection(), DebugAndroidEnvironmentSection(), DebugCacheSection(), DebugAppDatabaseSection(), diff --git a/lib/widgets/fullscreen/entry_action_delegate.dart b/lib/widgets/fullscreen/entry_action_delegate.dart index e77491913..cc7dbc506 100644 --- a/lib/widgets/fullscreen/entry_action_delegate.dart +++ b/lib/widgets/fullscreen/entry_action_delegate.dart @@ -77,7 +77,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { }); break; case EntryAction.share: - AndroidAppService.share({entry}).then((success) { + AndroidAppService.shareEntries({entry}).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; diff --git a/lib/widgets/fullscreen/fullscreen_page.dart b/lib/widgets/fullscreen/fullscreen_page.dart index 8691f2f3e..37abf6a17 100644 --- a/lib/widgets/fullscreen/fullscreen_page.dart +++ b/lib/widgets/fullscreen/fullscreen_page.dart @@ -48,7 +48,7 @@ class SingleFullscreenPage extends StatelessWidget { body: FullscreenBody( initialEntry: entry, ), - backgroundColor: Colors.black, + backgroundColor: Navigator.canPop(context) ? Colors.transparent : Colors.black, resizeToAvoidBottomInset: false, ), ); diff --git a/lib/widgets/fullscreen/info/common.dart b/lib/widgets/fullscreen/info/common.dart index d6d7acd4c..259c7d43f 100644 --- a/lib/widgets/fullscreen/info/common.dart +++ b/lib/widgets/fullscreen/info/common.dart @@ -40,10 +40,12 @@ class SectionRow extends StatelessWidget { class InfoRowGroup extends StatefulWidget { final Map keyValues; final int maxValueLength; + final Map linkHandlers; const InfoRowGroup( this.keyValues, { this.maxValueLength = 0, + this.linkHandlers, }); @override @@ -57,9 +59,13 @@ class _InfoRowGroupState extends State { int get maxValueLength => widget.maxValueLength; + Map get linkHandlers => widget.linkHandlers; + static const keyValuePadding = 16; + static const linkColor = Colors.blue; static final baseStyle = TextStyle(fontFamily: 'Concourse'); static final keyStyle = baseStyle.copyWith(color: Colors.white70, height: 1.7); + static final linkStyle = baseStyle.copyWith(color: linkColor, decoration: TextDecoration.underline); @override Widget build(BuildContext context) { @@ -85,11 +91,29 @@ class _InfoRowGroupState extends State { children: keyValues.entries.expand( (kv) { final key = kv.key; - var value = kv.value; - // long values are clipped, and made expandable by tapping them - final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key); - if (showPreviewOnly) { - value = '${value.substring(0, maxValueLength)}…'; + String value; + TextStyle style; + GestureRecognizer recognizer; + + if (linkHandlers?.containsKey(key) == true) { + final handler = linkHandlers[key]; + value = handler.linkText; + // open link on tap + recognizer = TapGestureRecognizer()..onTap = handler.onTap; + style = linkStyle; + } else { + value = kv.value; + // long values are clipped, and made expandable by tapping them + final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key); + if (showPreviewOnly) { + value = '${value.substring(0, maxValueLength)}…'; + // show full value on tap + recognizer = TapGestureRecognizer()..onTap = () => setState(() => _expandedKeys.add(key)); + } + } + + if (key != lastKey) { + value = '$value\n'; } // as of Flutter v1.22.4, `SelectableText` cannot contain `WidgetSpan` @@ -98,9 +122,9 @@ class _InfoRowGroupState extends State { final spaceCount = (100 * thisSpaceSize / baseSpaceWidth).round(); return [ - TextSpan(text: '$key', style: keyStyle), + TextSpan(text: key, style: keyStyle), TextSpan(text: '\u200A' * spaceCount), - TextSpan(text: '$value${key == lastKey ? '' : '\n'}', recognizer: showPreviewOnly ? _buildTapRecognizer(key) : null), + TextSpan(text: value, style: style, recognizer: recognizer), ]; }, ).toList(), @@ -121,8 +145,14 @@ class _InfoRowGroupState extends State { )..layout(BoxConstraints(), parentUsesSize: true); return para.getMaxIntrinsicWidth(double.infinity); } - - GestureRecognizer _buildTapRecognizer(String key) { - return TapGestureRecognizer()..onTap = () => setState(() => _expandedKeys.add(key)); - } +} + +class InfoLinkHandler { + final String linkText; + final VoidCallback onTap; + + const InfoLinkHandler({ + @required this.linkText, + @required this.onTap, + }); } diff --git a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart index 3de2cdc5a..5d3226803 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart @@ -2,17 +2,25 @@ import 'dart:collection'; import 'package:aves/model/image_entry.dart'; import 'package:aves/ref/brand_colors.dart'; +import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/xmp.dart'; +import 'package:aves/services/android_app_service.dart'; +import 'package:aves/services/metadata_service.dart'; import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:pedantic/pedantic.dart'; -class XmpDirTile extends StatelessWidget { +class XmpDirTile extends StatefulWidget { final ImageEntry entry; final SplayTreeMap tags; final ValueNotifier expandedNotifier; @@ -23,52 +31,75 @@ class XmpDirTile extends StatelessWidget { @required this.expandedNotifier, }); + @override + _XmpDirTileState createState() => _XmpDirTileState(); +} + +class _XmpDirTileState extends State with FeedbackMixin { + ImageEntry get entry => widget.entry; + @override Widget build(BuildContext context) { final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry); - final sections = SplayTreeMap.of( - groupBy, String>(tags.entries, (kv) { + final sections = SplayTreeMap<_XmpNamespace, List>>.of( + groupBy(widget.tags.entries, (kv) { final fullKey = kv.key; - final i = fullKey.indexOf(XMP.namespaceSeparator); - if (i == -1) return ''; + final i = fullKey.indexOf(XMP.propNamespaceSeparator); + if (i == -1) return _XmpNamespace(''); final namespace = fullKey.substring(0, i); - return XMP.namespaces[namespace] ?? namespace; + return _XmpNamespace(namespace); }), - compareAsciiUpperCase, + (a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle), ); return AvesExpansionTile( title: 'XMP', - expandedNotifier: expandedNotifier, + expandedNotifier: widget.expandedNotifier, children: [ if (thumbnail != null) thumbnail, Padding( padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: sections.entries.expand((sectionEntry) { - final title = sectionEntry.key; + children: sections.entries.expand((namespaceProps) { + final namespace = namespaceProps.key; + final displayNamespace = namespace.displayTitle; + final linkHandlers = {}; - final entries = sectionEntry.value.map((kv) { - final key = kv.key.splitMapJoin(XMP.structFieldSeparator, onNonMatch: (s) { + final entries = namespaceProps.value.map((prop) { + final propPath = prop.key; + + final displayKey = propPath.splitMapJoin(XMP.structFieldSeparator, onNonMatch: (s) { // strip namespace - final key = s.split(XMP.namespaceSeparator).last; + final key = s.split(XMP.propNamespaceSeparator).last; // uppercase first letter return key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); }); - return MapEntry(key, kv.value); + + var value = prop.value; + if (XMP.dataProps.contains(propPath)) { + linkHandlers.putIfAbsent( + displayKey, + () => InfoLinkHandler(linkText: 'Open', onTap: () => _openEmbeddedData(propPath)), + ); + } + return MapEntry(displayKey, value); }).toList() ..sort((a, b) => compareAsciiUpperCaseNatural(a.key, b.key)); return [ - if (title.isNotEmpty) + if (displayNamespace.isNotEmpty) Padding( padding: EdgeInsets.only(top: 8), child: HighlightTitle( - title, - color: BrandColors.get(title), + displayNamespace, + color: BrandColors.get(displayNamespace), selectable: true, ), ), - InfoRowGroup(Map.fromEntries(entries), maxValueLength: Constants.infoGroupMaxValueLength), + InfoRowGroup( + Map.fromEntries(entries), + maxValueLength: Constants.infoGroupMaxValueLength, + linkHandlers: linkHandlers, + ), ]; }).toList(), ), @@ -76,4 +107,58 @@ class XmpDirTile extends StatelessWidget { ], ); } + + Future _openEmbeddedData(String propPath) async { + final fields = await MetadataService.extractXmpDataProp(entry, propPath); + if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) { + showFeedback(context, 'Failed'); + return; + } + + final mimeType = fields['mimeType']; + final uri = fields['uri']; + if (!MimeTypes.isImage(mimeType) && !MimeTypes.isVideo(mimeType)) { + // open with another app + unawaited(AndroidAppService.open(uri, mimeType).then((success) { + if (!success) { + // fallback to sharing, so that the file can be saved somewhere + AndroidAppService.shareSingle(uri, mimeType).then((success) { + if (!success) showNoMatchingAppDialog(context); + }); + } + })); + return; + } + + final embedEntry = ImageEntry.fromMap(fields); + unawaited(Navigator.push( + context, + TransparentMaterialPageRoute( + settings: RouteSettings(name: SingleFullscreenPage.routeName), + pageBuilder: (c, a, sa) => SingleFullscreenPage(entry: embedEntry), + ), + )); + } +} + +class _XmpNamespace { + final String namespace; + + const _XmpNamespace(this.namespace); + + String get displayTitle => XMP.namespaces[namespace] ?? namespace; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is _XmpNamespace && other.namespace == namespace; + } + + @override + int get hashCode => namespace.hashCode; + + @override + String toString() { + return '$runtimeType#${shortHash(this)}{namespace=$namespace}'; + } } From ab6124e093537bbb0eec9d39400746be66a1dcb3 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 4 Dec 2020 11:09:49 +0900 Subject: [PATCH 13/27] collection: identify 360 images & videos, added filters for these and GeoTIFF --- .../aves/channel/calls/MetadataHandler.kt | 14 +++++ .../thibault/aves/metadata/Metadata.kt | 3 ++ .../deckers/thibault/aves/metadata/XMP.kt | 51 ++++++++++++++++--- lib/model/filters/mime.dart | 15 ++++++ lib/model/image_entry.dart | 4 ++ lib/model/image_metadata.dart | 22 ++++---- lib/theme/icons.dart | 1 + lib/widgets/collection/thumbnail/overlay.dart | 4 +- lib/widgets/common/identity/aves_icons.dart | 17 ++++++- .../fullscreen/fullscreen_debug_page.dart | 1 + .../fullscreen/info/basic_section.dart | 4 +- lib/widgets/search/search_delegate.dart | 3 ++ 12 files changed, 121 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 3f42b6d31..ab50472b2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -18,6 +18,7 @@ import com.drew.metadata.exif.GpsDirectory import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.gif.GifAnimationDirectory import com.drew.metadata.iptc.IptcDirectory +import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory import com.drew.metadata.webp.WebpDirectory import com.drew.metadata.xmp.XmpDirectory import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll @@ -299,6 +300,11 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } } + + // identification of panorama (aka photo sphere) + if (XMP.panoramaRequiredProps.all { xmpMeta.doesPropertyExist(XMP.GPANO_SCHEMA_NS, it) }) { + flags = flags or MASK_IS_360 + } } catch (e: XMPException) { Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) } @@ -329,6 +335,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } } + + // identification of spherical video (aka 360° video) + if (metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).any { + it.getString(Mp4UuidBoxDirectory.TAG_UUID) == Metadata.SPHERICAL_VIDEO_V1_UUID + }) { + flags = flags or MASK_IS_360 + } } } catch (e: Exception) { Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for uri=$uri, mimeType=$mimeType", e) @@ -635,6 +648,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private const val MASK_IS_ANIMATED = 1 shl 0 private const val MASK_IS_FLIPPED = 1 shl 1 private const val MASK_IS_GEOTIFF = 1 shl 2 + private const val MASK_IS_360 = 1 shl 3 private const val XMP_SUBJECTS_SEPARATOR = ";" // overlay metadata diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index 036ac6b18..22442e254 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -13,6 +13,9 @@ object Metadata { // "+51.3328-000.7053+113.474/" (Apple) val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*") + // cf https://github.com/google/spatial-media + const val SPHERICAL_VIDEO_V1_UUID = "ffcc8263-f855-4a93-8814-587a02521fdd" + // directory names, as shown when listing all metadata const val DIR_GPS = "GPS" // from metadata-extractor const val DIR_XMP = "XMP" // from metadata-extractor diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index cd49d4ea2..9572a76d6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -23,27 +23,66 @@ object XMP { private const val GENERIC_LANG = "" private const val SPECIFIC_LANG = "en-US" + // panorama + // cf https://developers.google.com/streetview/spherical-metadata + + const val GPANO_SCHEMA_NS = "http://ns.google.com/photos/1.0/panorama/" + + private const val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = "GPano:CroppedAreaImageHeightPixels" + private const val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = "GPano:CroppedAreaImageWidthPixels" + private const val GPANO_CROPPED_AREA_LEFT_PROP_NAME = "GPano:CroppedAreaLeftPixels" + private const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels" + private const val GPANO_FULL_PANO_HEIGHT_PIXELS_PROP_NAME = "GPano:FullPanoHeightPixels" + private const val GPANO_FULL_PANO_WIDTH_PIXELS_PROP_NAME = "GPano:FullPanoWidthPixels" + private const val GPANO_PROJECTION_TYPE_PROP_NAME = "GPano:ProjectionType" + + val panoramaRequiredProps = listOf( + GPANO_CROPPED_AREA_HEIGHT_PROP_NAME, + GPANO_CROPPED_AREA_WIDTH_PROP_NAME, + GPANO_CROPPED_AREA_LEFT_PROP_NAME, + GPANO_CROPPED_AREA_TOP_PROP_NAME, + GPANO_FULL_PANO_HEIGHT_PIXELS_PROP_NAME, + GPANO_FULL_PANO_WIDTH_PIXELS_PROP_NAME, + GPANO_PROJECTION_TYPE_PROP_NAME, + ) + // embedded media data properties + // cf https://developers.google.com/depthmap-metadata + // cf https://developers.google.com/vr/reference/cardboard-camera-vr-photo-format private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/" private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/" private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/" private const val GAUDIO_DATA_PROP_NAME = "GAudio:Data" - private const val GDEPTH_DATA_PROP_NAME = "GDepth:Data" private const val GIMAGE_DATA_PROP_NAME = "GImage:Data" + private const val GDEPTH_DATA_PROP_NAME = "GDepth:Data" + private const val GDEPTH_CONFIDENCE_PROP_NAME = "GDepth:Confidence" - private val dataProps = hashMapOf( + private const val GAUDIO_MIME_PROP_NAME = "GAudio:Mime" + private const val GIMAGE_MIME_PROP_NAME = "GImage:Mime" + private const val GDEPTH_MIME_PROP_NAME = "GDepth:Mime" + private const val GDEPTH_CONFIDENCE_MIME_PROP_NAME = "GDepth:ConfidenceMime" + + private val dataPropNamespaces = hashMapOf( GAUDIO_DATA_PROP_NAME to GAUDIO_SCHEMA_NS, - GDEPTH_DATA_PROP_NAME to GDEPTH_SCHEMA_NS, GIMAGE_DATA_PROP_NAME to GIMAGE_SCHEMA_NS, + GDEPTH_DATA_PROP_NAME to GDEPTH_SCHEMA_NS, + GDEPTH_CONFIDENCE_PROP_NAME to GDEPTH_SCHEMA_NS, ) - fun isDataPath(path: String) = dataProps.containsKey(path) + private val dataPropMimeProps = hashMapOf( + GAUDIO_DATA_PROP_NAME to GAUDIO_MIME_PROP_NAME, + GIMAGE_DATA_PROP_NAME to GIMAGE_MIME_PROP_NAME, + GDEPTH_DATA_PROP_NAME to GDEPTH_MIME_PROP_NAME, + GDEPTH_CONFIDENCE_PROP_NAME to GDEPTH_CONFIDENCE_MIME_PROP_NAME, + ) - fun namespaceForDataPath(path: String) = dataProps[path] + fun isDataPath(path: String) = dataPropNamespaces.containsKey(path) - fun mimeTypePathForDataPath(dataPropPath: String) = dataPropPath.replace("Data", "Mime") + fun namespaceForDataPath(dataPropPath: String) = dataPropNamespaces[dataPropPath] + + fun mimeTypePathForDataPath(dataPropPath: String) = dataPropMimeProps[dataPropPath] // extensions diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index 02ac50252..9b9cb7213 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -10,6 +10,9 @@ class MimeFilter extends CollectionFilter { // fake mime type static const animated = 'aves/animated'; // subset of `image/gif` and `image/webp` + static const panorama = 'aves/panorama'; // subset of images + static const sphericalVideo = 'aves/spherical_video'; // subset of videos + static const geotiff = 'aves/geotiff'; // subset of `image/tiff` final String mime; bool Function(ImageEntry) _filter; @@ -22,6 +25,18 @@ class MimeFilter extends CollectionFilter { _filter = (entry) => entry.isAnimated; _label = 'Animated'; _icon = AIcons.animated; + } else if (mime == panorama) { + _filter = (entry) => entry.isImage && entry.is360; + _label = 'Panorama'; + _icon = AIcons.threesixty; + } else if (mime == sphericalVideo) { + _filter = (entry) => entry.isVideo && entry.is360; + _label = '360° Video'; + _icon = AIcons.threesixty; + } else if (mime == geotiff) { + _filter = (entry) => entry.isGeotiff; + _label = 'GeoTIFF'; + _icon = AIcons.geo; } else if (lowMime.endsWith('/*')) { lowMime = lowMime.substring(0, lowMime.length - 2); _filter = (entry) => entry.mimeType.startsWith(lowMime); diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 73d2d9c79..56251265e 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -200,6 +200,8 @@ class ImageEntry { bool get isRaw => MimeTypes.rawImages.contains(mimeType); + bool get isImage => MimeTypes.isImage(mimeType); + bool get isVideo => MimeTypes.isVideo(mimeType); bool get isCatalogued => _catalogMetadata != null; @@ -208,6 +210,8 @@ class ImageEntry { bool get isGeotiff => _catalogMetadata?.isGeotiff ?? false; + bool get is360 => _catalogMetadata?.is360 ?? false; + bool get canEdit => path != null; bool get canPrint => !isVideo; diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index bfc6b84b7..ac10537d9 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -30,7 +30,7 @@ class DateMetadata { class CatalogMetadata { final int contentId, dateMillis; - final bool isAnimated, isGeotiff; + final bool isAnimated, isGeotiff, is360; bool isFlipped; int rotationDegrees; final String mimeType, xmpSubjects, xmpTitleDescription; @@ -38,9 +38,10 @@ class CatalogMetadata { Address address; static const double _precisionErrorTolerance = 1e-9; - static const isAnimatedMask = 1 << 0; - static const isFlippedMask = 1 << 1; - static const isGeotiffMask = 1 << 2; + static const _isAnimatedMask = 1 << 0; + static const _isFlippedMask = 1 << 1; + static const _isGeotiffMask = 1 << 2; + static const _is360Mask = 1 << 3; CatalogMetadata({ this.contentId, @@ -49,6 +50,7 @@ class CatalogMetadata { this.isAnimated, this.isFlipped, this.isGeotiff, + this.is360, this.rotationDegrees, this.xmpSubjects, this.xmpTitleDescription, @@ -74,6 +76,7 @@ class CatalogMetadata { isAnimated: isAnimated, isFlipped: isFlipped, isGeotiff: isGeotiff, + is360: is360, rotationDegrees: rotationDegrees, xmpSubjects: xmpSubjects, xmpTitleDescription: xmpTitleDescription, @@ -88,9 +91,10 @@ class CatalogMetadata { contentId: map['contentId'], mimeType: map['mimeType'], dateMillis: map['dateMillis'] ?? 0, - isAnimated: flags & isAnimatedMask != 0, - isFlipped: flags & isFlippedMask != 0, - isGeotiff: flags & isGeotiffMask != 0, + isAnimated: flags & _isAnimatedMask != 0, + isFlipped: flags & _isFlippedMask != 0, + isGeotiff: flags & _isGeotiffMask != 0, + is360: flags & _is360Mask != 0, // `rotationDegrees` should default to `sourceRotationDegrees`, not 0 rotationDegrees: map['rotationDegrees'], xmpSubjects: map['xmpSubjects'] ?? '', @@ -104,7 +108,7 @@ class CatalogMetadata { 'contentId': contentId, 'mimeType': mimeType, 'dateMillis': dateMillis, - 'flags': (isAnimated ? isAnimatedMask : 0) | (isFlipped ? isFlippedMask : 0) | (isGeotiff ? isGeotiffMask : 0), + 'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0), 'rotationDegrees': rotationDegrees, 'xmpSubjects': xmpSubjects, 'xmpTitleDescription': xmpTitleDescription, @@ -114,7 +118,7 @@ class CatalogMetadata { @override String toString() { - return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; + return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; } } diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index d198c454a..755e14a37 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -63,6 +63,7 @@ class AIcons { static const IconData animated = Icons.slideshow; static const IconData geo = Icons.language_outlined; static const IconData play = Icons.play_circle_outline; + static const IconData threesixty = Icons.threesixty_outlined; static const IconData selected = Icons.check_circle_outline; static const IconData unselected = Icons.radio_button_unchecked; } diff --git a/lib/widgets/collection/thumbnail/overlay.dart b/lib/widgets/collection/thumbnail/overlay.dart index a834070cc..29b464c74 100644 --- a/lib/widgets/collection/thumbnail/overlay.dart +++ b/lib/widgets/collection/thumbnail/overlay.dart @@ -50,7 +50,9 @@ class ThumbnailEntryOverlay extends StatelessWidget { iconSize: iconSize, showDuration: settings.showThumbnailVideoDuration, ), - ), + ) + else if (entry.is360) + SphericalImageIcon(iconSize: iconSize), ], ); }); diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index 382b26f75..4ac616a21 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -23,9 +23,10 @@ class VideoIcon extends StatelessWidget { @override Widget build(BuildContext context) { return OverlayIcon( - icon: AIcons.play, + icon: entry.is360 ? AIcons.threesixty : AIcons.play, size: iconSize, text: showDuration ? entry.durationText : null, + iconScale: entry.is360 && showDuration ? .9 : 1, ); } } @@ -59,6 +60,20 @@ class GeotiffIcon extends StatelessWidget { } } +class SphericalImageIcon extends StatelessWidget { + final double iconSize; + + const SphericalImageIcon({Key key, this.iconSize}) : super(key: key); + + @override + Widget build(BuildContext context) { + return OverlayIcon( + icon: AIcons.threesixty, + size: iconSize, + ); + } +} + class GpsIcon extends StatelessWidget { final double iconSize; diff --git a/lib/widgets/fullscreen/fullscreen_debug_page.dart b/lib/widgets/fullscreen/fullscreen_debug_page.dart index 0afd2e51a..0abf248a0 100644 --- a/lib/widgets/fullscreen/fullscreen_debug_page.dart +++ b/lib/widgets/fullscreen/fullscreen_debug_page.dart @@ -97,6 +97,7 @@ class FullscreenDebugPage extends StatelessWidget { 'isCatalogued': '${entry.isCatalogued}', 'isAnimated': '${entry.isAnimated}', 'isGeotiff': '${entry.isGeotiff}', + 'is360': '${entry.is360}', 'canEdit': '${entry.canEdit}', 'canEditExif': '${entry.canEditExif}', 'canPrint': '${entry.canPrint}', diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index 7b07a39a9..958ba72b1 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -54,8 +54,10 @@ class BasicSection extends StatelessWidget { final tags = entry.xmpSubjects..sort(compareAsciiUpperCase); final album = entry.directory; final filters = [ - if (entry.isVideo) MimeFilter(MimeTypes.anyVideo), if (entry.isAnimated) MimeFilter(MimeFilter.animated), + if (entry.isImage && entry.is360) MimeFilter(MimeFilter.panorama), + if (entry.isVideo) MimeFilter(entry.is360 ? MimeFilter.sphericalVideo : MimeTypes.anyVideo), + if (entry.isGeotiff) MimeFilter(MimeFilter.geotiff), if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)), ...tags.map((tag) => TagFilter(tag)), ]; diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 754150d56..febeec7b5 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -82,6 +82,9 @@ class ImageSearchDelegate { MimeFilter(MimeTypes.anyImage), MimeFilter(MimeTypes.anyVideo), MimeFilter(MimeFilter.animated), + MimeFilter(MimeFilter.panorama), + MimeFilter(MimeFilter.sphericalVideo), + MimeFilter(MimeFilter.geotiff), MimeFilter(MimeTypes.svg), ].where((f) => f != null && containQuery(f.label)), // usually perform hero animation only on tapped chips, From f899f563e8701ac007e378944a5c3cce3e815b5f Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 7 Dec 2020 13:07:20 +0900 Subject: [PATCH 14/27] info: show XMP history and some other structs via cards --- .../aves/channel/calls/DebugHandler.kt | 67 ++++++ .../aves/channel/calls/MetadataHandler.kt | 2 + lib/model/filters/query.dart | 2 +- lib/ref/mime_types.dart | 68 +++--- lib/services/android_debug_service.dart | 15 ++ lib/theme/durations.dart | 1 + lib/theme/icons.dart | 6 +- lib/utils/constants.dart | 4 +- .../common/basic/multi_cross_fader.dart | 57 +++++ .../common/identity/aves_expansion_tile.dart | 1 - .../common/identity/highlight_title.dart | 2 +- lib/widgets/fullscreen/debug/metadata.dart | 38 ++- .../info/metadata/metadata_section.dart | 61 ++--- .../info/metadata/xmp_namespaces.dart | 218 ++++++++++++++++++ .../fullscreen/info/metadata/xmp_structs.dart | 135 +++++++++++ .../fullscreen/info/metadata/xmp_tile.dart | 94 ++------ lib/widgets/settings/settings_page.dart | 15 -- 17 files changed, 619 insertions(+), 167 deletions(-) create mode 100644 lib/widgets/common/basic/multi_cross_fader.dart create mode 100644 lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart create mode 100644 lib/widgets/fullscreen/info/metadata/xmp_structs.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index dcdb1fecc..a528bfb98 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -14,6 +14,7 @@ import com.drew.imaging.ImageMetadataReader import com.drew.metadata.file.FileTypeDirectory import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper +import deckers.thibault.aves.model.provider.FieldMap import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface @@ -25,6 +26,7 @@ import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import org.beyka.tiffbitmapfactory.TiffBitmapFactory import java.io.IOException import java.util.* @@ -38,6 +40,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler { "getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) } "getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) } "getMetadataExtractorSummary" -> GlobalScope.launch { getMetadataExtractorSummary(call, Coresult(result)) } + "getTiffStructure" -> GlobalScope.launch { getTiffStructure(call, Coresult(result)) } else -> result.notImplemented() } } @@ -226,6 +229,70 @@ class DebugHandler(private val context: Context) : MethodCallHandler { result.success(metadataMap) } + private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) { + val uri = call.argument("uri")?.let { Uri.parse(it) } + if (uri == null) { + result.error("getTiffStructure-args", "failed because of missing arguments", null) + return + } + + try { + val metadataMap = HashMap() + var dirCount: Int? = null + context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> + val options = TiffBitmapFactory.Options().apply { + inJustDecodeBounds = true + } + TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) + metadataMap["0"] = tiffOptionsToMap(options) + dirCount = options.outDirectoryCount + } + if (dirCount != null) { + for (i in 1 until dirCount!!) { + context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> + val options = TiffBitmapFactory.Options().apply { + inJustDecodeBounds = true + inDirectoryNumber = i + } + TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) + metadataMap["$i"] = tiffOptionsToMap(options) + } + } + } + result.success(metadataMap) + } catch (e: Exception) { + result.error("getTiffStructure-read", "failed to read tiff", e.message) + } + } + + private fun tiffOptionsToMap(options: TiffBitmapFactory.Options): FieldMap = hashMapOf( + "Author" to options.outAuthor, + "BitsPerSample" to options.outBitsPerSample.toString(), + "CompressionScheme" to options.outCompressionScheme?.toString(), + "Copyright" to options.outCopyright, + "CurDirectoryNumber" to options.outCurDirectoryNumber.toString(), + "Datetime" to options.outDatetime, + "DirectoryCount" to options.outDirectoryCount.toString(), + "FillOrder" to options.outFillOrder?.toString(), + "Height" to options.outHeight.toString(), + "HostComputer" to options.outHostComputer, + "ImageDescription" to options.outImageDescription, + "ImageOrientation" to options.outImageOrientation?.toString(), + "NumberOfStrips" to options.outNumberOfStrips.toString(), + "Photometric" to options.outPhotometric?.toString(), + "PlanarConfig" to options.outPlanarConfig?.toString(), + "ResolutionUnit" to options.outResolutionUnit?.toString(), + "RowPerStrip" to options.outRowPerStrip.toString(), + "SamplePerPixel" to options.outSamplePerPixel.toString(), + "Software" to options.outSoftware, + "StripSize" to options.outStripSize.toString(), + "TileHeight" to options.outTileHeight.toString(), + "TileWidth" to options.outTileWidth.toString(), + "Width" to options.outWidth.toString(), + "XResolution" to options.outXResolution.toString(), + "YResolution" to options.outYResolution.toString(), + ) + companion object { private val LOG_TAG = LogUtils.createTag(DebugHandler::class.java) const val CHANNEL = "deckers.thibault/aves/debug" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index ab50472b2..83fcecd10 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -138,6 +138,8 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } catch (e: XMPException) { Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) } + // remove this stat as it is not actual XMP data + dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT)) } } } diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index b23b27f38..23794cdd0 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -26,7 +26,7 @@ class QueryFilter extends CollectionFilter { // allow untrimmed queries wrapped with `"..."` final matches = exactRegex.allMatches(upQuery); if (matches.length == 1) { - upQuery = matches.elementAt(0).group(1); + upQuery = matches.first.group(1); } _filter = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery); diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index 21af7155f..fe95d5309 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -1,43 +1,43 @@ class MimeTypes { - static const String anyImage = 'image/*'; + static const anyImage = 'image/*'; - static const String gif = 'image/gif'; - static const String heic = 'image/heic'; - static const String heif = 'image/heif'; - static const String jpeg = 'image/jpeg'; - static const String png = 'image/png'; - static const String svg = 'image/svg+xml'; - static const String webp = 'image/webp'; + static const gif = 'image/gif'; + static const heic = 'image/heic'; + static const heif = 'image/heif'; + static const jpeg = 'image/jpeg'; + static const png = 'image/png'; + static const svg = 'image/svg+xml'; + static const webp = 'image/webp'; - static const String tiff = 'image/tiff'; - static const String psd = 'image/vnd.adobe.photoshop'; + static const tiff = 'image/tiff'; + static const psd = 'image/vnd.adobe.photoshop'; - static const String arw = 'image/x-sony-arw'; - static const String cr2 = 'image/x-canon-cr2'; - static const String crw = 'image/x-canon-crw'; - static const String dcr = 'image/x-kodak-dcr'; - static const String dng = 'image/x-adobe-dng'; - static const String erf = 'image/x-epson-erf'; - static const String k25 = 'image/x-kodak-k25'; - static const String kdc = 'image/x-kodak-kdc'; - static const String mrw = 'image/x-minolta-mrw'; - static const String nef = 'image/x-nikon-nef'; - static const String nrw = 'image/x-nikon-nrw'; - static const String orf = 'image/x-olympus-orf'; - static const String pef = 'image/x-pentax-pef'; - static const String raf = 'image/x-fuji-raf'; - static const String raw = 'image/x-panasonic-raw'; - static const String rw2 = 'image/x-panasonic-rw2'; - static const String sr2 = 'image/x-sony-sr2'; - static const String srf = 'image/x-sony-srf'; - static const String srw = 'image/x-samsung-srw'; - static const String x3f = 'image/x-sigma-x3f'; + static const arw = 'image/x-sony-arw'; + static const cr2 = 'image/x-canon-cr2'; + static const crw = 'image/x-canon-crw'; + static const dcr = 'image/x-kodak-dcr'; + static const dng = 'image/x-adobe-dng'; + static const erf = 'image/x-epson-erf'; + static const k25 = 'image/x-kodak-k25'; + static const kdc = 'image/x-kodak-kdc'; + static const mrw = 'image/x-minolta-mrw'; + static const nef = 'image/x-nikon-nef'; + static const nrw = 'image/x-nikon-nrw'; + static const orf = 'image/x-olympus-orf'; + static const pef = 'image/x-pentax-pef'; + static const raf = 'image/x-fuji-raf'; + static const raw = 'image/x-panasonic-raw'; + static const rw2 = 'image/x-panasonic-rw2'; + static const sr2 = 'image/x-sony-sr2'; + static const srf = 'image/x-sony-srf'; + static const srw = 'image/x-samsung-srw'; + static const x3f = 'image/x-sigma-x3f'; - static const String anyVideo = 'video/*'; + static const anyVideo = 'video/*'; - static const String avi = 'video/avi'; - static const String mp2t = 'video/mp2t'; // .m2ts - static const String mp4 = 'video/mp4'; + static const avi = 'video/avi'; + static const mp2t = 'video/mp2t'; // .m2ts + static const mp4 = 'video/mp4'; // groups static const List rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f]; diff --git a/lib/services/android_debug_service.dart b/lib/services/android_debug_service.dart index c1bd79575..31392df08 100644 --- a/lib/services/android_debug_service.dart +++ b/lib/services/android_debug_service.dart @@ -1,4 +1,5 @@ import 'package:aves/model/image_entry.dart'; +import 'package:aves/ref/mime_types.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -94,4 +95,18 @@ class AndroidDebugService { } return {}; } + + static Future getTiffStructure(ImageEntry entry) async { + if (entry.mimeType != MimeTypes.tiff) return {}; + + try { + final result = await platform.invokeMethod('getTiffStructure', { + 'uri': entry.uri, + }) as Map; + return result; + } on PlatformException catch (e) { + debugPrint('getTiffStructure failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return {}; + } } diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 0481bf563..4dc281fdc 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -36,6 +36,7 @@ class Durations { // info static const mapStyleSwitchAnimation = Duration(milliseconds: 300); + static const xmpStructArrayCardTransition = Duration(milliseconds: 300); // delays & refresh intervals static const opToastDisplay = Duration(seconds: 2); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 755e14a37..a50c1c169 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -26,11 +26,9 @@ class AIcons { // actions static const IconData addShortcut = Icons.add_to_home_screen_outlined; static const IconData clear = Icons.clear_outlined; - static const IconData collapse = Icons.expand_less_outlined; static const IconData createAlbum = Icons.add_circle_outline; static const IconData debug = Icons.whatshot_outlined; static const IconData delete = Icons.delete_outlined; - static const IconData expand = Icons.expand_more_outlined; static const IconData flip = Icons.flip_outlined; static const IconData favourite = Icons.favorite_border; static const IconData favouriteActive = Icons.favorite; @@ -52,6 +50,10 @@ class AIcons { static const IconData stats = Icons.pie_chart_outlined; static const IconData zoomIn = Icons.add_outlined; static const IconData zoomOut = Icons.remove_outlined; + static const IconData collapse = Icons.expand_less_outlined; + static const IconData expand = Icons.expand_more_outlined; + static const IconData previous = Icons.chevron_left_outlined; + static const IconData next = Icons.chevron_right_outlined; // albums static const IconData album = Icons.photo_album_outlined; diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 59d567b65..cf0c59894 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -18,8 +18,8 @@ class Constants { offset: Offset(0.5, 1.0), ); - static const String overlayUnknown = '—'; // em dash - static const String infoUnknown = 'unknown'; + static const overlayUnknown = '—'; // em dash + static const infoUnknown = 'unknown'; static final pointNemo = LatLng(-48.876667, -123.393333); diff --git a/lib/widgets/common/basic/multi_cross_fader.dart b/lib/widgets/common/basic/multi_cross_fader.dart new file mode 100644 index 000000000..5c14dd458 --- /dev/null +++ b/lib/widgets/common/basic/multi_cross_fader.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +class MultiCrossFader extends StatefulWidget { + final Duration duration; + final Curve fadeCurve, sizeCurve; + final AlignmentGeometry alignment; + final Widget child; + + const MultiCrossFader({ + @required this.duration, + this.fadeCurve = Curves.linear, + this.sizeCurve = Curves.linear, + this.alignment = Alignment.topCenter, + @required this.child, + }); + + @override + _MultiCrossFaderState createState() => _MultiCrossFaderState(); +} + +class _MultiCrossFaderState extends State { + Widget _first, _second; + CrossFadeState _fadeState = CrossFadeState.showFirst; + + @override + void initState() { + super.initState(); + _first = widget.child; + _second = SizedBox(); + } + + @override + void didUpdateWidget(MultiCrossFader oldWidget) { + super.didUpdateWidget(oldWidget); + if (_first == oldWidget.child) { + _second = widget.child; + _fadeState = CrossFadeState.showSecond; + } else { + _first = widget.child; + _fadeState = CrossFadeState.showFirst; + } + } + + @override + Widget build(BuildContext context) { + return AnimatedCrossFade( + firstChild: _first, + secondChild: _second, + firstCurve: widget.fadeCurve, + secondCurve: widget.fadeCurve, + sizeCurve: widget.sizeCurve, + alignment: widget.alignment, + crossFadeState: _fadeState, + duration: widget.duration, + ); + } +} diff --git a/lib/widgets/common/identity/aves_expansion_tile.dart b/lib/widgets/common/identity/aves_expansion_tile.dart index 7ae1393c6..554d02e9b 100644 --- a/lib/widgets/common/identity/aves_expansion_tile.dart +++ b/lib/widgets/common/identity/aves_expansion_tile.dart @@ -30,7 +30,6 @@ class AvesExpansionTile extends StatelessWidget { title: HighlightTitle( title, color: color, - fontSize: 18, enabled: enabled, ), expandable: enabled, diff --git a/lib/widgets/common/identity/highlight_title.dart b/lib/widgets/common/identity/highlight_title.dart index c2433a944..377668dd3 100644 --- a/lib/widgets/common/identity/highlight_title.dart +++ b/lib/widgets/common/identity/highlight_title.dart @@ -11,7 +11,7 @@ class HighlightTitle extends StatelessWidget { const HighlightTitle( this.title, { this.color, - this.fontSize = 20, + this.fontSize = 18, this.enabled = true, this.selectable = false, }) : assert(title != null); diff --git a/lib/widgets/fullscreen/debug/metadata.dart b/lib/widgets/fullscreen/debug/metadata.dart index 3cf5bc723..fe655bdff 100644 --- a/lib/widgets/fullscreen/debug/metadata.dart +++ b/lib/widgets/fullscreen/debug/metadata.dart @@ -2,6 +2,7 @@ import 'dart:collection'; import 'dart:typed_data'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/android_debug_service.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; @@ -18,7 +19,7 @@ class MetadataTab extends StatefulWidget { } class _MetadataTabState extends State { - Future _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader; + Future _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader, _tiffStructureLoader; // MediaStore timestamp keys static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed']; @@ -38,15 +39,14 @@ class _MetadataTabState extends State { _exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry); _mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry); _metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry); + _tiffStructureLoader = AndroidDebugService.getTiffStructure(entry); setState(() {}); } @override Widget build(BuildContext context) { - Widget builder(BuildContext context, AsyncSnapshot snapshot, String title) { - if (snapshot.hasError) return Text(snapshot.error.toString()); - if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); - final data = SplayTreeMap.of(snapshot.data.map((k, v) { + Widget builderFromSnapshotData(BuildContext context, Map snapshotData, String title) { + final data = SplayTreeMap.of(snapshotData.map((k, v) { final key = k.toString(); var value = v?.toString() ?? 'null'; if ([...secondTimestampKeys, ...millisecondTimestampKeys].contains(key) && v is num && v != 0) { @@ -76,29 +76,47 @@ class _MetadataTabState extends State { ); } + Widget builderFromSnapshot(BuildContext context, AsyncSnapshot snapshot, String title) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); + return builderFromSnapshotData(context, snapshot.data, title); + } + return ListView( padding: EdgeInsets.all(8), children: [ FutureBuilder( future: _bitmapFactoryLoader, - builder: (context, snapshot) => builder(context, snapshot, 'Bitmap Factory'), + builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Bitmap Factory'), ), FutureBuilder( future: _contentResolverMetadataLoader, - builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'), + builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Content Resolver'), ), FutureBuilder( future: _exifInterfaceMetadataLoader, - builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'), + builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Exif Interface'), ), FutureBuilder( future: _mediaMetadataLoader, - builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'), + builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Media Metadata Retriever'), ), FutureBuilder( future: _metadataExtractorLoader, - builder: (context, snapshot) => builder(context, snapshot, 'Metadata Extractor'), + builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Metadata Extractor'), ), + if (entry.mimeType == MimeTypes.tiff) + FutureBuilder( + future: _tiffStructureLoader, + builder: (context, snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: snapshot.data.entries.map((kv) => builderFromSnapshotData(context, kv.value as Map, 'TIFF ${kv.key}')).toList(), + ); + }, + ), ], ); } diff --git a/lib/widgets/fullscreen/info/metadata/metadata_section.dart b/lib/widgets/fullscreen/info/metadata/metadata_section.dart index 438fb3807..ef9121ccd 100644 --- a/lib/widgets/fullscreen/info/metadata/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata/metadata_section.dart @@ -86,37 +86,42 @@ class _MetadataSectionSliverState extends State with Auto // warning: placing the `AnimationLimiter` as a parent to the `ScrollView` // triggers dispose & reinitialization of other sections, including heavy widgets like maps return SliverToBoxAdapter( - child: AnimatedBuilder( - animation: _loadedMetadataUri, - builder: (context, child) { - Widget content; - if (_metadata.isEmpty) { - content = SizedBox.shrink(); - } else { - content = Column( - children: AnimationConfiguration.toStaggeredList( - duration: Durations.staggeredAnimation, - delay: Durations.staggeredAnimationDelay, - childAnimationBuilder: (child) => SlideAnimation( - verticalOffset: 50.0, - child: FadeInAnimation( - child: child, + child: NotificationListener( + // cancel notification bubbling so that the info page + // does not misinterpret content scrolling for page scrolling + onNotification: (notification) => true, + child: AnimatedBuilder( + animation: _loadedMetadataUri, + builder: (context, child) { + Widget content; + if (_metadata.isEmpty) { + content = SizedBox.shrink(); + } else { + content = Column( + children: AnimationConfiguration.toStaggeredList( + duration: Durations.staggeredAnimation, + delay: Durations.staggeredAnimationDelay, + childAnimationBuilder: (child) => SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( + child: child, + ), ), + children: [ + SectionRow(AIcons.info), + ..._metadata.entries.map((kv) => _buildDirTile(kv.key, kv.value)), + ], ), - children: [ - SectionRow(AIcons.info), - ..._metadata.entries.map((kv) => _buildDirTile(kv.key, kv.value)), - ], - ), + ); + } + return AnimationLimiter( + // we update the limiter key after fetching the metadata of a new entry, + // in order to restart the staggered animation of the metadata section + key: Key(_loadedMetadataUri.value), + child: content, ); - } - return AnimationLimiter( - // we update the limiter key after fetching the metadata of a new entry, - // in order to restart the staggered animation of the metadata section - key: Key(_loadedMetadataUri.value), - child: content, - ); - }, + }, + ), ), ); } diff --git a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart new file mode 100644 index 000000000..9fc45fa16 --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart @@ -0,0 +1,218 @@ +import 'package:aves/ref/brand_colors.dart'; +import 'package:aves/ref/xmp.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/identity/highlight_title.dart'; +import 'package:aves/widgets/fullscreen/info/common.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_structs.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class XmpNamespace { + final String namespace; + + const XmpNamespace(this.namespace); + + String get displayTitle => XMP.namespaces[namespace] ?? namespace; + + List buildNamespaceSection({ + @required List> props, + @required void Function(String propPath) openEmbeddedData, + }) { + final linkHandlers = {}; + + final entries = props + .map((prop) { + final propPath = prop.key; + final value = formatValue(prop.value); + if (extractData(propPath, value)) return null; + + final displayKey = _formatKey(propPath); + if (XMP.dataProps.contains(propPath)) { + linkHandlers.putIfAbsent( + displayKey, + () => InfoLinkHandler(linkText: 'Open', onTap: () => openEmbeddedData(propPath)), + ); + } + return MapEntry(displayKey, value); + }) + .where((e) => e != null) + .toList() + ..sort((a, b) => compareAsciiUpperCaseNatural(a.key, b.key)); + + final content = [ + if (entries.isNotEmpty) + InfoRowGroup( + Map.fromEntries(entries), + maxValueLength: Constants.infoGroupMaxValueLength, + linkHandlers: linkHandlers, + ), + ...buildFromExtractedData(), + ]; + + return content.isNotEmpty + ? [ + if (displayTitle.isNotEmpty) + Padding( + padding: EdgeInsets.only(top: 8), + child: HighlightTitle( + displayTitle, + color: BrandColors.get(displayTitle), + selectable: true, + ), + ), + ...content + ] + : []; + } + + String _formatKey(String propPath) { + return propPath.splitMapJoin(XMP.structFieldSeparator, onNonMatch: (s) { + // strip namespace + final key = s.split(XMP.propNamespaceSeparator).last; + // uppercase first letter + return key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); + }); + } + + bool _extractStruct(String propPath, String value, RegExp pattern, Map store) { + final matches = pattern.allMatches(propPath); + if (matches.isEmpty) return false; + + final match = matches.first; + final field = _formatKey(match.group(1)); + store[field] = value; + return true; + } + + bool _extractIndexedStruct(String propPath, String value, RegExp pattern, Map> store) { + final matches = pattern.allMatches(propPath); + if (matches.isEmpty) return false; + + final match = matches.first; + final index = int.parse(match.group(1)); + final field = _formatKey(match.group(2)); + final fields = store.putIfAbsent(index, () => {}); + fields[field] = value; + return true; + } + + bool extractData(String propPath, String value) => false; + + List buildFromExtractedData() => []; + + String formatValue(String value) => value; + + // identity + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is XmpNamespace && other.namespace == namespace; + } + + @override + int get hashCode => namespace.hashCode; + + @override + String toString() { + return '$runtimeType#${shortHash(this)}{namespace=$namespace}'; + } +} + +class XmpBasicNamespace extends XmpNamespace { + static const ns = 'xmp'; + + static final thumbnailsPattern = RegExp(r'xmp:Thumbnails\[(\d+)\]/(.*)'); + + final thumbnails = >{}; + + XmpBasicNamespace() : super(ns); + + @override + bool extractData(String propPath, String value) => _extractIndexedStruct(propPath, value, thumbnailsPattern, thumbnails); + + @override + List buildFromExtractedData() => [ + if (thumbnails.isNotEmpty) + XmpStructArrayCard( + title: 'Thumbnail', + structByIndex: thumbnails, + ) + ]; +} + +class XmpIptcCoreNamespace extends XmpNamespace { + static const ns = 'Iptc4xmpCore'; + + static final creatorContactInfoPattern = RegExp(r'Iptc4xmpCore:CreatorContactInfo/(.*)'); + + final creatorContactInfo = {}; + + XmpIptcCoreNamespace() : super(ns); + + @override + bool extractData(String propPath, String value) => _extractStruct(propPath, value, creatorContactInfoPattern, creatorContactInfo); + + @override + List buildFromExtractedData() => [ + if (creatorContactInfo.isNotEmpty) + XmpStructCard( + title: 'Creator Contact Info', + struct: creatorContactInfo, + ), + ]; +} + +class XmpMMNamespace extends XmpNamespace { + static const ns = 'xmpMM'; + + static const didPrefix = 'xmp.did:'; + static const iidPrefix = 'xmp.iid:'; + + static final derivedFromPattern = RegExp(r'xmpMM:DerivedFrom/(.*)'); + static final historyPattern = RegExp(r'xmpMM:History\[(\d+)\]/(.*)'); + + final derivedFrom = {}; + final history = >{}; + + XmpMMNamespace() : super(ns); + + @override + bool extractData(String propPath, String value) => _extractStruct(propPath, value, derivedFromPattern, derivedFrom) || _extractIndexedStruct(propPath, value, historyPattern, history); + + @override + List buildFromExtractedData() => [ + if (derivedFrom.isNotEmpty) + XmpStructCard( + title: 'Derived From', + struct: derivedFrom, + ), + if (history.isNotEmpty) + XmpStructArrayCard( + title: 'History', + structByIndex: history, + ), + ]; + + @override + String formatValue(String value) { + if (value.startsWith(didPrefix)) return value.replaceFirst(didPrefix, ''); + if (value.startsWith(iidPrefix)) return value.replaceFirst(iidPrefix, ''); + return value; + } +} + +class XmpNoteNamespace extends XmpNamespace { + static const ns = 'xmpNote'; + + // `xmpNote:HasExtendedXMP` is structural and should not be displayed to users + static const hasExtendedXmp = '$ns:HasExtendedXMP'; + + XmpNoteNamespace() : super(ns); + + @override + bool extractData(String propPath, String value) { + return propPath == hasExtendedXmp; + } +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_structs.dart b/lib/widgets/fullscreen/info/metadata/xmp_structs.dart new file mode 100644 index 000000000..e45e6312b --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_structs.dart @@ -0,0 +1,135 @@ +import 'dart:math'; + +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/basic/multi_cross_fader.dart'; +import 'package:aves/widgets/common/identity/highlight_title.dart'; +import 'package:aves/widgets/fullscreen/info/common.dart'; +import 'package:flutter/material.dart'; + +class XmpStructArrayCard extends StatefulWidget { + final String title; + final List> structs = []; + + XmpStructArrayCard({ + @required this.title, + @required Map> structByIndex, + }) { + structs.length = structByIndex.keys.fold(0, max); + structByIndex.keys.forEach((index) => structs[index - 1] = structByIndex[index]); + } + + @override + _XmpStructArrayCardState createState() => _XmpStructArrayCardState(); +} + +class _XmpStructArrayCardState extends State { + int _index; + + List> get structs => widget.structs; + + @override + void initState() { + super.initState(); + _index = structs.length - 1; + } + + @override + Widget build(BuildContext context) { + void setIndex(int index) { + index = index.clamp(0, structs.length - 1); + if (_index != index) { + _index = index; + setState(() {}); + } + } + + return Card( + margin: XmpStructCard.cardMargin, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(left: 8, top: 8, right: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: HighlightTitle( + '${widget.title} ${_index + 1}', + color: Colors.transparent, + selectable: true, + ), + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: Icon(AIcons.previous), + onPressed: _index > 0 ? () => setIndex(_index - 1) : null, + tooltip: 'Previous', + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: Icon(AIcons.next), + onPressed: _index < structs.length - 1 ? () => setIndex(_index + 1) : null, + tooltip: 'Next', + ), + ], + ), + ), + MultiCrossFader( + duration: Durations.xmpStructArrayCardTransition, + sizeCurve: Curves.easeOutBack, + alignment: AlignmentDirectional.topStart, + child: Padding( + // add padding at this level (instead of the column level) + // so that the crossfader can animate the content size + // without clipping the text + padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), + child: InfoRowGroup( + structs[_index], + maxValueLength: Constants.infoGroupMaxValueLength, + ), + ), + ), + ], + ), + ); + } +} + +class XmpStructCard extends StatelessWidget { + final String title; + final Map struct; + + static const cardMargin = EdgeInsets.symmetric(vertical: 8, horizontal: 0); + + const XmpStructCard({ + @required this.title, + @required this.struct, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: cardMargin, + child: Padding( + padding: EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HighlightTitle( + title, + color: Colors.transparent, + selectable: true, + ), + InfoRowGroup( + struct, + maxValueLength: Constants.infoGroupMaxValueLength, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart index 5d3226803..c557eadf7 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart @@ -1,22 +1,18 @@ import 'dart:collection'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/ref/brand_colors.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/xmp.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/metadata_service.dart'; -import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; -import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; -import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:pedantic/pedantic.dart'; @@ -41,13 +37,23 @@ class _XmpDirTileState extends State with FeedbackMixin { @override Widget build(BuildContext context) { final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry); - final sections = SplayTreeMap<_XmpNamespace, List>>.of( + final sections = SplayTreeMap>>.of( groupBy(widget.tags.entries, (kv) { final fullKey = kv.key; final i = fullKey.indexOf(XMP.propNamespaceSeparator); - if (i == -1) return _XmpNamespace(''); - final namespace = fullKey.substring(0, i); - return _XmpNamespace(namespace); + final namespace = i == -1 ? '' : fullKey.substring(0, i); + switch (namespace) { + case XmpBasicNamespace.ns: + return XmpBasicNamespace(); + case XmpIptcCoreNamespace.ns: + return XmpIptcCoreNamespace(); + case XmpMMNamespace.ns: + return XmpMMNamespace(); + case XmpNoteNamespace.ns: + return XmpNoteNamespace(); + default: + return XmpNamespace(namespace); + } }), (a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle), ); @@ -60,48 +66,12 @@ class _XmpDirTileState extends State with FeedbackMixin { padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: sections.entries.expand((namespaceProps) { - final namespace = namespaceProps.key; - final displayNamespace = namespace.displayTitle; - final linkHandlers = {}; - - final entries = namespaceProps.value.map((prop) { - final propPath = prop.key; - - final displayKey = propPath.splitMapJoin(XMP.structFieldSeparator, onNonMatch: (s) { - // strip namespace - final key = s.split(XMP.propNamespaceSeparator).last; - // uppercase first letter - return key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); - }); - - var value = prop.value; - if (XMP.dataProps.contains(propPath)) { - linkHandlers.putIfAbsent( - displayKey, - () => InfoLinkHandler(linkText: 'Open', onTap: () => _openEmbeddedData(propPath)), - ); - } - return MapEntry(displayKey, value); - }).toList() - ..sort((a, b) => compareAsciiUpperCaseNatural(a.key, b.key)); - return [ - if (displayNamespace.isNotEmpty) - Padding( - padding: EdgeInsets.only(top: 8), - child: HighlightTitle( - displayNamespace, - color: BrandColors.get(displayNamespace), - selectable: true, - ), - ), - InfoRowGroup( - Map.fromEntries(entries), - maxValueLength: Constants.infoGroupMaxValueLength, - linkHandlers: linkHandlers, - ), - ]; - }).toList(), + children: sections.entries + .expand((kv) => kv.key.buildNamespaceSection( + props: kv.value, + openEmbeddedData: _openEmbeddedData, + )) + .toList(), ), ), ], @@ -140,25 +110,3 @@ class _XmpDirTileState extends State with FeedbackMixin { )); } } - -class _XmpNamespace { - final String namespace; - - const _XmpNamespace(this.namespace); - - String get displayTitle => XMP.namespaces[namespace] ?? namespace; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is _XmpNamespace && other.namespace == namespace; - } - - @override - int get hashCode => namespace.hashCode; - - @override - String toString() { - return '$runtimeType#${shortHash(this)}{namespace=$namespace}'; - } -} diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index 8661c13d5..a6e52de38 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -5,7 +5,6 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; -import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/settings/access_grants.dart'; @@ -225,17 +224,3 @@ class _SettingsPageState extends State { ); } } - -class SectionTitle extends StatelessWidget { - final String text; - - const SectionTitle(this.text); - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(left: 16, top: 6, right: 16, bottom: 12), - child: HighlightTitle(text), - ); - } -} From ca670e4ee95ff1adaf21db2170000bb3bc852524 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 8 Dec 2020 11:19:52 +0900 Subject: [PATCH 15/27] info: format XMP keys and some values (enums in Exif/Photoshop/TIFF) --- lib/ref/exif.dart | 639 ++++++++++++++++++ .../info/metadata/xmp_namespaces.dart | 185 ++--- .../fullscreen/info/metadata/xmp_ns/exif.dart | 75 ++ .../fullscreen/info/metadata/xmp_ns/iptc.dart | 25 + .../info/metadata/xmp_ns/photoshop.dart | 44 ++ .../fullscreen/info/metadata/xmp_ns/tiff.dart | 29 + .../fullscreen/info/metadata/xmp_ns/xmp.dart | 79 +++ .../fullscreen/info/metadata/xmp_tile.dart | 13 +- 8 files changed, 957 insertions(+), 132 deletions(-) create mode 100644 lib/ref/exif.dart create mode 100644 lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart create mode 100644 lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart create mode 100644 lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart create mode 100644 lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart create mode 100644 lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart diff --git a/lib/ref/exif.dart b/lib/ref/exif.dart new file mode 100644 index 000000000..1c6b890cf --- /dev/null +++ b/lib/ref/exif.dart @@ -0,0 +1,639 @@ +class Exif { + static String getColorSpaceDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'sRGB'; + case 65535: + return 'Uncalibrated'; + default: + return 'Unknown ($value)'; + } + } + + static String getContrastDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Normal'; + case 1: + return 'Soft'; + case 2: + return 'Hard'; + default: + return 'Unknown ($value)'; + } + } + + // adapted from `metadata-extractor` + static String getCompressionDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'Uncompressed'; + case 2: + return 'CCITT 1D'; + case 3: + return 'T4/Group 3 Fax'; + case 4: + return 'T6/Group 4 Fax'; + case 5: + return 'LZW'; + case 6: + return 'JPEG (old-style)'; + case 7: + return 'JPEG'; + case 8: + return 'Adobe Deflate'; + case 9: + return 'JBIG B&W'; + case 10: + return 'JBIG Color'; + case 99: + return 'JPEG'; + case 262: + return 'Kodak 262'; + case 32766: + return 'Next'; + case 32767: + return 'Sony ARW Compressed'; + case 32769: + return 'Packed RAW'; + case 32770: + return 'Samsung SRW Compressed'; + case 32771: + return 'CCIRLEW'; + case 32772: + return 'Samsung SRW Compressed 2'; + case 32773: + return 'PackBits'; + case 32809: + return 'Thunderscan'; + case 32867: + return 'Kodak KDC Compressed'; + case 32895: + return 'IT8CTPAD'; + case 32896: + return 'IT8LW'; + case 32897: + return 'IT8MP'; + case 32898: + return 'IT8BL'; + case 32908: + return 'PixarFilm'; + case 32909: + return 'PixarLog'; + case 32946: + return 'Deflate'; + case 32947: + return 'DCS'; + case 34661: + return 'JBIG'; + case 34676: + return 'SGILog'; + case 34677: + return 'SGILog24'; + case 34712: + return 'JPEG 2000'; + case 34713: + return 'Nikon NEF Compressed'; + case 34715: + return 'JBIG2 TIFF FX'; + case 34718: + return 'Microsoft Document Imaging (MDI) Binary Level Codec'; + case 34719: + return 'Microsoft Document Imaging (MDI) Progressive Transform Codec'; + case 34720: + return 'Microsoft Document Imaging (MDI) Vector'; + case 34892: + return 'Lossy JPEG'; + case 65000: + return 'Kodak DCR Compressed'; + case 65535: + return 'Pentax PEF Compressed'; + default: + return 'Unknown ($value)'; + } + } + + static String getCustomRenderedDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Normal process'; + case 1: + return 'Custom process'; + default: + return 'Unknown ($value)'; + } + } + + static String getExifVersionDescription(String valueString) { + if (valueString?.length == 4) { + final major = int.tryParse(valueString.substring(0, 2)); + final minor = int.tryParse(valueString.substring(2, 4)); + if (major != null && minor != null) { + return '$major.$minor'; + } + } + return valueString; + } + + static String getExposureModeDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Auto exposure'; + case 1: + return 'Manual exposure'; + case 2: + return 'Auto bracket'; + default: + return 'Unknown ($value)'; + } + } + + static String getExposureProgramDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'Manual'; + case 2: + return 'Normal program'; + case 3: + return 'Aperture priority'; + case 4: + return 'Shutter priority'; + case 5: + return 'Creative program'; + case 6: + return 'Action program'; + case 7: + return 'Portrait mode'; + case 8: + return 'Landscape mode'; + default: + return 'Unknown ($value)'; + } + } + + // adapted from `metadata-extractor` + static String getFileSourceDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'Film Scanner'; + case 2: + return 'Reflection Print Scanner'; + case 3: + return 'Digital Still Camera (DSC)'; + default: + return 'Unknown ($value)'; + } + } + + static String getLightSourceDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Unknown'; + case 1: + return 'Daylight'; + case 2: + return 'Fluorescent'; + case 3: + return 'Tungsten (Incandescent)'; + case 4: + return 'Flash'; + case 9: + return 'Fine Weather'; + case 10: + return 'Cloudy Weather'; + case 11: + return 'Shade'; + case 12: + return 'Daylight Fluorescent (D 5700 – 7100K)'; + case 13: + return 'Day White Fluorescent (N 4600 – 5400K)'; + case 14: + return 'Cool White Fluorescent (W 3900 – 4500K)'; + case 15: + return 'White Fluorescent (WW 3200 – 3700K)'; + case 16: + return 'Warm White Fluorescent (WW 2600 - 3250K)'; + case 17: + return 'Standard light A'; + case 18: + return 'Standard light B'; + case 19: + return 'Standard light C'; + case 20: + return 'D55'; + case 21: + return 'D65'; + case 22: + return 'D75'; + case 23: + return 'D50'; + case 24: + return 'ISO Studio Tungsten'; + case 255: + return 'Other'; + default: + return 'Unknown ($value)'; + } + } + + // adapted from `metadata-extractor` + static String getOrientationDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'Top, left side (Horizontal / normal)'; + case 2: + return 'Top, right side (Mirror horizontal)'; + case 3: + return 'Bottom, right side (Rotate 180)'; + case 4: + return 'Bottom, left side (Mirror vertical)'; + case 5: + return 'Left side, top (Mirror horizontal and rotate 270 CW)'; + case 6: + return 'Right side, top (Rotate 90 CW)'; + case 7: + return 'Right side, bottom (Mirror horizontal and rotate 90 CW)'; + case 8: + return 'Left side, bottom (Rotate 270 CW)'; + default: + return 'Unknown ($value)'; + } + } + + // adapted from `metadata-extractor` + static String getPhotometricInterpretationDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'WhiteIsZero'; + case 1: + return 'BlackIsZero'; + case 2: + return 'RGB'; + case 3: + return 'RGB Palette'; + case 4: + return 'Transparency Mask'; + case 5: + return 'CMYK'; + case 6: + return 'YCbCr'; + case 8: + return 'CIELab'; + case 9: + return 'ICCLab'; + case 10: + return 'ITULab'; + case 32803: + return 'Color Filter Array'; + case 32844: + return 'Pixar LogL'; + case 32845: + return 'Pixar LogLuv'; + case 32892: + return 'Linear Raw'; + default: + return 'Unknown ($value)'; + } + } + + // adapted from `metadata-extractor` + static String getPlanarConfigurationDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'Chunky (contiguous for each subsampling pixel)'; + case 2: + return 'Separate (Y-plane/Cb-plane/Cr-plane format)'; + default: + return 'Unknown ($value)'; + } + } + + // adapted from `metadata-extractor` + static String getResolutionUnitDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return '(No unit)'; + case 2: + return 'Inch'; + case 3: + return 'cm'; + default: + return 'Unknown ($value)'; + } + } + + static String getGainControlDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'None'; + case 1: + return 'Low gain up'; + case 2: + return 'High gain up'; + case 3: + return 'Low gain down'; + case 4: + return 'High gain down'; + default: + return 'Unknown ($value)'; + } + } + + static String getMeteringModeDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Unknown'; + case 1: + return 'Average'; + case 2: + return 'Center weighted average'; + case 3: + return 'Spot'; + case 4: + return 'Multi-spot'; + case 5: + return 'Pattern'; + case 6: + return 'Partial'; + case 255: + return 'Other'; + default: + return 'Unknown ($value)'; + } + } + + static String getSaturationDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Normal'; + case 1: + return 'Low saturation'; + case 2: + return 'High saturation'; + default: + return 'Unknown ($value)'; + } + } + + static String getSceneCaptureTypeDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Standard'; + case 1: + return 'Landscape'; + case 2: + return 'Portrait'; + case 3: + return 'Night scene'; + default: + return 'Unknown ($value)'; + } + } + + static String getSceneTypeDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'Directly photographed image'; + default: + return 'Unknown ($value)'; + } + } + + static String getSensingMethodDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'Not defined'; + case 2: + return 'One-chip colour area sensor'; + case 3: + return 'Two-chip colour area sensor'; + case 4: + return 'Three-chip colour area sensor'; + case 5: + return 'Colour sequential area sensor'; + case 7: + return 'Trilinear sensor'; + case 8: + return 'Colour sequential linear sensor'; + default: + return 'Unknown ($value)'; + } + } + + static String getSharpnessDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Normal'; + case 1: + return 'Soft'; + case 2: + return 'Hard'; + default: + return 'Unknown ($value)'; + } + } + + static String getSubjectDistanceRangeDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Unknown'; + case 1: + return 'Macro'; + case 2: + return 'Close view'; + case 3: + return 'Distant view'; + default: + return 'Unknown ($value)'; + } + } + + static String getWhiteBalanceDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Auto'; + case 1: + return 'Manual'; + default: + return 'Unknown ($value)'; + } + } + + static String getYCbCrPositioningDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'Centered'; + case 2: + return 'Co-sited'; + default: + return 'Unknown ($value)'; + } + } + + // Flash + + static String getFlashModeDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Unknown'; + case 1: + return 'Compulsory flash firing'; + case 2: + return 'Compulsory flash suppression'; + case 3: + return 'Auto mode'; + default: + return 'Unknown ($value)'; + } + } + + static String getFlashReturnDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'No strobe return detection'; + case 2: + return 'Strobe return light not detected'; + case 3: + return 'Strobe return light detected'; + default: + return 'Unknown ($value)'; + } + } + + // GPS + + static String getGPSAltitudeRefDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Above sea level'; + case 1: + return 'Below sea level'; + default: + return 'Unknown ($value)'; + } + } + + static String getGPSDifferentialDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Without correction'; + case 1: + return 'Correction applied'; + default: + return 'Unknown ($value)'; + } + } + + static String getGPSDirectionRefDescription(String value) { + switch (value) { + case 'T': + return 'True direction'; + case 'M': + return 'Magnetic direction'; + default: + return 'Unknown ($value)'; + } + } + + static String getGPSMeasureModeDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 2: + return 'Two-dimensional measurement'; + case 3: + return 'Three-dimensional measurement'; + default: + return 'Unknown ($value)'; + } + } + + static String getGPSDestDistanceRefDescription(String value) { + switch (value) { + case 'K': + return 'kilometers'; + case 'M': + return 'miles'; + case 'N': + return 'knots'; + default: + return 'Unknown ($value)'; + } + } + + static String getGPSSpeedRefDescription(String value) { + switch (value) { + case 'K': + return 'kilometers per hour'; + case 'M': + return 'miles per hour'; + case 'N': + return 'knots'; + default: + return 'Unknown ($value)'; + } + } + + static String getGPSStatusDescription(String value) { + switch (value) { + case 'A': + return 'Measurement in progress'; + case 'V': + return 'Measurement is interoperability'; + default: + return 'Unknown ($value)'; + } + } +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart index 9fc45fa16..cfcb92ecc 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart @@ -3,11 +3,42 @@ import 'package:aves/ref/xmp.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_structs.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +class XmpProp { + final String path, value; + final String displayKey; + + static final sentenceCaseStep1 = RegExp(r'([A-Z][a-z]|\[)'); + static final sentenceCaseStep2 = RegExp(r'([a-z])([A-Z])'); + + XmpProp(this.path, this.value) : displayKey = formatKey(path); + + bool get isOpenable => XMP.dataProps.contains(path); + + static String formatKey(String propPath) { + return propPath.splitMapJoin(XMP.structFieldSeparator, + onMatch: (match) => ' ${match.group(0)} ', + onNonMatch: (s) { + // strip namespace + var key = s.split(XMP.propNamespaceSeparator).last; + + // uppercase first letter + key = key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); + + // sentence case + return key.replaceAllMapped(sentenceCaseStep1, (m) => ' ${m.group(1)}').replaceAllMapped(sentenceCaseStep2, (m) => '${m.group(1)} ${m.group(2)}').trim(); + }); + } + + @override + String toString() { + return '$runtimeType#${shortHash(this)}{path=$path, value=$value}'; + } +} + class XmpNamespace { final String namespace; @@ -16,34 +47,32 @@ class XmpNamespace { String get displayTitle => XMP.namespaces[namespace] ?? namespace; List buildNamespaceSection({ - @required List> props, + @required List> rawProps, @required void Function(String propPath) openEmbeddedData, }) { final linkHandlers = {}; - final entries = props - .map((prop) { - final propPath = prop.key; - final value = formatValue(prop.value); - if (extractData(propPath, value)) return null; + final props = rawProps + .map((kv) { + final prop = XmpProp(kv.key, kv.value); + if (extractData(prop)) return null; - final displayKey = _formatKey(propPath); - if (XMP.dataProps.contains(propPath)) { + if (prop.isOpenable) { linkHandlers.putIfAbsent( - displayKey, - () => InfoLinkHandler(linkText: 'Open', onTap: () => openEmbeddedData(propPath)), + prop.displayKey, + () => InfoLinkHandler(linkText: 'Open', onTap: () => openEmbeddedData(prop.path)), ); } - return MapEntry(displayKey, value); + return prop; }) .where((e) => e != null) .toList() - ..sort((a, b) => compareAsciiUpperCaseNatural(a.key, b.key)); + ..sort((a, b) => compareAsciiUpperCaseNatural(a.displayKey, b.displayKey)); final content = [ - if (entries.isNotEmpty) + if (props.isNotEmpty) InfoRowGroup( - Map.fromEntries(entries), + Map.fromEntries(props.map((prop) => MapEntry(prop.displayKey, formatValue(prop)))), maxValueLength: Constants.infoGroupMaxValueLength, linkHandlers: linkHandlers, ), @@ -66,42 +95,33 @@ class XmpNamespace { : []; } - String _formatKey(String propPath) { - return propPath.splitMapJoin(XMP.structFieldSeparator, onNonMatch: (s) { - // strip namespace - final key = s.split(XMP.propNamespaceSeparator).last; - // uppercase first letter - return key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); - }); - } - - bool _extractStruct(String propPath, String value, RegExp pattern, Map store) { - final matches = pattern.allMatches(propPath); + bool extractStruct(XmpProp prop, RegExp pattern, Map store) { + final matches = pattern.allMatches(prop.path); if (matches.isEmpty) return false; final match = matches.first; - final field = _formatKey(match.group(1)); - store[field] = value; + final field = XmpProp.formatKey(match.group(1)); + store[field] = formatValue(prop); return true; } - bool _extractIndexedStruct(String propPath, String value, RegExp pattern, Map> store) { - final matches = pattern.allMatches(propPath); + bool extractIndexedStruct(XmpProp prop, RegExp pattern, Map> store) { + final matches = pattern.allMatches(prop.path); if (matches.isEmpty) return false; final match = matches.first; final index = int.parse(match.group(1)); - final field = _formatKey(match.group(2)); + final field = XmpProp.formatKey(match.group(2)); final fields = store.putIfAbsent(index, () => {}); - fields[field] = value; + fields[field] = formatValue(prop); return true; } - bool extractData(String propPath, String value) => false; + bool extractData(XmpProp prop) => false; List buildFromExtractedData() => []; - String formatValue(String value) => value; + String formatValue(XmpProp prop) => prop.value; // identity @@ -119,100 +139,3 @@ class XmpNamespace { return '$runtimeType#${shortHash(this)}{namespace=$namespace}'; } } - -class XmpBasicNamespace extends XmpNamespace { - static const ns = 'xmp'; - - static final thumbnailsPattern = RegExp(r'xmp:Thumbnails\[(\d+)\]/(.*)'); - - final thumbnails = >{}; - - XmpBasicNamespace() : super(ns); - - @override - bool extractData(String propPath, String value) => _extractIndexedStruct(propPath, value, thumbnailsPattern, thumbnails); - - @override - List buildFromExtractedData() => [ - if (thumbnails.isNotEmpty) - XmpStructArrayCard( - title: 'Thumbnail', - structByIndex: thumbnails, - ) - ]; -} - -class XmpIptcCoreNamespace extends XmpNamespace { - static const ns = 'Iptc4xmpCore'; - - static final creatorContactInfoPattern = RegExp(r'Iptc4xmpCore:CreatorContactInfo/(.*)'); - - final creatorContactInfo = {}; - - XmpIptcCoreNamespace() : super(ns); - - @override - bool extractData(String propPath, String value) => _extractStruct(propPath, value, creatorContactInfoPattern, creatorContactInfo); - - @override - List buildFromExtractedData() => [ - if (creatorContactInfo.isNotEmpty) - XmpStructCard( - title: 'Creator Contact Info', - struct: creatorContactInfo, - ), - ]; -} - -class XmpMMNamespace extends XmpNamespace { - static const ns = 'xmpMM'; - - static const didPrefix = 'xmp.did:'; - static const iidPrefix = 'xmp.iid:'; - - static final derivedFromPattern = RegExp(r'xmpMM:DerivedFrom/(.*)'); - static final historyPattern = RegExp(r'xmpMM:History\[(\d+)\]/(.*)'); - - final derivedFrom = {}; - final history = >{}; - - XmpMMNamespace() : super(ns); - - @override - bool extractData(String propPath, String value) => _extractStruct(propPath, value, derivedFromPattern, derivedFrom) || _extractIndexedStruct(propPath, value, historyPattern, history); - - @override - List buildFromExtractedData() => [ - if (derivedFrom.isNotEmpty) - XmpStructCard( - title: 'Derived From', - struct: derivedFrom, - ), - if (history.isNotEmpty) - XmpStructArrayCard( - title: 'History', - structByIndex: history, - ), - ]; - - @override - String formatValue(String value) { - if (value.startsWith(didPrefix)) return value.replaceFirst(didPrefix, ''); - if (value.startsWith(iidPrefix)) return value.replaceFirst(iidPrefix, ''); - return value; - } -} - -class XmpNoteNamespace extends XmpNamespace { - static const ns = 'xmpNote'; - - // `xmpNote:HasExtendedXMP` is structural and should not be displayed to users - static const hasExtendedXmp = '$ns:HasExtendedXMP'; - - XmpNoteNamespace() : super(ns); - - @override - bool extractData(String propPath, String value) { - return propPath == hasExtendedXmp; - } -} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart new file mode 100644 index 000000000..a4c17f069 --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart @@ -0,0 +1,75 @@ +import 'package:aves/ref/exif.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; + +// cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/exif.md +class XmpExifNamespace extends XmpNamespace { + static const ns = 'exif'; + + XmpExifNamespace() : super(ns); + + @override + String formatValue(XmpProp prop) { + final v = prop.value; + switch (prop.path) { + case 'exif:ColorSpace': + return Exif.getColorSpaceDescription(v); + case 'exif:Contrast': + return Exif.getContrastDescription(v); + case 'exif:CustomRendered': + return Exif.getCustomRenderedDescription(v); + case 'exif:ExifVersion': + case 'exif:FlashpixVersion': + return Exif.getExifVersionDescription(v); + case 'exif:ExposureMode': + return Exif.getExposureModeDescription(v); + case 'exif:ExposureProgram': + return Exif.getExposureProgramDescription(v); + case 'exif:FileSource': + return Exif.getFileSourceDescription(v); + case 'exif:Flash/exif:Mode': + return Exif.getFlashModeDescription(v); + case 'exif:Flash/exif:Return': + return Exif.getFlashReturnDescription(v); + case 'exif:FocalPlaneResolutionUnit': + return Exif.getResolutionUnitDescription(v); + case 'exif:GainControl': + return Exif.getGainControlDescription(v); + case 'exif:LightSource': + return Exif.getLightSourceDescription(v); + case 'exif:MeteringMode': + return Exif.getMeteringModeDescription(v); + case 'exif:Saturation': + return Exif.getSaturationDescription(v); + case 'exif:SceneCaptureType': + return Exif.getSceneCaptureTypeDescription(v); + case 'exif:SceneType': + return Exif.getSceneTypeDescription(v); + case 'exif:SensingMethod': + return Exif.getSensingMethodDescription(v); + case 'exif:Sharpness': + return Exif.getSharpnessDescription(v); + case 'exif:SubjectDistanceRange': + return Exif.getSubjectDistanceRangeDescription(v); + case 'exif:WhiteBalance': + return Exif.getWhiteBalanceDescription(v); + case 'exif:GPSAltitudeRef': + return Exif.getGPSAltitudeRefDescription(v); + case 'exif:GPSDestBearingRef': + case 'exif:GPSImgDirectionRef': + case 'exif:GPSTrackRef': + return Exif.getGPSDirectionRefDescription(v); + case 'exif:GPSDestDistanceRef': + return Exif.getGPSDestDistanceRefDescription(v); + case 'exif:GPSDifferential': + return Exif.getGPSDifferentialDescription(v); + case 'exif:GPSMeasureMode': + return Exif.getGPSMeasureModeDescription(v); + case 'exif:GPSSpeedRef': + return Exif.getGPSSpeedRefDescription(v); + case 'exif:GPSStatus': + return Exif.getGPSStatusDescription(v); + default: + return v; + } + } +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart new file mode 100644 index 000000000..a6654e3e5 --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart @@ -0,0 +1,25 @@ +import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_structs.dart'; +import 'package:flutter/material.dart'; + +class XmpIptcCoreNamespace extends XmpNamespace { + static const ns = 'Iptc4xmpCore'; + + static final creatorContactInfoPattern = RegExp(r'Iptc4xmpCore:CreatorContactInfo/(.*)'); + + final creatorContactInfo = {}; + + XmpIptcCoreNamespace() : super(ns); + + @override + bool extractData(XmpProp prop) => extractStruct(prop, creatorContactInfoPattern, creatorContactInfo); + + @override + List buildFromExtractedData() => [ + if (creatorContactInfo.isNotEmpty) + XmpStructCard( + title: 'Creator Contact Info', + struct: creatorContactInfo, + ), + ]; +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart new file mode 100644 index 000000000..d659bd627 --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart @@ -0,0 +1,44 @@ +// cf photoshop:ColorMode +// cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/photoshop.md +import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; + +class XmpPhotoshopNamespace extends XmpNamespace { + static const ns = 'photoshop'; + + XmpPhotoshopNamespace() : super(ns); + + @override + String formatValue(XmpProp prop) { + final value = prop.value; + switch (prop.path) { + case 'photoshop:ColorMode': + return getColorModeDescription(value); + } + return value; + } + + static String getColorModeDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Bitmap'; + case 1: + return 'Gray scale'; + case 2: + return 'Indexed colour'; + case 3: + return 'RGB colour'; + case 4: + return 'CMYK colour'; + case 7: + return 'Multi-channel'; + case 8: + return 'Duotone'; + case 9: + return 'LAB colour'; + default: + return 'Unknown ($value)'; + } + } +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart new file mode 100644 index 000000000..38e6f0937 --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart @@ -0,0 +1,29 @@ +import 'package:aves/ref/exif.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; + +// cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/tiff.md +class XmpTiffNamespace extends XmpNamespace { + static const ns = 'tiff'; + + XmpTiffNamespace() : super(ns); + + @override + String formatValue(XmpProp prop) { + final value = prop.value; + switch (prop.path) { + case 'tiff:Compression': + return Exif.getCompressionDescription(value); + case 'tiff:Orientation': + return Exif.getOrientationDescription(value); + case 'tiff:PhotometricInterpretation': + return Exif.getPhotometricInterpretationDescription(value); + case 'tiff:PlanarConfiguration': + return Exif.getPlanarConfigurationDescription(value); + case 'tiff:ResolutionUnit': + return Exif.getResolutionUnitDescription(value); + case 'tiff:YCbCrPositioning': + return Exif.getYCbCrPositioningDescription(value); + } + return value; + } +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart new file mode 100644 index 000000000..36376e1ae --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart @@ -0,0 +1,79 @@ +import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_structs.dart'; +import 'package:flutter/material.dart'; + +class XmpBasicNamespace extends XmpNamespace { + static const ns = 'xmp'; + + static final thumbnailsPattern = RegExp(r'xmp:Thumbnails\[(\d+)\]/(.*)'); + + final thumbnails = >{}; + + XmpBasicNamespace() : super(ns); + + @override + bool extractData(XmpProp prop) => extractIndexedStruct(prop, thumbnailsPattern, thumbnails); + + @override + List buildFromExtractedData() => [ + if (thumbnails.isNotEmpty) + XmpStructArrayCard( + title: 'Thumbnail', + structByIndex: thumbnails, + ) + ]; +} + +class XmpMMNamespace extends XmpNamespace { + static const ns = 'xmpMM'; + + static const didPrefix = 'xmp.did:'; + static const iidPrefix = 'xmp.iid:'; + + static final derivedFromPattern = RegExp(r'xmpMM:DerivedFrom/(.*)'); + static final historyPattern = RegExp(r'xmpMM:History\[(\d+)\]/(.*)'); + + final derivedFrom = {}; + final history = >{}; + + XmpMMNamespace() : super(ns); + + @override + bool extractData(XmpProp prop) => extractStruct(prop, derivedFromPattern, derivedFrom) || extractIndexedStruct(prop, historyPattern, history); + + @override + List buildFromExtractedData() => [ + if (derivedFrom.isNotEmpty) + XmpStructCard( + title: 'Derived From', + struct: derivedFrom, + ), + if (history.isNotEmpty) + XmpStructArrayCard( + title: 'History', + structByIndex: history, + ), + ]; + + @override + String formatValue(XmpProp prop) { + final value = prop.value; + if (value.startsWith(didPrefix)) return value.replaceFirst(didPrefix, ''); + if (value.startsWith(iidPrefix)) return value.replaceFirst(iidPrefix, ''); + return value; + } +} + +class XmpNoteNamespace extends XmpNamespace { + static const ns = 'xmpNote'; + + // `xmpNote:HasExtendedXMP` is structural and should not be displayed to users + static const hasExtendedXmp = '$ns:HasExtendedXMP'; + + XmpNoteNamespace() : super(ns); + + @override + bool extractData(XmpProp prop) { + return prop.path == hasExtendedXmp; + } +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart index c557eadf7..80cef89e9 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart @@ -12,6 +12,11 @@ import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/exif.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:pedantic/pedantic.dart'; @@ -45,12 +50,18 @@ class _XmpDirTileState extends State with FeedbackMixin { switch (namespace) { case XmpBasicNamespace.ns: return XmpBasicNamespace(); + case XmpExifNamespace.ns: + return XmpExifNamespace(); case XmpIptcCoreNamespace.ns: return XmpIptcCoreNamespace(); case XmpMMNamespace.ns: return XmpMMNamespace(); case XmpNoteNamespace.ns: return XmpNoteNamespace(); + case XmpPhotoshopNamespace.ns: + return XmpPhotoshopNamespace(); + case XmpTiffNamespace.ns: + return XmpTiffNamespace(); default: return XmpNamespace(namespace); } @@ -68,7 +79,7 @@ class _XmpDirTileState extends State with FeedbackMixin { crossAxisAlignment: CrossAxisAlignment.start, children: sections.entries .expand((kv) => kv.key.buildNamespaceSection( - props: kv.value, + rawProps: kv.value, openEmbeddedData: _openEmbeddedData, )) .toList(), From b297fd5fe0c0cc39be21404a333b125d9f680740 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 8 Dec 2020 11:21:56 +0900 Subject: [PATCH 16/27] catalog: fallback date from XMP photoshop:DateCreated, fallback HEIF date from MMR --- .../aves/channel/calls/MetadataHandler.kt | 57 +++++++++++-------- .../deckers/thibault/aves/metadata/XMP.kt | 2 + 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 83fcecd10..ed5a8bcd6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -200,6 +200,20 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { return dirMap } + // set `KEY_DATE_MILLIS` from these fields (by precedence): + // - ME / Exif / DATETIME_ORIGINAL + // - ME / Exif / DATETIME + // - EI / Exif / DATETIME_ORIGINAL + // - EI / Exif / DATETIME + // - ME / XMP / xmp:CreateDate + // - ME / XMP / photoshop:DateCreated + // - MMR / METADATA_KEY_DATE + // set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence): + // - ME / XMP / dc:title + // - ME / XMP / dc:description + // set `KEY_XMP_SUBJECTS` from these fields (by precedence): + // - ME / XMP / dc:subject + // - ME / IPTC / keywords private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } @@ -211,24 +225,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes)) - if (isVideo(mimeType)) { - metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri)) + if (isMultimedia(mimeType)) { + metadataMap.putAll(getMultimediaCatalogMetadataByMediaMetadataRetriever(uri)) } // report success even when empty result.success(metadataMap) } - // set `KEY_DATE_MILLIS` from these fields (by precedence): - // - Exif / DATETIME_ORIGINAL - // - Exif / DATETIME - // - XMP / xmp:CreateDate - // set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence): - // - XMP / dc:title - // - XMP / dc:description - // set `KEY_XMP_SUBJECTS` from these fields (by precedence): - // - XMP / dc:subject - // - IPTC / keywords private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, path: String?, sizeBytes: Long?): Map { val metadataMap = HashMap() @@ -301,6 +305,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } + if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { + xmpMeta.getSafeDateMillis(XMP.PHOTOSHOP_SCHEMA_NS, XMP.PS_DATE_CREATED_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } + } } // identification of panorama (aka photo sphere) @@ -381,22 +388,26 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { return metadataMap } - private fun getVideoCatalogMetadataByMediaMetadataRetriever(uri: Uri): Map { + private fun getMultimediaCatalogMetadataByMediaMetadataRetriever(uri: Uri): Map { val metadataMap = HashMap() val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return metadataMap try { retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it } - retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it } + if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { + retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it } + } - val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION) - if (locationString != null) { - val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString) - if (matcher.find() && matcher.groupCount() >= 2) { - val latitude = matcher.group(1)?.toDoubleOrNull() - val longitude = matcher.group(2)?.toDoubleOrNull() - if (latitude != null && longitude != null) { - metadataMap[KEY_LATITUDE] = latitude - metadataMap[KEY_LONGITUDE] = longitude + if (!metadataMap.containsKey(KEY_LATITUDE)) { + val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION) + if (locationString != null) { + val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString) + if (matcher.find() && matcher.groupCount() >= 2) { + val latitude = matcher.group(1)?.toDoubleOrNull() + val longitude = matcher.group(2)?.toDoubleOrNull() + if (latitude != null && longitude != null) { + metadataMap[KEY_LATITUDE] = latitude + metadataMap[KEY_LONGITUDE] = longitude + } } } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 9572a76d6..1953e7d9e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -10,12 +10,14 @@ object XMP { private val LOG_TAG = LogUtils.createTag(XMP::class.java) const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/" + const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/" const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/" const val IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/" const val SUBJECT_PROP_NAME = "dc:subject" const val TITLE_PROP_NAME = "dc:title" const val DESCRIPTION_PROP_NAME = "dc:description" + const val PS_DATE_CREATED_PROP_NAME = "photoshop:DateCreated"; const val CREATE_DATE_PROP_NAME = "xmp:CreateDate" const val THUMBNAIL_PROP_NAME = "xmp:Thumbnails" const val THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image" From 690d25737576b04987ae9a6eb6d4f0b2e1f8fc27 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 8 Dec 2020 19:00:29 +0900 Subject: [PATCH 17/27] XMP: reviewed data prop linking, open thumbnails like other data prop --- .../aves/channel/calls/MetadataHandler.kt | 65 ++++--------- .../deckers/thibault/aves/metadata/XMP.kt | 60 ++++-------- lib/ref/xmp.dart | 16 ---- lib/services/metadata_service.dart | 17 +--- .../fullscreen/info/basic_section.dart | 12 ++- lib/widgets/fullscreen/info/common.dart | 4 +- .../info/metadata/metadata_thumbnail.dart | 5 +- .../info/metadata/xmp_namespaces.dart | 94 ++++++++++--------- .../fullscreen/info/metadata/xmp_ns/exif.dart | 3 + .../info/metadata/xmp_ns/google.dart | 69 ++++++++++++++ .../fullscreen/info/metadata/xmp_ns/iptc.dart | 3 + .../info/metadata/xmp_ns/photoshop.dart | 3 + .../fullscreen/info/metadata/xmp_ns/tiff.dart | 3 + .../fullscreen/info/metadata/xmp_ns/xmp.dart | 28 +++++- .../fullscreen/info/metadata/xmp_structs.dart | 6 ++ .../fullscreen/info/metadata/xmp_tile.dart | 39 +++++--- 16 files changed, 237 insertions(+), 190 deletions(-) create mode 100644 lib/widgets/fullscreen/info/metadata/xmp_ns/google.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index ed5a8bcd6..5a5e254c0 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -74,7 +74,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { "getOverlayMetadata" -> GlobalScope.launch { getOverlayMetadata(call, Coresult(result)) } "getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) } "getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) } - "getXmpThumbnails" -> GlobalScope.launch { getXmpThumbnails(call, Coresult(result)) } "extractXmpDataProp" -> GlobalScope.launch { extractXmpDataProp(call, Coresult(result)) } else -> result.notImplemented() } @@ -539,53 +538,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { result.success(thumbnails) } - private fun getXmpThumbnails(call: MethodCall, result: MethodChannel.Result) { - val mimeType = call.argument("mimeType") - val uri = call.argument("uri")?.let { Uri.parse(it) } - val sizeBytes = call.argument("sizeBytes")?.toLong() - if (mimeType == null || uri == null) { - result.error("getXmpThumbnails-args", "failed because of missing arguments", null) - return - } - - val thumbnails = ArrayList() - if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { - try { - StorageUtils.openInputStream(context, uri)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) - for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { - val xmpMeta = dir.xmpMeta - try { - if (xmpMeta.doesPropertyExist(XMP.XMP_SCHEMA_NS, XMP.THUMBNAIL_PROP_NAME)) { - val count = xmpMeta.countArrayItems(XMP.XMP_SCHEMA_NS, XMP.THUMBNAIL_PROP_NAME) - for (i in 1 until count + 1) { - val structName = "${XMP.THUMBNAIL_PROP_NAME}[$i]" - val image = xmpMeta.getStructField(XMP.XMP_SCHEMA_NS, structName, XMP.IMG_SCHEMA_NS, XMP.THUMBNAIL_IMAGE_PROP_NAME) - if (image != null) { - thumbnails.add(XMPUtils.decodeBase64(image.value)) - } - } - } - } catch (e: XMPException) { - Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) - } - } - } - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to extract xmp thumbnail", e) - } catch (e: NoClassDefFoundError) { - Log.w(LOG_TAG, "failed to extract xmp thumbnail", e) - } - } - result.success(thumbnails) - } - private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } val sizeBytes = call.argument("sizeBytes")?.toLong() val dataPropPath = call.argument("propPath") - if (mimeType == null || uri == null || dataPropPath == null) { + val embedMimeType = call.argument("propMimeType") + if (mimeType == null || uri == null || dataPropPath == null || embedMimeType == null) { result.error("extractXmpDataProp-args", "failed because of missing arguments", null) return } @@ -598,10 +557,22 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { // which is returned as a second XMP directory val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java) try { - val ns = XMP.namespaceForDataPath(dataPropPath) - val mimePropPath = XMP.mimeTypePathForDataPath(dataPropPath) - val embedMimeType = xmpDirs.map { it.xmpMeta.getPropertyString(ns, mimePropPath) }.first { it != null } - val embedBytes = xmpDirs.map { it.xmpMeta.getPropertyBase64(ns, dataPropPath) }.first { it != null } + val pathParts = dataPropPath.split('/') + + val embedBytes: ByteArray = if (pathParts.size == 1) { + val propName = pathParts[0] + val propNs = XMP.namespaceForPropPath(propName) + xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, propName) }.first { it != null } + } else { + val structName = pathParts[0] + val structNs = XMP.namespaceForPropPath(structName) + val fieldName = pathParts[1] + val fieldNs = XMP.namespaceForPropPath(fieldName) + xmpDirs.map { it.xmpMeta.getStructField(structNs, structName, fieldNs, fieldName) }.first { it != null }.let { + XMPUtils.decodeBase64(it.value) + } + } + val embedFile = File.createTempFile("aves", null, context.cacheDir).apply { deleteOnExit() outputStream().use { outputStream -> diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 1953e7d9e..9e71c08a5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -12,19 +12,33 @@ object XMP { const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/" const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/" const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/" - const val IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/" const val SUBJECT_PROP_NAME = "dc:subject" const val TITLE_PROP_NAME = "dc:title" const val DESCRIPTION_PROP_NAME = "dc:description" - const val PS_DATE_CREATED_PROP_NAME = "photoshop:DateCreated"; + const val PS_DATE_CREATED_PROP_NAME = "photoshop:DateCreated" const val CREATE_DATE_PROP_NAME = "xmp:CreateDate" - const val THUMBNAIL_PROP_NAME = "xmp:Thumbnails" - const val THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image" private const val GENERIC_LANG = "" private const val SPECIFIC_LANG = "en-US" + private val schemas = hashMapOf( + "GAudio" to "http://ns.google.com/photos/1.0/audio/", + "GDepth" to "http://ns.google.com/photos/1.0/depthmap/", + "GImage" to "http://ns.google.com/photos/1.0/image/", + "xmp" to XMP_SCHEMA_NS, + "xmpGImg" to "http://ns.adobe.com/xap/1.0/g/img/", + ) + + fun namespaceForPropPath(propPath: String) = schemas[propPath.split(":")[0]] + + // embedded media data properties + // cf https://developers.google.com/depthmap-metadata + // cf https://developers.google.com/vr/reference/cardboard-camera-vr-photo-format + private val knownDataPaths = listOf("GAudio:Data", "GImage:Data", "GDepth:Data", "GDepth:Confidence") + + fun isDataPath(path: String) = knownDataPaths.contains(path) + // panorama // cf https://developers.google.com/streetview/spherical-metadata @@ -48,44 +62,6 @@ object XMP { GPANO_PROJECTION_TYPE_PROP_NAME, ) - // embedded media data properties - // cf https://developers.google.com/depthmap-metadata - // cf https://developers.google.com/vr/reference/cardboard-camera-vr-photo-format - - private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/" - private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/" - private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/" - - private const val GAUDIO_DATA_PROP_NAME = "GAudio:Data" - private const val GIMAGE_DATA_PROP_NAME = "GImage:Data" - private const val GDEPTH_DATA_PROP_NAME = "GDepth:Data" - private const val GDEPTH_CONFIDENCE_PROP_NAME = "GDepth:Confidence" - - private const val GAUDIO_MIME_PROP_NAME = "GAudio:Mime" - private const val GIMAGE_MIME_PROP_NAME = "GImage:Mime" - private const val GDEPTH_MIME_PROP_NAME = "GDepth:Mime" - private const val GDEPTH_CONFIDENCE_MIME_PROP_NAME = "GDepth:ConfidenceMime" - - private val dataPropNamespaces = hashMapOf( - GAUDIO_DATA_PROP_NAME to GAUDIO_SCHEMA_NS, - GIMAGE_DATA_PROP_NAME to GIMAGE_SCHEMA_NS, - GDEPTH_DATA_PROP_NAME to GDEPTH_SCHEMA_NS, - GDEPTH_CONFIDENCE_PROP_NAME to GDEPTH_SCHEMA_NS, - ) - - private val dataPropMimeProps = hashMapOf( - GAUDIO_DATA_PROP_NAME to GAUDIO_MIME_PROP_NAME, - GIMAGE_DATA_PROP_NAME to GIMAGE_MIME_PROP_NAME, - GDEPTH_DATA_PROP_NAME to GDEPTH_MIME_PROP_NAME, - GDEPTH_CONFIDENCE_PROP_NAME to GDEPTH_CONFIDENCE_MIME_PROP_NAME, - ) - - fun isDataPath(path: String) = dataPropNamespaces.containsKey(path) - - fun namespaceForDataPath(dataPropPath: String) = dataPropNamespaces[dataPropPath] - - fun mimeTypePathForDataPath(dataPropPath: String) = dataPropMimeProps[dataPropPath] - // extensions fun XMPMeta.getSafeLocalizedText(schema: String, propName: String, save: (value: String) -> Unit) { diff --git a/lib/ref/xmp.dart b/lib/ref/xmp.dart index 7874edbb5..56c819de7 100644 --- a/lib/ref/xmp.dart +++ b/lib/ref/xmp.dart @@ -11,17 +11,12 @@ class XMP { 'crs': 'Camera Raw Settings', 'dc': 'Dublin Core', 'drone-dji': 'DJI Drone', - 'exif': 'Exif', 'exifEX': 'Exif Ex', 'GettyImagesGIFT': 'Getty Images', 'GIMP': 'GIMP', - 'GAudio': 'Google Audio', - 'GDepth': 'Google Depth', 'GFocus': 'Google Focus', - 'GImage': 'Google Image', 'GPano': 'Google Panorama', 'illustrator': 'Illustrator', - 'Iptc4xmpCore': 'IPTC Core', 'lr': 'Lightroom', 'MicrosoftPhoto': 'Microsoft Photo', 'panorama': 'Panorama', @@ -29,21 +24,10 @@ class XMP { 'pdfx': 'PDF/X', 'PanoStudioXMP': 'PanoramaStudio', 'photomechanic': 'Photo Mechanic', - 'photoshop': 'Photoshop', 'plus': 'PLUS', - 'tiff': 'TIFF', - 'xmp': 'Basic', 'xmpBJ': 'Basic Job Ticket', 'xmpDM': 'Dynamic Media', - 'xmpMM': 'Media Management', 'xmpRights': 'Rights Management', 'xmpTPg': 'Paged-Text', }; - - // TODO TLAD 'xmp:Thumbnails[\d]/Image' - static const dataProps = [ - 'GAudio:Data', - 'GDepth:Data', - 'GImage:Data', - ]; } diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index c09845310..268bf1c17 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -106,27 +106,14 @@ class MetadataService { return []; } - static Future> getXmpThumbnails(ImageEntry entry) async { - try { - final result = await platform.invokeMethod('getXmpThumbnails', { - 'mimeType': entry.mimeType, - 'uri': entry.uri, - 'sizeBytes': entry.sizeBytes, - }); - return (result as List).cast(); - } on PlatformException catch (e) { - debugPrint('getXmpThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); - } - return []; - } - - static Future extractXmpDataProp(ImageEntry entry, String propPath) async { + static Future extractXmpDataProp(ImageEntry entry, String propPath, String propMimeType) async { try { final result = await platform.invokeMethod('extractXmpDataProp', { 'mimeType': entry.mimeType, 'uri': entry.uri, 'sizeBytes': entry.sizeBytes, 'propPath': propPath, + 'propMimeType': propMimeType, }); return result; } on PlatformException catch (e) { diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index 958ba72b1..c14b92088 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -33,17 +33,23 @@ class BasicSection extends StatelessWidget { final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0; final resolutionText = '${entry.resolutionText}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}'; + // TODO TLAD line break on all characters for the following fields when this is fixed: https://github.com/flutter/flutter/issues/61081 + // inserting ZWSP (\u200B) between characters does help, but it messes with width and height computation (another Flutter issue) + final title = entry.bestTitle ?? Constants.infoUnknown; + final uri = entry.uri ?? Constants.infoUnknown; + final path = entry.path; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ InfoRowGroup({ - 'Title': entry.bestTitle ?? Constants.infoUnknown, + 'Title': title, 'Date': dateText, if (entry.isVideo) ..._buildVideoRows(), if (!entry.isSvg) 'Resolution': resolutionText, 'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.infoUnknown, - 'URI': entry.uri ?? Constants.infoUnknown, - if (entry.path != null) 'Path': entry.path, + 'URI': uri, + if (path != null) 'Path': path, }), _buildChips(), ], diff --git a/lib/widgets/fullscreen/info/common.dart b/lib/widgets/fullscreen/info/common.dart index 259c7d43f..4002d1571 100644 --- a/lib/widgets/fullscreen/info/common.dart +++ b/lib/widgets/fullscreen/info/common.dart @@ -99,7 +99,7 @@ class _InfoRowGroupState extends State { final handler = linkHandlers[key]; value = handler.linkText; // open link on tap - recognizer = TapGestureRecognizer()..onTap = handler.onTap; + recognizer = TapGestureRecognizer()..onTap = () => handler.onTap(context); style = linkStyle; } else { value = kv.value; @@ -149,7 +149,7 @@ class _InfoRowGroupState extends State { class InfoLinkHandler { final String linkText; - final VoidCallback onTap; + final void Function(BuildContext context) onTap; const InfoLinkHandler({ @required this.linkText, diff --git a/lib/widgets/fullscreen/info/metadata/metadata_thumbnail.dart b/lib/widgets/fullscreen/info/metadata/metadata_thumbnail.dart index f47024f2a..7072ac53e 100644 --- a/lib/widgets/fullscreen/info/metadata/metadata_thumbnail.dart +++ b/lib/widgets/fullscreen/info/metadata/metadata_thumbnail.dart @@ -5,7 +5,7 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/services/metadata_service.dart'; import 'package:flutter/material.dart'; -enum MetadataThumbnailSource { embedded, exif, xmp } +enum MetadataThumbnailSource { embedded, exif } class MetadataThumbnails extends StatefulWidget { final MetadataThumbnailSource source; @@ -38,9 +38,6 @@ class _MetadataThumbnailsState extends State { case MetadataThumbnailSource.exif: _loader = MetadataService.getExifThumbnails(entry); break; - case MetadataThumbnailSource.xmp: - _loader = MetadataService.getXmpThumbnails(entry); - break; } } diff --git a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart index cfcb92ecc..de15d6b32 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart @@ -7,38 +7,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -class XmpProp { - final String path, value; - final String displayKey; - - static final sentenceCaseStep1 = RegExp(r'([A-Z][a-z]|\[)'); - static final sentenceCaseStep2 = RegExp(r'([a-z])([A-Z])'); - - XmpProp(this.path, this.value) : displayKey = formatKey(path); - - bool get isOpenable => XMP.dataProps.contains(path); - - static String formatKey(String propPath) { - return propPath.splitMapJoin(XMP.structFieldSeparator, - onMatch: (match) => ' ${match.group(0)} ', - onNonMatch: (s) { - // strip namespace - var key = s.split(XMP.propNamespaceSeparator).last; - - // uppercase first letter - key = key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); - - // sentence case - return key.replaceAllMapped(sentenceCaseStep1, (m) => ' ${m.group(1)}').replaceAllMapped(sentenceCaseStep2, (m) => '${m.group(1)} ${m.group(2)}').trim(); - }); - } - - @override - String toString() { - return '$runtimeType#${shortHash(this)}{path=$path, value=$value}'; - } -} - class XmpNamespace { final String namespace; @@ -48,22 +16,11 @@ class XmpNamespace { List buildNamespaceSection({ @required List> rawProps, - @required void Function(String propPath) openEmbeddedData, }) { - final linkHandlers = {}; - final props = rawProps .map((kv) { final prop = XmpProp(kv.key, kv.value); - if (extractData(prop)) return null; - - if (prop.isOpenable) { - linkHandlers.putIfAbsent( - prop.displayKey, - () => InfoLinkHandler(linkText: 'Open', onTap: () => openEmbeddedData(prop.path)), - ); - } - return prop; + return extractData(prop) ? null : prop; }) .where((e) => e != null) .toList() @@ -74,7 +31,7 @@ class XmpNamespace { InfoRowGroup( Map.fromEntries(props.map((prop) => MapEntry(prop.displayKey, formatValue(prop)))), maxValueLength: Constants.infoGroupMaxValueLength, - linkHandlers: linkHandlers, + linkHandlers: linkifyValues(props), ), ...buildFromExtractedData(), ]; @@ -123,6 +80,8 @@ class XmpNamespace { String formatValue(XmpProp prop) => prop.value; + Map linkifyValues(List props) => null; + // identity @override @@ -139,3 +98,48 @@ class XmpNamespace { return '$runtimeType#${shortHash(this)}{namespace=$namespace}'; } } + +class XmpProp { + final String path, value; + final String displayKey; + + static final sentenceCaseStep1 = RegExp(r'([A-Z][a-z]|\[)'); + static final sentenceCaseStep2 = RegExp(r'([a-z])([A-Z])'); + + XmpProp(this.path, this.value) : displayKey = formatKey(path); + + static String formatKey(String propPath) { + return propPath.splitMapJoin(XMP.structFieldSeparator, + onMatch: (match) => ' ${match.group(0)} ', + onNonMatch: (s) { + // strip namespace + var key = s.split(XMP.propNamespaceSeparator).last; + + // uppercase first letter + key = key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); + + // sentence case + return key.replaceAllMapped(sentenceCaseStep1, (m) => ' ${m.group(1)}').replaceAllMapped(sentenceCaseStep2, (m) => '${m.group(1)} ${m.group(2)}').trim(); + }); + } + + @override + String toString() { + return '$runtimeType#${shortHash(this)}{path=$path, value=$value}'; + } +} + +class OpenEmbeddedDataNotification extends Notification { + final String propPath; + final String mimeType; + + const OpenEmbeddedDataNotification({ + @required this.propPath, + @required this.mimeType, + }); + + @override + String toString() { + return '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}'; + } +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart index a4c17f069..168401ce5 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart @@ -7,6 +7,9 @@ class XmpExifNamespace extends XmpNamespace { XmpExifNamespace() : super(ns); + @override + String get displayTitle => 'Exif'; + @override String formatValue(XmpProp prop) { final v = prop.value; diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/google.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/google.dart new file mode 100644 index 000000000..ee9154591 --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/google.dart @@ -0,0 +1,69 @@ +import 'package:aves/widgets/fullscreen/info/common.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; +import 'package:tuple/tuple.dart'; + +abstract class XmpGoogleNamespace extends XmpNamespace { + XmpGoogleNamespace(String ns) : super(ns); + + List> get dataProps; + + @override + Map linkifyValues(List props) { + return Map.fromEntries(dataProps.map((t) { + final dataPropPath = t.item1; + final mimePropPath = t.item2; + final dataProp = props.firstWhere((prop) => prop.path == dataPropPath, orElse: () => null); + final mimeProp = props.firstWhere((prop) => prop.path == mimePropPath, orElse: () => null); + return (dataProp != null && mimeProp != null) + ? MapEntry( + dataProp.displayKey, + InfoLinkHandler( + linkText: 'Open', + onTap: (context) => OpenEmbeddedDataNotification( + propPath: dataProp.path, + mimeType: mimeProp.value, + ).dispatch(context), + )) + : null; + }).where((e) => e != null)); + } +} + +class XmpGAudioNamespace extends XmpGoogleNamespace { + static const ns = 'GAudio'; + + XmpGAudioNamespace() : super(ns); + + @override + List> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')]; + + @override + String get displayTitle => 'Google Audio'; +} + +class XmpGDepthNamespace extends XmpGoogleNamespace { + static const ns = 'GDepth'; + + XmpGDepthNamespace() : super(ns); + + @override + List> get dataProps => [ + Tuple2('$ns:Data', '$ns:Mime'), + Tuple2('$ns:Confidence', '$ns:ConfidenceMime'), + ]; + + @override + String get displayTitle => 'Google Depth'; +} + +class XmpGImageNamespace extends XmpGoogleNamespace { + static const ns = 'GImage'; + + XmpGImageNamespace() : super(ns); + + @override + List> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')]; + + @override + String get displayTitle => 'Google Image'; +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart index a6654e3e5..5876ce30c 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart @@ -11,6 +11,9 @@ class XmpIptcCoreNamespace extends XmpNamespace { XmpIptcCoreNamespace() : super(ns); + @override + String get displayTitle => 'IPTC Core'; + @override bool extractData(XmpProp prop) => extractStruct(prop, creatorContactInfoPattern, creatorContactInfo); diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart index d659bd627..b8241caa9 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart @@ -7,6 +7,9 @@ class XmpPhotoshopNamespace extends XmpNamespace { XmpPhotoshopNamespace() : super(ns); + @override + String get displayTitle => 'Photoshop'; + @override String formatValue(XmpProp prop) { final value = prop.value; diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart index 38e6f0937..6083d5a39 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart @@ -5,6 +5,9 @@ import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; class XmpTiffNamespace extends XmpNamespace { static const ns = 'tiff'; + @override + String get displayTitle => 'TIFF'; + XmpTiffNamespace() : super(ns); @override diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart index 36376e1ae..1a5dd4f28 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart @@ -1,3 +1,5 @@ +import 'package:aves/ref/mime_types.dart'; +import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_structs.dart'; import 'package:flutter/material.dart'; @@ -6,11 +8,15 @@ class XmpBasicNamespace extends XmpNamespace { static const ns = 'xmp'; static final thumbnailsPattern = RegExp(r'xmp:Thumbnails\[(\d+)\]/(.*)'); + static const thumbnailDataDisplayKey = 'Image'; final thumbnails = >{}; XmpBasicNamespace() : super(ns); + @override + String get displayTitle => 'Basic'; + @override bool extractData(XmpProp prop) => extractIndexedStruct(prop, thumbnailsPattern, thumbnails); @@ -20,6 +26,19 @@ class XmpBasicNamespace extends XmpNamespace { XmpStructArrayCard( title: 'Thumbnail', structByIndex: thumbnails, + linkifier: (index) { + final struct = thumbnails[index]; + return { + if (struct.containsKey(thumbnailDataDisplayKey)) + thumbnailDataDisplayKey: InfoLinkHandler( + linkText: 'Open', + onTap: (context) => OpenEmbeddedDataNotification( + propPath: 'xmp:Thumbnails[$index]/xmpGImg:image', + mimeType: MimeTypes.jpeg, + ).dispatch(context), + ), + }; + }, ) ]; } @@ -39,7 +58,14 @@ class XmpMMNamespace extends XmpNamespace { XmpMMNamespace() : super(ns); @override - bool extractData(XmpProp prop) => extractStruct(prop, derivedFromPattern, derivedFrom) || extractIndexedStruct(prop, historyPattern, history); + String get displayTitle => 'Media Management'; + + @override + bool extractData(XmpProp prop) { + final hasStructs = extractStruct(prop, derivedFromPattern, derivedFrom); + final hasIndexedStructs = extractIndexedStruct(prop, historyPattern, history); + return hasStructs || hasIndexedStructs; + } @override List buildFromExtractedData() => [ diff --git a/lib/widgets/fullscreen/info/metadata/xmp_structs.dart b/lib/widgets/fullscreen/info/metadata/xmp_structs.dart index e45e6312b..836dab6ec 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_structs.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_structs.dart @@ -11,10 +11,12 @@ import 'package:flutter/material.dart'; class XmpStructArrayCard extends StatefulWidget { final String title; final List> structs = []; + final Map Function(int index) linkifier; XmpStructArrayCard({ @required this.title, @required Map> structByIndex, + this.linkifier, }) { structs.length = structByIndex.keys.fold(0, max); structByIndex.keys.forEach((index) => structs[index - 1] = structByIndex[index]); @@ -89,6 +91,7 @@ class _XmpStructArrayCardState extends State { child: InfoRowGroup( structs[_index], maxValueLength: Constants.infoGroupMaxValueLength, + linkHandlers: widget.linkifier?.call(_index + 1), ), ), ), @@ -101,12 +104,14 @@ class _XmpStructArrayCardState extends State { class XmpStructCard extends StatelessWidget { final String title; final Map struct; + final Map Function() linkifier; static const cardMargin = EdgeInsets.symmetric(vertical: 8, horizontal: 0); const XmpStructCard({ @required this.title, @required this.struct, + this.linkifier, }); @override @@ -126,6 +131,7 @@ class XmpStructCard extends StatelessWidget { InfoRowGroup( struct, maxValueLength: Constants.infoGroupMaxValueLength, + linkHandlers: linkifier?.call(), ), ], ), diff --git a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart index 80cef89e9..8a7338b2c 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart @@ -10,9 +10,9 @@ import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/exif.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/google.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart'; @@ -41,7 +41,6 @@ class _XmpDirTileState extends State with FeedbackMixin { @override Widget build(BuildContext context) { - final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry); final sections = SplayTreeMap>>.of( groupBy(widget.tags.entries, (kv) { final fullKey = kv.key; @@ -52,6 +51,12 @@ class _XmpDirTileState extends State with FeedbackMixin { return XmpBasicNamespace(); case XmpExifNamespace.ns: return XmpExifNamespace(); + case XmpGAudioNamespace.ns: + return XmpGAudioNamespace(); + case XmpGDepthNamespace.ns: + return XmpGDepthNamespace(); + case XmpGImageNamespace.ns: + return XmpGImageNamespace(); case XmpIptcCoreNamespace.ns: return XmpIptcCoreNamespace(); case XmpMMNamespace.ns: @@ -72,25 +77,29 @@ class _XmpDirTileState extends State with FeedbackMixin { title: 'XMP', expandedNotifier: widget.expandedNotifier, children: [ - if (thumbnail != null) thumbnail, - Padding( - padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: sections.entries - .expand((kv) => kv.key.buildNamespaceSection( - rawProps: kv.value, - openEmbeddedData: _openEmbeddedData, - )) - .toList(), + NotificationListener( + onNotification: (notification) { + _openEmbeddedData(notification.propPath, notification.mimeType); + return true; + }, + child: Padding( + padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: sections.entries + .expand((kv) => kv.key.buildNamespaceSection( + rawProps: kv.value, + )) + .toList(), + ), ), ), ], ); } - Future _openEmbeddedData(String propPath) async { - final fields = await MetadataService.extractXmpDataProp(entry, propPath); + Future _openEmbeddedData(String propPath, String propMimeType) async { + final fields = await MetadataService.extractXmpDataProp(entry, propPath, propMimeType); if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) { showFeedback(context, 'Failed'); return; From d40f32b11b3ff3a50231a59a0397fa035b296b2f Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 9 Dec 2020 11:39:56 +0900 Subject: [PATCH 18/27] viewer: open panorama --- lib/utils/constants.dart | 6 ++ lib/widgets/common/fx/blurred.dart | 8 +- lib/widgets/common/fx/borders.dart | 17 ++- lib/widgets/fullscreen/fullscreen_body.dart | 30 ++++-- lib/widgets/fullscreen/overlay/bottom.dart | 32 ++++++ lib/widgets/fullscreen/overlay/common.dart | 49 ++++++++- lib/widgets/fullscreen/overlay/panorama.dart | 37 +++++++ lib/widgets/fullscreen/overlay/video.dart | 104 ++++++++----------- lib/widgets/fullscreen/panorama_page.dart | 30 ++++++ pubspec.lock | 14 +++ pubspec.yaml | 18 ++-- 11 files changed, 253 insertions(+), 92 deletions(-) create mode 100644 lib/widgets/fullscreen/overlay/panorama.dart create mode 100644 lib/widgets/fullscreen/panorama_page.dart diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index cf0c59894..6304c3ec1 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -209,6 +209,12 @@ class Constants { licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/palette_generator/LICENSE', sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/palette_generator', ), + Dependency( + name: 'Panorama', + license: 'Apache 2.0', + licenseUrl: 'https://github.com/zesage/panorama/blob/master/LICENSE', + sourceUrl: 'https://github.com/zesage/panorama', + ), Dependency( name: 'PDF for Dart and Flutter', license: 'Apache 2.0', diff --git a/lib/widgets/common/fx/blurred.dart b/lib/widgets/common/fx/blurred.dart index e21af0e29..a3f606ede 100644 --- a/lib/widgets/common/fx/blurred.dart +++ b/lib/widgets/common/fx/blurred.dart @@ -2,6 +2,8 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +final _filter = ImageFilter.blur(sigmaX: 4, sigmaY: 4); + class BlurredRect extends StatelessWidget { final Widget child; @@ -11,7 +13,7 @@ class BlurredRect extends StatelessWidget { Widget build(BuildContext context) { return ClipRect( child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4), + filter: _filter, child: child, ), ); @@ -29,7 +31,7 @@ class BlurredRRect extends StatelessWidget { return ClipRRect( borderRadius: BorderRadius.circular(borderRadius), child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4), + filter: _filter, child: child, ), ); @@ -45,7 +47,7 @@ class BlurredOval extends StatelessWidget { Widget build(BuildContext context) { return ClipOval( child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4), + filter: _filter, child: child, ), ); diff --git a/lib/widgets/common/fx/borders.dart b/lib/widgets/common/fx/borders.dart index 92c38bf78..3c02d47f2 100644 --- a/lib/widgets/common/fx/borders.dart +++ b/lib/widgets/common/fx/borders.dart @@ -1,11 +1,18 @@ import 'package:flutter/material.dart'; class AvesCircleBorder { - static BoxBorder build(BuildContext context) { - final subPixel = MediaQuery.of(context).devicePixelRatio > 2; - return Border.all( - color: Colors.white30, - width: subPixel ? 0.5 : 1.0, + static const borderColor = Colors.white30; + + static double _borderWidth(BuildContext context) => MediaQuery.of(context).devicePixelRatio > 2 ? 0.5 : 1.0; + + static Border build(BuildContext context) { + return Border.fromBorderSide(buildSide(context)); + } + + static BorderSide buildSide(BuildContext context) { + return BorderSide( + color: borderColor, + width: _borderWidth(context), ); } } diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index 3fce014bf..1363a2ec2 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -5,8 +5,8 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/utils/change_notifier.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/change_notifier.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/fullscreen/entry_action_delegate.dart'; import 'package:aves/widgets/fullscreen/image_page.dart'; @@ -14,6 +14,7 @@ import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/info/notifications.dart'; import 'package:aves/widgets/fullscreen/overlay/bottom.dart'; +import 'package:aves/widgets/fullscreen/overlay/panorama.dart'; import 'package:aves/widgets/fullscreen/overlay/top.dart'; import 'package:aves/widgets/fullscreen/overlay/video.dart'; import 'package:flutter/foundation.dart'; @@ -223,22 +224,33 @@ class FullscreenBodyState extends State with SingleTickerProvide Widget bottomOverlay = ValueListenableBuilder( valueListenable: _entryNotifier, builder: (context, entry, child) { - Widget videoOverlay; - if (entry != null) { - final videoController = entry.isVideo ? _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2 : null; + if (entry == null) return SizedBox.shrink(); + + Widget extraBottomOverlay; + if (entry.isVideo) { + final videoController = _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2; if (videoController != null) { - videoOverlay = VideoControlOverlay( + extraBottomOverlay = VideoControlOverlay( entry: entry, controller: videoController, scale: _bottomOverlayScale, - viewInsets: _frozenViewInsets, - viewPadding: _frozenViewPadding, ); } + } else if (entry.is360) { + extraBottomOverlay = PanoramaOverlay( + entry: entry, + scale: _bottomOverlayScale, + ); } + final child = Column( children: [ - if (videoOverlay != null) videoOverlay, + if (extraBottomOverlay != null) + ExtraBottomOverlay( + viewInsets: _frozenViewInsets, + viewPadding: _frozenViewPadding, + child: extraBottomOverlay, + ), SlideTransition( position: _bottomOverlayOffset, child: FullscreenBottomOverlay( @@ -255,7 +267,7 @@ class FullscreenBodyState extends State with SingleTickerProvide valueListenable: _overlayAnimationController, builder: (context, animation, child) { return Visibility( - visible: entry != null && _overlayAnimationController.status != AnimationStatus.dismissed, + visible: _overlayAnimationController.status != AnimationStatus.dismissed, child: child, ); }, diff --git a/lib/widgets/fullscreen/overlay/bottom.dart b/lib/widgets/fullscreen/overlay/bottom.dart index b2d08980f..577c2ad43 100644 --- a/lib/widgets/fullscreen/overlay/bottom.dart +++ b/lib/widgets/fullscreen/overlay/bottom.dart @@ -303,3 +303,35 @@ class _ShootingRow extends StatelessWidget { ); } } + +class ExtraBottomOverlay extends StatelessWidget { + final EdgeInsets viewInsets, viewPadding; + final Widget child; + + const ExtraBottomOverlay({ + Key key, + this.viewInsets, + this.viewPadding, + @required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final mq = context.select>((mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding)); + final mqWidth = mq.item1; + final mqViewInsets = mq.item2; + final mqViewPadding = mq.item3; + + final viewInsets = this.viewInsets ?? mqViewInsets; + final viewPadding = this.viewPadding ?? mqViewPadding; + final safePadding = (viewInsets + viewPadding).copyWith(bottom: 8) + EdgeInsets.symmetric(horizontal: 8.0); + + return Padding( + padding: safePadding, + child: SizedBox( + width: mqWidth - safePadding.horizontal, + child: child, + ), + ); + } +} diff --git a/lib/widgets/fullscreen/overlay/common.dart b/lib/widgets/fullscreen/overlay/common.dart index 9c7183bee..23ad79666 100644 --- a/lib/widgets/fullscreen/overlay/common.dart +++ b/lib/widgets/fullscreen/overlay/common.dart @@ -8,7 +8,12 @@ class OverlayButton extends StatelessWidget { final Animation scale; final Widget child; - const OverlayButton({Key key, this.scale, this.child}) : super(key: key); + const OverlayButton({ + Key key, + @required this.scale, + @required this.child, + }) : assert(scale != null), + super(key: key); @override Widget build(BuildContext context) { @@ -30,3 +35,45 @@ class OverlayButton extends StatelessWidget { ); } } + +class OverlayTextButton extends StatelessWidget { + final Animation scale; + final String text; + final VoidCallback onPressed; + + const OverlayTextButton({ + Key key, + @required this.scale, + @required this.text, + this.onPressed, + }) : assert(scale != null), + super(key: key); + + static const _borderRadius = 123.0; + static final _minSize = MaterialStateProperty.all(Size(kMinInteractiveDimension, kMinInteractiveDimension)); + + @override + Widget build(BuildContext context) { + return SizeTransition( + sizeFactor: scale, + child: BlurredRRect( + borderRadius: _borderRadius, + child: OutlinedButton( + onPressed: onPressed, + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all(kOverlayBackgroundColor), + foregroundColor: MaterialStateProperty.all(Colors.white), + overlayColor: MaterialStateProperty.all(Colors.white.withOpacity(0.12)), + minimumSize: _minSize, + side: MaterialStateProperty.all(AvesCircleBorder.buildSide(context)), + shape: MaterialStateProperty.all(RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(_borderRadius)), + )), + // shape: MaterialStateProperty.all(CircleBorder()), + ), + child: Text(text.toUpperCase()), + ), + ), + ); + } +} diff --git a/lib/widgets/fullscreen/overlay/panorama.dart b/lib/widgets/fullscreen/overlay/panorama.dart new file mode 100644 index 000000000..386752e5f --- /dev/null +++ b/lib/widgets/fullscreen/overlay/panorama.dart @@ -0,0 +1,37 @@ +import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/fullscreen/overlay/common.dart'; +import 'package:aves/widgets/fullscreen/panorama_page.dart'; +import 'package:flutter/material.dart'; + +class PanoramaOverlay extends StatelessWidget { + final ImageEntry entry; + final Animation scale; + + const PanoramaOverlay({ + Key key, + @required this.entry, + @required this.scale, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Spacer(), + OverlayTextButton( + scale: scale, + text: 'Open Panorama', + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: PanoramaPage.routeName), + builder: (context) => PanoramaPage(entry: entry), + ), + ); + }, + ) + ], + ); + } +} diff --git a/lib/widgets/fullscreen/overlay/video.dart b/lib/widgets/fullscreen/overlay/video.dart index 47e7a69b8..1e0b20dbf 100644 --- a/lib/widgets/fullscreen/overlay/video.dart +++ b/lib/widgets/fullscreen/overlay/video.dart @@ -10,22 +10,17 @@ import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/fullscreen/overlay/common.dart'; import 'package:flutter/material.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; -import 'package:provider/provider.dart'; -import 'package:tuple/tuple.dart'; class VideoControlOverlay extends StatefulWidget { final ImageEntry entry; - final Animation scale; final IjkMediaController controller; - final EdgeInsets viewInsets, viewPadding; + final Animation scale; const VideoControlOverlay({ Key key, @required this.entry, @required this.controller, @required this.scale, - this.viewInsets, - this.viewPadding, }) : super(key: key); @override @@ -99,63 +94,48 @@ class VideoControlOverlayState extends State with SingleTic @override Widget build(BuildContext context) { - final mq = context.select>((mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding)); - final mqWidth = mq.item1; - final mqViewInsets = mq.item2; - final mqViewPadding = mq.item3; - - final viewInsets = widget.viewInsets ?? mqViewInsets; - final viewPadding = widget.viewPadding ?? mqViewPadding; - final safePadding = (viewInsets + viewPadding).copyWith(bottom: 8) + EdgeInsets.symmetric(horizontal: 8.0); - - return Padding( - padding: safePadding, - child: SizedBox( - width: mqWidth - safePadding.horizontal, - child: StreamBuilder( - stream: controller.ijkStatusStream, - builder: (context, snapshot) { - // do not use stream snapshot because it is obsolete when switching between videos - final status = controller.ijkStatus; - return TooltipTheme( - data: TooltipTheme.of(context).copyWith( - preferBelow: false, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: status == IjkStatus.error - ? [ - OverlayButton( - scale: scale, - child: IconButton( - icon: Icon(AIcons.openInNew), - onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype), - tooltip: 'Open', - ), + return StreamBuilder( + stream: controller.ijkStatusStream, + builder: (context, snapshot) { + // do not use stream snapshot because it is obsolete when switching between videos + final status = controller.ijkStatus; + return TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: status == IjkStatus.error + ? [ + OverlayButton( + scale: scale, + child: IconButton( + icon: Icon(AIcons.openInNew), + onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype), + tooltip: 'Open', + ), + ), + ] + : [ + Expanded( + child: _buildProgressBar(), + ), + SizedBox(width: 8), + OverlayButton( + scale: scale, + child: IconButton( + icon: AnimatedIcon( + icon: AnimatedIcons.play_pause, + progress: _playPauseAnimation, ), - ] - : [ - Expanded( - child: _buildProgressBar(), - ), - SizedBox(width: 8), - OverlayButton( - scale: scale, - child: IconButton( - icon: AnimatedIcon( - icon: AnimatedIcons.play_pause, - progress: _playPauseAnimation, - ), - onPressed: _playPause, - tooltip: isPlaying ? 'Pause' : 'Play', - ), - ), - ], - ), - ); - }), - ), - ); + onPressed: _playPause, + tooltip: isPlaying ? 'Pause' : 'Play', + ), + ), + ], + ), + ); + }); } Widget _buildProgressBar() { diff --git a/lib/widgets/fullscreen/panorama_page.dart b/lib/widgets/fullscreen/panorama_page.dart new file mode 100644 index 000000000..17fa4fac5 --- /dev/null +++ b/lib/widgets/fullscreen/panorama_page.dart @@ -0,0 +1,30 @@ +import 'package:aves/image_providers/uri_image_provider.dart'; +import 'package:aves/model/image_entry.dart'; +import 'package:flutter/material.dart'; +import 'package:panorama/panorama.dart'; + +class PanoramaPage extends StatelessWidget { + static const routeName = '/fullscreen/panorama'; + + final ImageEntry entry; + + const PanoramaPage({@required this.entry}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Panorama( + child: Image( + image: UriImage( + uri: entry.uri, + mimeType: entry.mimeType, + rotationDegrees: entry.rotationDegrees, + isFlipped: entry.isFlipped, + expectedContentLength: entry.sizeBytes, + ), + ), + ), + resizeToAvoidBottomInset: false, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 32888931d..3857dc12b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -283,6 +283,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" + flutter_cube: + dependency: transitive + description: + name: flutter_cube + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.6" flutter_driver: dependency: "direct dev" description: flutter @@ -585,6 +592,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.3" + panorama: + dependency: "direct main" + description: + name: panorama + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.2" path: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4dffbafbc..088af199f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,26 +1,19 @@ name: aves description: Aves is a gallery and metadata explorer app, built for Android. -# The following line prevents the package from being accidentally published to -# pub.dev using `pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html version: 1.2.8+34 # brendan-duncan/image (as of v2.1.19): # - does not support TIFF with JPEG compression (issue #184) # - TIFF tile decoding is not public (issue #258) +# dnfield/flutter_svg (as of v0.19.1): +# - `Could not parse "currentColor" as a color`: https://github.com/dnfield/flutter_svg/issues/31 +# - no