diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index b86b2d636..3cf9babea 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -109,7 +109,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { when (call.method) { "getAllMetadata" -> ioScope.launch { safe(call, result, ::getAllMetadata) } "getCatalogMetadata" -> ioScope.launch { safe(call, result, ::getCatalogMetadata) } - "getOverlayMetadata" -> ioScope.launch { safe(call, result, ::getOverlayMetadata) } + "getFields" -> ioScope.launch { safe(call, result, ::getFields) } "getGeoTiffInfo" -> ioScope.launch { safe(call, result, ::getGeoTiffInfo) } "getMultiPageInfo" -> ioScope.launch { safe(call, result, ::getMultiPageInfo) } "getPanoramaInfo" -> ioScope.launch { safe(call, result, ::getPanoramaInfo) } @@ -118,7 +118,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { "hasContentResolverProp" -> ioScope.launch { safe(call, result, ::hasContentProp) } "getContentResolverProp" -> ioScope.launch { safe(call, result, ::getContentPropValue) } "getDate" -> ioScope.launch { safe(call, result, ::getDate) } - "getDescription" -> ioScope.launch { safe(call, result, ::getDescription) } else -> result.notImplemented() } } @@ -807,17 +806,18 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } } - private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) { + private fun getFields(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) { + val fields = call.argument>("fields") + if (mimeType == null || uri == null || fields == null) { result.error("getOverlayMetadata-args", "missing arguments", null) return } val metadataMap = HashMap() - if (isVideo(mimeType)) { + if (fields.isEmpty() || isVideo(mimeType)) { result.success(metadataMap) return } @@ -842,10 +842,21 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { val metadata = Helper.safeRead(input) for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { foundExif = true - dir.getSafeRational(ExifDirectoryBase.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator } - dir.getSafeRational(ExifDirectoryBase.TAG_EXPOSURE_TIME, saveExposureTime) - dir.getSafeRational(ExifDirectoryBase.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator } - dir.getSafeInt(ExifDirectoryBase.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it } + if (fields.contains(KEY_APERTURE)) { + dir.getSafeRational(ExifDirectoryBase.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator } + } + if (fields.contains(KEY_DESCRIPTION)) { + getDescriptionByMetadataExtractor(metadata)?.let { metadataMap[KEY_DESCRIPTION] = it } + } + if (fields.contains(KEY_EXPOSURE_TIME)) { + dir.getSafeRational(ExifDirectoryBase.TAG_EXPOSURE_TIME, saveExposureTime) + } + if (fields.contains(KEY_FOCAL_LENGTH)) { + dir.getSafeRational(ExifDirectoryBase.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator } + } + if (fields.contains(KEY_ISO)) { + dir.getSafeInt(ExifDirectoryBase.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it } + } } } } catch (e: Exception) { @@ -862,10 +873,18 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val exif = ExifInterface(input) - exif.getSafeDouble(ExifInterface.TAG_F_NUMBER) { metadataMap[KEY_APERTURE] = it } - exif.getSafeRational(ExifInterface.TAG_EXPOSURE_TIME, saveExposureTime) - exif.getSafeDouble(ExifInterface.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it } - exif.getSafeInt(ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY) { metadataMap[KEY_ISO] = it } + if (fields.contains(KEY_APERTURE)) { + exif.getSafeDouble(ExifInterface.TAG_F_NUMBER) { metadataMap[KEY_APERTURE] = it } + } + if (fields.contains(KEY_EXPOSURE_TIME)) { + exif.getSafeRational(ExifInterface.TAG_EXPOSURE_TIME, saveExposureTime) + } + if (fields.contains(KEY_FOCAL_LENGTH)) { + exif.getSafeDouble(ExifInterface.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it } + } + if (fields.contains(KEY_ISO)) { + exif.getSafeInt(ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY) { metadataMap[KEY_ISO] = it } + } } } catch (e: Exception) { // ExifInterface initialization can fail with a RuntimeException @@ -877,6 +896,47 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { result.success(metadataMap) } + // return description from these fields (by precedence): + // - XMP / dc:description + // - IPTC / caption-abstract + // - Exif / UserComment + // - Exif / ImageDescription + private fun getDescriptionByMetadataExtractor(metadata: com.drew.metadata.Metadata): String? { + var description: String? = null + for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { + val xmpMeta = dir.xmpMeta + try { + if (xmpMeta.doesPropExist(XMP.DC_DESCRIPTION_PROP_NAME)) { + xmpMeta.getSafeLocalizedText(XMP.DC_DESCRIPTION_PROP_NAME, acceptBlank = false) { description = it } + } + } catch (e: XMPException) { + Log.w(LOG_TAG, "failed to read XMP directory", e) + } + } + if (description == null) { + for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) { + dir.getSafeString(IptcDirectory.TAG_CAPTION, acceptBlank = false) { description = it } + } + } + if (description == null) { + for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { + // user comment field specifies encoding, unlike other string fields + if (dir.containsTag(ExifSubIFDDirectory.TAG_USER_COMMENT)) { + val string = dir.getDescription(ExifSubIFDDirectory.TAG_USER_COMMENT) + if (string.isNotBlank()) { + description = string + } + } + } + } + if (description == null) { + for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) { + dir.getSafeString(ExifIFD0Directory.TAG_IMAGE_DESCRIPTION, acceptBlank = false) { description = it } + } + } + return description + } + private fun getGeoTiffInfo(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } @@ -1191,70 +1251,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { result.success(dateMillis) } - // return description from these fields (by precedence): - // - XMP / dc:description - // - IPTC / caption-abstract - // - Exif / UserComment - // - Exif / ImageDescription - private fun getDescription(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("getDescription-args", "missing arguments", null) - return - } - - var description: String? = null - if (canReadWithMetadataExtractor(mimeType)) { - try { - Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> - val metadata = Helper.safeRead(input) - - for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { - val xmpMeta = dir.xmpMeta - try { - if (xmpMeta.doesPropExist(XMP.DC_DESCRIPTION_PROP_NAME)) { - xmpMeta.getSafeLocalizedText(XMP.DC_DESCRIPTION_PROP_NAME, acceptBlank = false) { description = it } - } - } catch (e: XMPException) { - Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) - } - } - if (description == null) { - for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) { - dir.getSafeString(IptcDirectory.TAG_CAPTION, acceptBlank = false) { description = it } - } - } - if (description == null) { - for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { - // user comment field specifies encoding, unlike other string fields - if (dir.containsTag(ExifSubIFDDirectory.TAG_USER_COMMENT)) { - val string = dir.getDescription(ExifSubIFDDirectory.TAG_USER_COMMENT) - if (string.isNotBlank()) { - description = string - } - } - } - } - if (description == null) { - for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) { - dir.getSafeString(ExifIFD0Directory.TAG_IMAGE_DESCRIPTION, acceptBlank = false) { description = it } - } - } - } - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e) - } catch (e: NoClassDefFoundError) { - Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e) - } catch (e: AssertionError) { - Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e) - } - } - - result.success(description) - } - companion object { private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/metadata_fetch" @@ -1319,6 +1315,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { // overlay metadata private const val KEY_APERTURE = "aperture" + private const val KEY_DESCRIPTION = "description" private const val KEY_EXPOSURE_TIME = "exposureTime" private const val KEY_FOCAL_LENGTH = "focalLength" private const val KEY_ISO = "iso" diff --git a/android/build.gradle b/android/build.gradle index 0dfb0c5b9..3d2b26752 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -2,7 +2,7 @@ buildscript { ext { kotlin_version = '1.9.21' ksp_version = "$kotlin_version-1.0.15" - agp_version = '8.2.1' + agp_version = '8.2.2' glide_version = '4.16.0' // AppGallery Connect plugin versions: https://developer.huawei.com/consumer/en/doc/development/AppGallery-connect-Guides/agc-sdk-changenotes-0000001058732550 huawei_agconnect_version = '1.9.1.300' diff --git a/lib/convert/metadata/fields.dart b/lib/convert/metadata/fields.dart index fd677c105..c19f0f91b 100644 --- a/lib/convert/metadata/fields.dart +++ b/lib/convert/metadata/fields.dart @@ -1,5 +1,9 @@ import 'package:aves_model/aves_model.dart'; +extension ExtraMetadataSyntheticFieldConvert on MetadataSyntheticField { + String? get toPlatform => name; +} + extension ExtraMetadataFieldConvert on MetadataField { MetadataType get type { switch (this) { diff --git a/lib/model/metadata/overlay.dart b/lib/model/metadata/overlay.dart index 1858642c1..55e145fd1 100644 --- a/lib/model/metadata/overlay.dart +++ b/lib/model/metadata/overlay.dart @@ -4,18 +4,17 @@ import 'package:flutter/foundation.dart'; @immutable class OverlayMetadata extends Equatable { final double? aperture, focalLength; - final String? exposureTime; + final String? description, exposureTime; final int? iso; @override - List get props => [aperture, exposureTime, focalLength, iso]; + List get props => [aperture, description, exposureTime, focalLength, iso]; - bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null; - - bool get isNotEmpty => !isEmpty; + bool get hasShootingDetails => aperture != null || exposureTime != null || focalLength != null || iso != null; const OverlayMetadata({ this.aperture, + this.description, this.exposureTime, this.focalLength, this.iso, @@ -24,6 +23,7 @@ class OverlayMetadata extends Equatable { factory OverlayMetadata.fromMap(Map map) { return OverlayMetadata( aperture: map['aperture'] as double?, + description: map['description'] as String?, exposureTime: map['exposureTime'] as String?, focalLength: map['focalLength'] as double?, iso: map['iso'] as int?, diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index bbd5d7b30..a08deb667 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -22,7 +22,7 @@ abstract class MetadataFetchService { Future getCatalogMetadata(AvesEntry entry, {bool background = false}); - Future getOverlayMetadata(AvesEntry entry); + Future getFields(AvesEntry entry, Set fields); Future getGeoTiffInfo(AvesEntry entry); @@ -39,8 +39,6 @@ abstract class MetadataFetchService { Future getContentResolverProp(AvesEntry entry, String prop); Future getDate(AvesEntry entry, MetadataField field); - - Future getDescription(AvesEntry entry); } class PlatformMetadataFetchService implements MetadataFetchService { @@ -112,23 +110,29 @@ class PlatformMetadataFetchService implements MetadataFetchService { } @override - Future getOverlayMetadata(AvesEntry entry) async { - if (entry.isSvg) return null; - - try { - // returns map with values for: 'aperture' (double), 'exposureTime' (description), 'focalLength' (double), 'iso' (int) - 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, stack) { - if (entry.isValid) { - await reportService.recordError(e, stack); + Future getFields(AvesEntry entry, Set fields) async { + if (fields.isNotEmpty && !entry.isSvg) { + try { + // returns fields on demand, with various value types: + // 'aperture' (double), + // 'description' (string) + // 'exposureTime' (string), + // 'focalLength' (double), + // 'iso' (int), + final result = await _platform.invokeMethod('getFields', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + 'fields': fields.map((v) => v.toPlatform).toList(), + }) as Map; + return OverlayMetadata.fromMap(result); + } on PlatformException catch (e, stack) { + if (entry.isValid) { + await reportService.recordError(e, stack); + } } } - return null; + return const OverlayMetadata(); } @override @@ -280,20 +284,4 @@ class PlatformMetadataFetchService implements MetadataFetchService { } return null; } - - @override - Future getDescription(AvesEntry entry) async { - try { - return await _platform.invokeMethod('getDescription', { - 'mimeType': entry.mimeType, - 'uri': entry.uri, - 'sizeBytes': entry.sizeBytes, - }); - } on PlatformException catch (e, stack) { - if (entry.isValid) { - await reportService.recordError(e, stack); - } - } - return null; - } } diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart index 32656ee08..4d2dc495f 100644 --- a/lib/widgets/common/action_mixins/entry_editor.dart +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -55,7 +55,8 @@ mixin EntryEditorMixin { final entry = entries.first; final initialTitle = entry.catalogMetadata?.xmpTitle ?? ''; - final initialDescription = await metadataFetchService.getDescription(entry) ?? ''; + final fields = await metadataFetchService.getFields(entry, {MetadataSyntheticField.description}); + final initialDescription = fields.description ?? ''; return showDialog>( context: context, diff --git a/lib/widgets/viewer/overlay/details/details.dart b/lib/widgets/viewer/overlay/details/details.dart index b6a6900e4..18f6e8ce7 100644 --- a/lib/widgets/viewer/overlay/details/details.dart +++ b/lib/widgets/viewer/overlay/details/details.dart @@ -15,6 +15,7 @@ import 'package:aves/widgets/viewer/overlay/details/position_title.dart'; import 'package:aves/widgets/viewer/overlay/details/rating_tags.dart'; import 'package:aves/widgets/viewer/overlay/details/shooting.dart'; import 'package:aves/widgets/viewer/page_entry_builder.dart'; +import 'package:aves_model/aves_model.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -48,9 +49,9 @@ class _ViewerDetailOverlayState extends State { AvesEntry? entryForIndex(int index) => index < entries.length ? entries[index] : null; - late Future?> _detailLoader; + late Future _detailLoader; AvesEntry? _lastEntry; - List? _lastDetails; + OverlayMetadata _lastDetails = const OverlayMetadata(); @override void initState() { @@ -70,12 +71,17 @@ class _ViewerDetailOverlayState extends State { void _initDetailLoader() { final requestEntry = entry; if (requestEntry == null) { - _detailLoader = SynchronousFuture(null); + _detailLoader = SynchronousFuture(const OverlayMetadata()); } else { - _detailLoader = Future.wait([ - settings.showOverlayShootingDetails ? metadataFetchService.getOverlayMetadata(requestEntry) : Future.value(null), - settings.showOverlayDescription ? metadataFetchService.getDescription(requestEntry) : Future.value(null), - ]); + _detailLoader = metadataFetchService.getFields(requestEntry, { + if (settings.showOverlayShootingDetails) ...{ + MetadataSyntheticField.aperture, + MetadataSyntheticField.exposureTime, + MetadataSyntheticField.focalLength, + MetadataSyntheticField.iso, + }, + if (settings.showOverlayDescription) MetadataSyntheticField.description, + }); } } @@ -84,24 +90,20 @@ class _ViewerDetailOverlayState extends State { return SafeArea( top: false, bottom: false, - child: FutureBuilder?>( + child: FutureBuilder( future: _detailLoader, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) { - _lastDetails = snapshot.data; + _lastDetails = snapshot.data!; _lastEntry = entry; } if (_lastEntry == null) return const SizedBox(); final mainEntry = _lastEntry!; - final shootingDetails = _lastDetails![0]; - final description = _lastDetails![1]; - final multiPageController = widget.multiPageController; Widget _buildContent({AvesEntry? pageEntry}) => ViewerDetailOverlayContent( pageEntry: pageEntry ?? mainEntry, - shootingDetails: shootingDetails, - description: description, + details: _lastDetails, position: widget.hasCollection ? '${widget.index + 1}/${entries.length}' : null, availableWidth: widget.availableSize.width, multiPageController: multiPageController, @@ -122,8 +124,7 @@ class _ViewerDetailOverlayState extends State { class ViewerDetailOverlayContent extends StatelessWidget { final AvesEntry pageEntry; - final OverlayMetadata? shootingDetails; - final String? description; + final OverlayMetadata details; final String? position; final double availableWidth; final MultiPageController? multiPageController; @@ -140,8 +141,7 @@ class ViewerDetailOverlayContent extends StatelessWidget { const ViewerDetailOverlayContent({ super.key, required this.pageEntry, - required this.shootingDetails, - required this.description, + required this.details, required this.position, required this.availableWidth, required this.multiPageController, @@ -244,27 +244,27 @@ class ViewerDetailOverlayContent extends StatelessWidget { Widget _buildDescriptionFullRow(BuildContext context) => _buildFullRowSwitcher( context: context, - visible: description != null, + visible: details.description != null, builder: (context) => OverlayRowExpander( expandedNotifier: expandedNotifier, - child: OverlayDescriptionRow(description: description!), + child: OverlayDescriptionRow(description: details.description!), ), ); Widget _buildShootingFullRow(BuildContext context, double subRowWidth) => _buildFullRowSwitcher( context: context, - visible: shootingDetails != null && shootingDetails!.isNotEmpty, + visible: details.hasShootingDetails, builder: (context) => SizedBox( width: subRowWidth, - child: OverlayShootingRow(details: shootingDetails!), + child: OverlayShootingRow(details: details), ), ); Widget _buildShootingSubRow(BuildContext context, double subRowWidth) => _buildSubRowSwitcher( context: context, subRowWidth: subRowWidth, - visible: shootingDetails != null && shootingDetails!.isNotEmpty, - builder: (context) => OverlayShootingRow(details: shootingDetails!), + visible: details.hasShootingDetails, + builder: (context) => OverlayShootingRow(details: details), ); Widget _buildLocationFullRow(BuildContext context) => _buildFullRowSwitcher( diff --git a/plugins/aves_model/lib/src/metadata/fields.dart b/plugins/aves_model/lib/src/metadata/fields.dart index 29c7ca15b..c61759609 100644 --- a/plugins/aves_model/lib/src/metadata/fields.dart +++ b/plugins/aves_model/lib/src/metadata/fields.dart @@ -1,3 +1,11 @@ +enum MetadataSyntheticField { + aperture, + description, + exposureTime, + focalLength, + iso, +} + enum MetadataField { exifDate, exifDateOriginal,