diff --git a/CHANGELOG.md b/CHANGELOG.md index f37ccc550..570a17689 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. - Cataloguing: detect/filter `Ultra HDR` - Info: show metadata from JPEG MPF +- Info: open images embedded via JPEG MPF ### Changed diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt index e956a25aa..500133fbc 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt @@ -11,21 +11,30 @@ import com.bumptech.glide.load.resource.bitmap.TransformationUtils import com.drew.metadata.xmp.XmpDirectory import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend -import deckers.thibault.aves.metadata.* +import deckers.thibault.aves.metadata.GoogleDeviceContainer +import deckers.thibault.aves.metadata.Metadata +import deckers.thibault.aves.metadata.MultiPage +import deckers.thibault.aves.metadata.XMP import deckers.thibault.aves.metadata.XMP.doesPropPathExist import deckers.thibault.aves.metadata.XMP.getSafeStructField +import deckers.thibault.aves.metadata.XMPPropName import deckers.thibault.aves.metadata.metadataextractor.Helper +import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry +import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntryDirectory import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.provider.ContentImageProvider import deckers.thibault.aves.model.provider.ImageProvider -import deckers.thibault.aves.utils.* +import deckers.thibault.aves.utils.BitmapUtils import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.FileUtils.transferFrom +import deckers.thibault.aves.utils.LogUtils +import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.isImage 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 @@ -42,6 +51,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { when (call.method) { "getExifThumbnails" -> ioScope.launch { safeSuspend(call, result, ::getExifThumbnails) } "extractGoogleDeviceItem" -> ioScope.launch { safe(call, result, ::extractGoogleDeviceItem) } + "extractJpegMultiPictureFormat" -> ioScope.launch { safe(call, result, ::extractJpegMultiPictureFormat) } "extractMotionPhotoImage" -> ioScope.launch { safe(call, result, ::extractMotionPhotoImage) } "extractMotionPhotoVideo" -> ioScope.launch { safe(call, result, ::extractMotionPhotoVideo) } "extractVideoEmbeddedPicture" -> ioScope.launch { safe(call, result, ::extractVideoEmbeddedPicture) } @@ -141,6 +151,50 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { result.error("extractGoogleDeviceItem-empty", "failed to extract item from Google Device XMP at uri=$uri dataUri=$dataUri", null) } + private fun extractJpegMultiPictureFormat(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 displayName = call.argument("displayName") + val id = call.argument("id") + if (mimeType == null || uri == null || sizeBytes == null || id == null) { + result.error("extractJpegMultiPictureFormat-args", "missing arguments", null) + return + } + + if (canReadWithMetadataExtractor(mimeType)) { + try { + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val metadata = Helper.safeRead(input) + metadata.getDirectoriesOfType(MpEntryDirectory::class.java).first { it.id == id }?.let { dir -> + val mpEntry = dir.entry + MpEntry.getMimeType(dir.entry.format)?.let { embedMimeType -> + var dataOffset = mpEntry.dataOffset + if (dataOffset > 0) { + val baseOffset = MultiPage.getJpegMultiPictureFormatBaseOffset(context, uri, sizeBytes) + if (baseOffset != null) { + dataOffset += baseOffset + } + } + StorageUtils.openInputStream(context, uri)?.let { input -> + input.skip(dataOffset) + copyEmbeddedBytes(result, embedMimeType, displayName, input, mpEntry.size) + } + return + } + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to extract file from MPF", e) + } catch (e: NoClassDefFoundError) { + Log.w(LOG_TAG, "failed to extract file from MPF", e) + } catch (e: AssertionError) { + Log.w(LOG_TAG, "failed to extract file from MPF", e) + } + } + result.error("extractJpegMultiPictureFormat-empty", "failed to extract file index=$id from MPF at uri=$uri", null) + } + private fun extractMotionPhotoImage(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt index 53c5b472d..d992a14ff 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt @@ -8,6 +8,7 @@ import android.os.Build import android.os.ParcelFileDescriptor import android.util.Log import com.adobe.internal.xmp.XMPMeta +import com.drew.imaging.jpeg.JpegSegmentType import com.drew.metadata.xmp.XmpDirectory import deckers.thibault.aves.metadata.XMP.countPropArrayItems import deckers.thibault.aves.metadata.XMP.doesPropExist @@ -203,6 +204,36 @@ object MultiPage { return offsetFromEnd } + // starts after `[APP2 marker (1 byte)] [segment size (2 bytes)] [MPF marker (4 bytes)]` + fun getJpegMultiPictureFormatBaseOffset(context: Context, uri: Uri, sizeBytes: Long): Int? { + val app2Marker = JpegSegmentType.APP2.byteValue + val mpfMarker = "MPF".toByteArray() + 0x00 + + try { + Metadata.openSafeInputStream(context, uri, MimeTypes.JPEG, sizeBytes)?.use { input -> + var offset = 0 + while (true) { + do { + val b = input.read().toByte() + offset++ + } while (b != app2Marker) + // skip 2 bytes for segment size + input.skip(2) + offset += 2 + val marker = ByteArray(4) + input.read(marker, 0, marker.size) + offset += 4 + if (marker.contentEquals(mpfMarker)) { + return offset + } + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get MPF base offset from uri=$uri", e) + } + return null + } + fun getTiffPages(context: Context, uri: Uri): ArrayList { fun toMap(page: Int, options: TiffBitmapFactory.Options): FieldMap { return hashMapOf( diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/mpf/MpEntryDirectory.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/mpf/MpEntryDirectory.kt index 7b8dcccbd..b1f217a10 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/mpf/MpEntryDirectory.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/mpf/MpEntryDirectory.kt @@ -47,10 +47,7 @@ class MpEntryDescriptor(directory: MpEntryDirectory?) : TagDescriptor MimeTypes.JPEG - else -> "Unknown ($format)" - } + return MpEntry.getMimeType(format) ?: "Unknown ($format)" } fun getTypeDescription(type: Int): String { @@ -73,4 +70,13 @@ class MpEntryDescriptor(directory: MpEntryDirectory?) : TagDescriptor MimeTypes.JPEG + else -> null + } + } + } +} diff --git a/lib/services/media/embedded_data_service.dart b/lib/services/media/embedded_data_service.dart index 137144dca..c86608fbc 100644 --- a/lib/services/media/embedded_data_service.dart +++ b/lib/services/media/embedded_data_service.dart @@ -12,6 +12,8 @@ abstract class EmbeddedDataService { Future extractMotionPhotoVideo(AvesEntry entry); + Future extractJpegMultiPictureFormat(AvesEntry entry, int index); + Future extractVideoEmbeddedPicture(AvesEntry entry); Future extractXmpDataProp(AvesEntry entry, List? props, String? propMimeType); @@ -84,6 +86,23 @@ class PlatformEmbeddedDataService implements EmbeddedDataService { return {}; } + @override + Future extractJpegMultiPictureFormat(AvesEntry entry, int id) async { + try { + final result = await _platform.invokeMethod('extractJpegMultiPictureFormat', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + 'displayName': ['${entry.bestTitle}', 'MPF #$id'].join(AText.separator), + 'id': id, + }); + if (result != null) return result as Map; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return {}; + } + @override Future extractVideoEmbeddedPicture(AvesEntry entry) async { try { diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index 871cca7e6..f1a9bda36 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -340,6 +340,7 @@ class _FeedbackMessageState extends State<_FeedbackMessage> with SingleTickerPro theme.textTheme.bodyMedium!.copyWith( color: colorScheme.onInverseSurface, ); + final contentTextFontSize = contentTextStyle.fontSize ?? theme.textTheme.bodyMedium!.fontSize!; final timerChangeShadowColor = colorScheme.primary; return Row( @@ -347,7 +348,7 @@ class _FeedbackMessageState extends State<_FeedbackMessage> with SingleTickerPro if (widget.type == FeedbackType.warn) ...[ CustomPaint( painter: const _WarnIndicator(AColors.warning), - size: Size(4, textScaler.scale(contentTextStyle.fontSize!)), + size: Size(4, textScaler.scale(contentTextFontSize)), ), const SizedBox(width: 8), ], diff --git a/lib/widgets/viewer/info/embedded/embedded_data_opener.dart b/lib/widgets/viewer/info/embedded/embedded_data_opener.dart index c7dc91b81..a34c53aa6 100644 --- a/lib/widgets/viewer/info/embedded/embedded_data_opener.dart +++ b/lib/widgets/viewer/info/embedded/embedded_data_opener.dart @@ -44,6 +44,8 @@ class EmbeddedDataOpener extends StatelessWidget with FeedbackMixin { fields = await embeddedDataService.extractGoogleDeviceItem(entry, notification.dataUri!); case EmbeddedDataSource.motionPhotoVideo: fields = await embeddedDataService.extractMotionPhotoVideo(entry); + case EmbeddedDataSource.mpf: + fields = await embeddedDataService.extractJpegMultiPictureFormat(entry, notification.mpfId!); case EmbeddedDataSource.videoCover: fields = await embeddedDataService.extractVideoEmbeddedPicture(entry); case EmbeddedDataSource.xmp: diff --git a/lib/widgets/viewer/info/embedded/notifications.dart b/lib/widgets/viewer/info/embedded/notifications.dart index a857b9aa3..b8401f27a 100644 --- a/lib/widgets/viewer/info/embedded/notifications.dart +++ b/lib/widgets/viewer/info/embedded/notifications.dart @@ -1,19 +1,21 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -enum EmbeddedDataSource { googleDevice, motionPhotoVideo, videoCover, xmp } +enum EmbeddedDataSource { googleDevice, motionPhotoVideo, mpf, videoCover, xmp } @immutable class OpenEmbeddedDataNotification extends Notification { final EmbeddedDataSource source; final List? props; final String? mimeType, dataUri; + final int? mpfId; const OpenEmbeddedDataNotification._private({ required this.source, this.props, this.mimeType, this.dataUri, + this.mpfId, }); factory OpenEmbeddedDataNotification.googleDevice({ @@ -28,6 +30,11 @@ class OpenEmbeddedDataNotification extends Notification { source: EmbeddedDataSource.motionPhotoVideo, ); + factory OpenEmbeddedDataNotification.mpf(int id) => OpenEmbeddedDataNotification._private( + source: EmbeddedDataSource.mpf, + mpfId: id, + ); + factory OpenEmbeddedDataNotification.videoCover() => const OpenEmbeddedDataNotification._private( source: EmbeddedDataSource.videoCover, ); @@ -43,5 +50,5 @@ class OpenEmbeddedDataNotification extends Notification { ); @override - String toString() => '$runtimeType#${shortHash(this)}{source=$source, props=$props, mimeType=$mimeType, dataUri=$dataUri}'; + String toString() => '$runtimeType#${shortHash(this)}{source=$source, props=$props, mimeType=$mimeType, dataUri=$dataUri, index=$mpfId}'; } diff --git a/lib/widgets/viewer/info/metadata/metadata_dir.dart b/lib/widgets/viewer/info/metadata/metadata_dir.dart index c2737e8fe..af717700d 100644 --- a/lib/widgets/viewer/info/metadata/metadata_dir.dart +++ b/lib/widgets/viewer/info/metadata/metadata_dir.dart @@ -13,9 +13,10 @@ class MetadataDirectory { // special directory names static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor static const xmpDirectory = 'XMP'; // from metadata-extractor - static const mediaDirectory = 'Media'; // custom static const coverDirectory = 'Cover'; // custom static const geoTiffDirectory = 'GeoTIFF'; // custom + static const mediaDirectory = 'Media'; // custom + static const mpfImageDirectoryPrefix = 'MPF Image #'; // custom const MetadataDirectory( this.name, diff --git a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart index 0fa09df08..28b0d5243 100644 --- a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart +++ b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart @@ -6,6 +6,7 @@ import 'package:aves/services/metadata/svg_metadata_service.dart'; import 'package:aves/theme/colors.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; +import 'package:aves/widgets/common/identity/buttons/outlined_button.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/embedded/notifications.dart'; import 'package:aves/widgets/viewer/info/metadata/geotiff.dart'; @@ -109,6 +110,19 @@ class MetadataDirTileBody extends StatelessWidget { children = [ if (showThumbnails && dirName == MetadataDirectory.exifThumbnailDirectory) MetadataThumbnails(entry: entry), + if (dirName.startsWith(MetadataDirectory.mpfImageDirectoryPrefix)) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: AvesOutlinedButton( + label: context.l10n.viewerInfoOpenLinkText, + onPressed: () { + final id = int.tryParse(dirName.substring(MetadataDirectory.mpfImageDirectoryPrefix.length)); + if (id != null) { + OpenEmbeddedDataNotification.mpf(id).dispatch(context); + } + }, + ), + ), Padding( padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), child: InfoRowGroup(