From 0e3cf257bdad51b40ae8976296a0a9742be928ea Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 13 Jan 2023 12:32:30 +0100 Subject: [PATCH] #443 google camera portrait mode item extraction --- .../aves/channel/calls/EmbeddedDataHandler.kt | 73 ++++++++++++++-- .../channel/calls/MetadataFetchHandler.kt | 4 +- .../aves/metadata/GoogleDeviceContainer.kt | 83 +++++++++++++++++++ .../thibault/aves/metadata/MultiPage.kt | 8 +- .../thibault/aves/metadata/SphericalVideo.kt | 22 ++--- .../deckers/thibault/aves/metadata/XMP.kt | 29 ++++--- .../aves/model/provider/ImageProvider.kt | 4 +- .../thibault/aves/utils/CollectionUtils.kt | 4 +- lib/services/media/embedded_data_service.dart | 19 +++++ lib/utils/xmp_utils.dart | 2 + .../viewer/embedded/embedded_data_opener.dart | 3 + .../viewer/embedded/notifications.dart | 15 +++- lib/widgets/viewer/info/common.dart | 2 +- .../viewer/info/metadata/xmp_card.dart | 2 +- .../viewer/info/metadata/xmp_ns/google.dart | 37 ++++++++- 15 files changed, 263 insertions(+), 44 deletions(-) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/metadata/GoogleDeviceContainer.kt 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 62f13accd..715b155b5 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,25 +11,21 @@ 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.Metadata -import deckers.thibault.aves.metadata.MultiPage +import deckers.thibault.aves.metadata.* +import deckers.thibault.aves.metadata.XMP.doesPropExist import deckers.thibault.aves.metadata.XMP.getSafeStructField -import deckers.thibault.aves.metadata.XMPPropName import deckers.thibault.aves.metadata.metadataextractor.Helper 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.BitmapUtils +import deckers.thibault.aves.utils.* 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 @@ -46,6 +42,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "getExifThumbnails" -> ioScope.launch { safeSuspend(call, result, ::getExifThumbnails) } + "extractGoogleDeviceItem" -> ioScope.launch { safe(call, result, ::extractGoogleDeviceItem) } "extractMotionPhotoImage" -> ioScope.launch { safe(call, result, ::extractMotionPhotoImage) } "extractMotionPhotoVideo" -> ioScope.launch { safe(call, result, ::extractMotionPhotoVideo) } "extractVideoEmbeddedPicture" -> ioScope.launch { safe(call, result, ::extractVideoEmbeddedPicture) } @@ -84,6 +81,68 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { result.success(thumbnails) } + private fun extractGoogleDeviceItem(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 dataUri = call.argument("dataUri") + if (mimeType == null || uri == null || sizeBytes == null || dataUri == null) { + result.error("extractGoogleDeviceItem-args", "missing arguments", null) + return + } + + var container: GoogleDeviceContainer? = null + + if (canReadWithMetadataExtractor(mimeType)) { + try { + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val metadata = Helper.safeRead(input) + // 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 { + container = xmpDirs.firstNotNullOfOrNull { + val xmpMeta = it.xmpMeta + if (xmpMeta.doesPropExist(XMP.GDEVICE_DIRECTORY_PROP_NAME)) { + GoogleDeviceContainer().apply { findItems(xmpMeta) } + } else { + null + } + } + } catch (e: XMPException) { + result.error("extractGoogleDeviceItem-xmp", "failed to read XMP directory for uri=$uri dataUri=$dataUri", 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) + } catch (e: AssertionError) { + Log.w(LOG_TAG, "failed to extract file from XMP", e) + } + } + + container?.let { + it.findOffsets(context, uri, mimeType, sizeBytes) + + val index = it.itemIndex(dataUri) + val itemStartOffset = it.itemStartOffset(index) + val itemLength = it.itemLength(index) + val itemMimeType = it.itemMimeType(index) + if (itemStartOffset != null && itemLength != null && itemMimeType != null) { + StorageUtils.openInputStream(context, uri)?.let { input -> + input.skip(itemStartOffset) + copyEmbeddedBytes(result, itemMimeType, displayName, input, itemLength) + return + } + } + } + + result.error("extractGoogleDeviceItem-empty", "failed to extract item from Google Device XMP at uri=$uri dataUri=$dataUri", 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/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index 619e090a5..291de3940 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 @@ -134,7 +134,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { if (prop is XMPPropertyInfo) { val path = prop.path if (path?.isNotEmpty() == true) { - val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value + val value = if (XMP.isDataPath(path)) VALUE_SKIPPED_DATA else prop.value if (value?.isNotEmpty() == true) { dirMap[path] = value } @@ -1281,5 +1281,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { // additional media key private const val KEY_HAS_EMBEDDED_PICTURE = "Has Embedded Picture" + + private const val VALUE_SKIPPED_DATA = "[skipped]" } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/GoogleDeviceContainer.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/GoogleDeviceContainer.kt new file mode 100644 index 000000000..590b1f65d --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/GoogleDeviceContainer.kt @@ -0,0 +1,83 @@ +package deckers.thibault.aves.metadata + +import android.content.Context +import android.net.Uri +import com.adobe.internal.xmp.XMPMeta +import deckers.thibault.aves.metadata.XMP.countPropArrayItems +import deckers.thibault.aves.metadata.XMP.getSafeStructField +import deckers.thibault.aves.utils.indexOfBytes +import java.io.DataInputStream + +class GoogleDeviceContainer { + private val jfifSignature = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte(), 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01) + + private val items: MutableList = ArrayList() + private val offsets: MutableList = ArrayList() + + fun findItems(xmpMeta: XMPMeta) { + val count = xmpMeta.countPropArrayItems(XMP.GDEVICE_DIRECTORY_PROP_NAME) + for (i in 1 until count + 1) { + val mimeType = xmpMeta.getSafeStructField(listOf(XMP.GDEVICE_DIRECTORY_PROP_NAME, i, XMP.GDEVICE_CONTAINER_ITEM_MIME_PROP_NAME))?.value + val length = xmpMeta.getSafeStructField(listOf(XMP.GDEVICE_DIRECTORY_PROP_NAME, i, XMP.GDEVICE_CONTAINER_ITEM_LENGTH_PROP_NAME))?.value?.toLongOrNull() + val dataUri = xmpMeta.getSafeStructField(listOf(XMP.GDEVICE_DIRECTORY_PROP_NAME, i, XMP.GDEVICE_CONTAINER_ITEM_DATA_URI_PROP_NAME))?.value + if (mimeType != null && length != null && dataUri != null) { + items.add( + GoogleDeviceContainerItem( + mimeType = mimeType, + length = length, + dataUri = dataUri, + ) + ) + } else throw Exception("failed to extract Google device container item at index=$i with mimeType=$mimeType, length=$length, dataUri=$dataUri") + } + } + + fun findOffsets(context: Context, uri: Uri, mimeType: String, sizeBytes: Long) { + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val bytes = ByteArray(sizeBytes.toInt()) + DataInputStream(input).use { + it.readFully(bytes) + } + + var start = 0 + while (start < sizeBytes) { + val offset = bytes.indexOfBytes(jfifSignature, start) + if (offset != -1 && offset >= start) { + start = offset + jfifSignature.size + offsets.add(offset) + } else { + start = sizeBytes.toInt() + } + } + } + + // fix first offset as it may refer to included thumbnail instead of primary image + while (offsets.size < items.size) { + offsets.add(0, 0) + } + offsets[0] = 0 + } + + fun itemIndex(dataUri: String) = items.indexOfFirst { it.dataUri == dataUri } + + private fun item(index: Int): GoogleDeviceContainerItem? { + return if (0 <= index && index < items.size) { + items[index] + } else null + } + + fun itemStartOffset(index: Int): Long? { + return if (0 <= index && index < offsets.size) { + offsets[index].toLong() + } else null + } + + fun itemLength(index: Int): Long? { + val lengthByMeta = item(index)?.length ?: return null + return if (lengthByMeta != 0L) lengthByMeta else itemStartOffset(index + 1) + } + + fun itemMimeType(index: Int) = item(index)?.mimeType +} + +class GoogleDeviceContainerItem(val mimeType: String, val length: Long, val dataUri: String) {} 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 711d62cad..1693c4bab 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 @@ -175,14 +175,14 @@ object MultiPage { if (xmpMeta.doesPropExist(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) { // `GCamera` motion photo xmpMeta.getSafeLong(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it } - } else if (xmpMeta.doesPropExist(XMP.CONTAINER_DIRECTORY_PROP_NAME)) { + } else if (xmpMeta.doesPropExist(XMP.GCONTAINER_DIRECTORY_PROP_NAME)) { // `Container` motion photo - val count = xmpMeta.countPropArrayItems(XMP.CONTAINER_DIRECTORY_PROP_NAME) + val count = xmpMeta.countPropArrayItems(XMP.GCONTAINER_DIRECTORY_PROP_NAME) if (count == 2) { // expect the video to be the second item val i = 2 - val mime = xmpMeta.getSafeStructField(listOf(XMP.CONTAINER_DIRECTORY_PROP_NAME, i, XMP.CONTAINER_ITEM_PROP_NAME, XMP.CONTAINER_ITEM_MIME_PROP_NAME))?.value - val length = xmpMeta.getSafeStructField(listOf(XMP.CONTAINER_DIRECTORY_PROP_NAME, i, XMP.CONTAINER_ITEM_PROP_NAME, XMP.CONTAINER_ITEM_LENGTH_PROP_NAME))?.value + val mime = xmpMeta.getSafeStructField(listOf(XMP.GCONTAINER_DIRECTORY_PROP_NAME, i, XMP.GCONTAINER_ITEM_PROP_NAME, XMP.GCONTAINER_ITEM_MIME_PROP_NAME))?.value + val length = xmpMeta.getSafeStructField(listOf(XMP.GCONTAINER_DIRECTORY_PROP_NAME, i, XMP.GCONTAINER_ITEM_PROP_NAME, XMP.GCONTAINER_ITEM_LENGTH_PROP_NAME))?.value if (MimeTypes.isVideo(mime) && length != null) { offsetFromEnd = length.toLong() } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SphericalVideo.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SphericalVideo.kt index e2a1c45e4..783e4b2a1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SphericalVideo.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SphericalVideo.kt @@ -42,17 +42,17 @@ class GSpherical(xmlBytes: ByteArray) { "StitchingSoftware" -> stitchingSoftware = readTag(parser, tag) "ProjectionType" -> projectionType = readTag(parser, tag) "StereoMode" -> stereoMode = readTag(parser, tag) - "SourceCount" -> sourceCount = Integer.parseInt(readTag(parser, tag)) - "InitialViewHeadingDegrees" -> initialViewHeadingDegrees = Integer.parseInt(readTag(parser, tag)) - "InitialViewPitchDegrees" -> initialViewPitchDegrees = Integer.parseInt(readTag(parser, tag)) - "InitialViewRollDegrees" -> initialViewRollDegrees = Integer.parseInt(readTag(parser, tag)) - "Timestamp" -> timestamp = Integer.parseInt(readTag(parser, tag)) - "FullPanoWidthPixels" -> fullPanoWidthPixels = Integer.parseInt(readTag(parser, tag)) - "FullPanoHeightPixels" -> fullPanoHeightPixels = Integer.parseInt(readTag(parser, tag)) - "CroppedAreaImageWidthPixels" -> croppedAreaImageWidthPixels = Integer.parseInt(readTag(parser, tag)) - "CroppedAreaImageHeightPixels" -> croppedAreaImageHeightPixels = Integer.parseInt(readTag(parser, tag)) - "CroppedAreaLeftPixels" -> croppedAreaLeftPixels = Integer.parseInt(readTag(parser, tag)) - "CroppedAreaTopPixels" -> croppedAreaTopPixels = Integer.parseInt(readTag(parser, tag)) + "SourceCount" -> sourceCount = readTag(parser, tag).toInt() + "InitialViewHeadingDegrees" -> initialViewHeadingDegrees = readTag(parser, tag).toInt() + "InitialViewPitchDegrees" -> initialViewPitchDegrees = readTag(parser, tag).toInt() + "InitialViewRollDegrees" -> initialViewRollDegrees = readTag(parser, tag).toInt() + "Timestamp" -> timestamp = readTag(parser, tag).toInt() + "FullPanoWidthPixels" -> fullPanoWidthPixels = readTag(parser, tag).toInt() + "FullPanoHeightPixels" -> fullPanoHeightPixels = readTag(parser, tag).toInt() + "CroppedAreaImageWidthPixels" -> croppedAreaImageWidthPixels = readTag(parser, tag).toInt() + "CroppedAreaImageHeightPixels" -> croppedAreaImageHeightPixels = readTag(parser, tag).toInt() + "CroppedAreaLeftPixels" -> croppedAreaLeftPixels = readTag(parser, tag).toInt() + "CroppedAreaTopPixels" -> croppedAreaTopPixels = readTag(parser, tag).toInt() } } } 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 a1f0055a7..737c862da 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 @@ -43,11 +43,13 @@ object XMP { private const val XMP_NS_URI = "http://ns.adobe.com/xap/1.0/" // other namespaces - private const val CONTAINER_NS_URI = "http://ns.google.com/photos/1.0/container/" - private const val CONTAINER_ITEM_NS_URI = "http://ns.google.com/photos/1.0/container/item/" private const val GAUDIO_NS_URI = "http://ns.google.com/photos/1.0/audio/" private const val GCAMERA_NS_URI = "http://ns.google.com/photos/1.0/camera/" + private const val GCONTAINER_NS_URI = "http://ns.google.com/photos/1.0/container/" + private const val GCONTAINER_ITEM_NS_URI = "http://ns.google.com/photos/1.0/container/item/" private const val GDEPTH_NS_URI = "http://ns.google.com/photos/1.0/depthmap/" + private const val GDEVICE_NS_URI = "http://ns.google.com/photos/dd/1.0/device/" + private const val GDEVICE_ITEM_NS_URI = "http://ns.google.com/photos/dd/1.0/item/" private const val GIMAGE_NS_URI = "http://ns.google.com/photos/1.0/image/" private const val GPANO_NS_URI = "http://ns.google.com/photos/1.0/panorama/" private const val PMTM_NS_URI = "http://www.hdrsoft.com/photomatix_settings01" @@ -75,13 +77,20 @@ object XMP { fun isDataPath(path: String) = knownDataProps.map { it.toString() }.any { path == it } + // google portrait + + val GDEVICE_DIRECTORY_PROP_NAME = XMPPropName(GDEVICE_NS_URI, "Container/Container:Directory") + val GDEVICE_CONTAINER_ITEM_DATA_URI_PROP_NAME = XMPPropName(GDEVICE_ITEM_NS_URI, "DataURI") + val GDEVICE_CONTAINER_ITEM_LENGTH_PROP_NAME = XMPPropName(GDEVICE_ITEM_NS_URI, "Length") + val GDEVICE_CONTAINER_ITEM_MIME_PROP_NAME = XMPPropName(GDEVICE_ITEM_NS_URI, "Mime") + // motion photo val GCAMERA_VIDEO_OFFSET_PROP_NAME = XMPPropName(GCAMERA_NS_URI, "MicroVideoOffset") - val CONTAINER_DIRECTORY_PROP_NAME = XMPPropName(CONTAINER_NS_URI, "Directory") - val CONTAINER_ITEM_PROP_NAME = XMPPropName(CONTAINER_NS_URI, "Item") - val CONTAINER_ITEM_LENGTH_PROP_NAME = XMPPropName(CONTAINER_ITEM_NS_URI, "Length") - val CONTAINER_ITEM_MIME_PROP_NAME = XMPPropName(CONTAINER_ITEM_NS_URI, "Mime") + val GCONTAINER_DIRECTORY_PROP_NAME = XMPPropName(GCONTAINER_NS_URI, "Directory") + val GCONTAINER_ITEM_PROP_NAME = XMPPropName(GCONTAINER_NS_URI, "Item") + val GCONTAINER_ITEM_LENGTH_PROP_NAME = XMPPropName(GCONTAINER_ITEM_NS_URI, "Length") + val GCONTAINER_ITEM_MIME_PROP_NAME = XMPPropName(GCONTAINER_ITEM_NS_URI, "Mime") // panorama // cf https://developers.google.com/streetview/spherical-metadata @@ -189,14 +198,14 @@ object XMP { if (doesPropExist(GCAMERA_VIDEO_OFFSET_PROP_NAME)) return true // Container motion photo - if (doesPropExist(CONTAINER_DIRECTORY_PROP_NAME)) { - val count = countPropArrayItems(CONTAINER_DIRECTORY_PROP_NAME) + if (doesPropExist(GCONTAINER_DIRECTORY_PROP_NAME)) { + val count = countPropArrayItems(GCONTAINER_DIRECTORY_PROP_NAME) if (count == 2) { var hasImage = false var hasVideo = false for (i in 1 until count + 1) { - val mime = getSafeStructField(listOf(CONTAINER_DIRECTORY_PROP_NAME, i, CONTAINER_ITEM_PROP_NAME, CONTAINER_ITEM_MIME_PROP_NAME))?.value - val length = getSafeStructField(listOf(CONTAINER_DIRECTORY_PROP_NAME, i, CONTAINER_ITEM_PROP_NAME, CONTAINER_ITEM_LENGTH_PROP_NAME))?.value + val mime = getSafeStructField(listOf(GCONTAINER_DIRECTORY_PROP_NAME, i, GCONTAINER_ITEM_PROP_NAME, GCONTAINER_ITEM_MIME_PROP_NAME))?.value + val length = getSafeStructField(listOf(GCONTAINER_DIRECTORY_PROP_NAME, i, GCONTAINER_ITEM_PROP_NAME, GCONTAINER_ITEM_LENGTH_PROP_NAME))?.value hasImage = hasImage || MimeTypes.isImage(mime) && length != null hasVideo = hasVideo || MimeTypes.isVideo(mime) && length != null } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index dd06a2e0c..9e2e9c9eb 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -761,8 +761,8 @@ abstract class ImageProvider { "${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$newTrailerOffset\"", ).replace( // Container motion photo - "${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"", - "${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"", + "${XMP.GCONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"", + "${XMP.GCONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"", ) }) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/CollectionUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/CollectionUtils.kt index 9fc81e5c7..55ed6ce6c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/CollectionUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/CollectionUtils.kt @@ -20,7 +20,7 @@ fun MutableList.compatRemoveIf(filter: (t: E) -> Boolean): Boolean { } // Boyer-Moore algorithm for pattern searching -fun ByteArray.indexOfBytes(pattern: ByteArray): Int { +fun ByteArray.indexOfBytes(pattern: ByteArray, start: Int = 0): Int { val n: Int = this.size val m: Int = pattern.size val badChar = Array(256) { 0 } @@ -30,7 +30,7 @@ fun ByteArray.indexOfBytes(pattern: ByteArray): Int { i += 1 } var j: Int = m - 1 - var s = 0 + var s = start while (s <= (n - m)) { while (j >= 0 && pattern[j] == this[s + j]) { j -= 1 diff --git a/lib/services/media/embedded_data_service.dart b/lib/services/media/embedded_data_service.dart index 23b47849c..33f024ba2 100644 --- a/lib/services/media/embedded_data_service.dart +++ b/lib/services/media/embedded_data_service.dart @@ -6,6 +6,8 @@ import 'package:flutter/services.dart'; abstract class EmbeddedDataService { Future> getExifThumbnails(AvesEntry entry); + Future extractGoogleDeviceItem(AvesEntry entry, String dataUri); + Future extractMotionPhotoImage(AvesEntry entry); Future extractMotionPhotoVideo(AvesEntry entry); @@ -33,6 +35,23 @@ class PlatformEmbeddedDataService implements EmbeddedDataService { return []; } + @override + Future extractGoogleDeviceItem(AvesEntry entry, String dataUri) async { + try { + final result = await _platform.invokeMethod('extractGoogleDeviceItem', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + 'displayName': ['${entry.bestTitle}', dataUri].join(Constants.separator), + 'dataUri': dataUri, + }); + if (result != null) return result as Map; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return {}; + } + @override Future extractMotionPhotoImage(AvesEntry entry) async { try { diff --git a/lib/utils/xmp_utils.dart b/lib/utils/xmp_utils.dart index f81fa46d8..72f2b3515 100644 --- a/lib/utils/xmp_utils.dart +++ b/lib/utils/xmp_utils.dart @@ -41,6 +41,7 @@ class Namespaces { static const iptc4xmpExt = 'http://iptc.org/std/Iptc4xmpExt/2008-02-29/'; static const lr = 'http://ns.adobe.com/lightroom/1.0/'; static const mediapro = 'http://ns.iview-multimedia.com/mediapro/1.0/'; + static const miCamera = 'http://ns.xiaomi.com/photos/1.0/camera/'; // also seen in the wild for prefix `MicrosoftPhoto`: 'http://ns.microsoft.com/photo/1.0' static const microsoftPhoto = 'http://ns.microsoft.com/photo/1.0/'; @@ -111,6 +112,7 @@ class Namespaces { iptc4xmpExt: 'IPTC Extension', lr: 'Lightroom', mediapro: 'MediaPro', + miCamera: 'Mi Camera', microsoftPhoto: 'Microsoft Photo 1.0', mp1: 'Microsoft Photo 1.1', mp: 'Microsoft Photo 1.2', diff --git a/lib/widgets/viewer/embedded/embedded_data_opener.dart b/lib/widgets/viewer/embedded/embedded_data_opener.dart index 288a9c256..a95a21a1a 100644 --- a/lib/widgets/viewer/embedded/embedded_data_opener.dart +++ b/lib/widgets/viewer/embedded/embedded_data_opener.dart @@ -35,6 +35,9 @@ class EmbeddedDataOpener extends StatelessWidget with FeedbackMixin { Future _openEmbeddedData(BuildContext context, OpenEmbeddedDataNotification notification) async { late Map fields; switch (notification.source) { + case EmbeddedDataSource.googleDevice: + fields = await embeddedDataService.extractGoogleDeviceItem(entry, notification.dataUri!); + break; case EmbeddedDataSource.motionPhotoVideo: fields = await embeddedDataService.extractMotionPhotoVideo(entry); break; diff --git a/lib/widgets/viewer/embedded/notifications.dart b/lib/widgets/viewer/embedded/notifications.dart index cc1581d35..a857b9aa3 100644 --- a/lib/widgets/viewer/embedded/notifications.dart +++ b/lib/widgets/viewer/embedded/notifications.dart @@ -1,20 +1,29 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -enum EmbeddedDataSource { motionPhotoVideo, videoCover, xmp } +enum EmbeddedDataSource { googleDevice, motionPhotoVideo, videoCover, xmp } @immutable class OpenEmbeddedDataNotification extends Notification { final EmbeddedDataSource source; final List? props; - final String? mimeType; + final String? mimeType, dataUri; const OpenEmbeddedDataNotification._private({ required this.source, this.props, this.mimeType, + this.dataUri, }); + factory OpenEmbeddedDataNotification.googleDevice({ + required String dataUri, + }) => + OpenEmbeddedDataNotification._private( + source: EmbeddedDataSource.googleDevice, + dataUri: dataUri, + ); + factory OpenEmbeddedDataNotification.motionPhotoVideo() => const OpenEmbeddedDataNotification._private( source: EmbeddedDataSource.motionPhotoVideo, ); @@ -34,5 +43,5 @@ class OpenEmbeddedDataNotification extends Notification { ); @override - String toString() => '$runtimeType#${shortHash(this)}{source=$source, props=$props, mimeType=$mimeType}'; + String toString() => '$runtimeType#${shortHash(this)}{source=$source, props=$props, mimeType=$mimeType, dataUri=$dataUri}'; } diff --git a/lib/widgets/viewer/info/common.dart b/lib/widgets/viewer/info/common.dart index 578a3439d..8f73ced5e 100644 --- a/lib/widgets/viewer/info/common.dart +++ b/lib/widgets/viewer/info/common.dart @@ -91,7 +91,7 @@ class _InfoRowGroupState extends State { @override Widget build(BuildContext context) { - if (keyValues.isEmpty) return const SizedBox.shrink(); + if (keyValues.isEmpty) return const SizedBox(); final _keyStyle = InfoRowGroup.keyStyle(context); diff --git a/lib/widgets/viewer/info/metadata/xmp_card.dart b/lib/widgets/viewer/info/metadata/xmp_card.dart index c4d00128c..840a46983 100644 --- a/lib/widgets/viewer/info/metadata/xmp_card.dart +++ b/lib/widgets/viewer/info/metadata/xmp_card.dart @@ -58,7 +58,7 @@ class _XmpCardState extends State { @override void didUpdateWidget(covariant XmpCard oldWidget) { super.didUpdateWidget(oldWidget); - if (_indexNotifier.value >= indexedStructCount) { + if (isIndexed && _indexNotifier.value >= indexedStructCount) { _indexNotifier.value = indexedStructCount - 1; } } diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/google.dart b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart index 06639ab7d..53833c81b 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/google.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart @@ -70,7 +70,24 @@ class XmpGDepthNamespace extends XmpGoogleNamespace { } class XmpGDeviceNamespace extends XmpNamespace { - XmpGDeviceNamespace(String nsPrefix, Map rawProps) : super(Namespaces.gDevice, nsPrefix, rawProps); + XmpGDeviceNamespace(String nsPrefix, Map rawProps) : super(Namespaces.gDevice, nsPrefix, rawProps) { + final mimePattern = RegExp(nsPrefix + r'Container/Container:Directory\[(\d+)\]/Item:Mime'); + final originalProps = rawProps.entries.toList(); + originalProps.forEach((kv) { + final path = kv.key; + final match = mimePattern.firstMatch(path); + if (match != null) { + final indexString = match.group(1); + if (indexString != null) { + final index = int.tryParse(indexString); + if (index != null) { + final dataPath = '${nsPrefix}Container/Container:Directory[$index]/Item:Data'; + rawProps[dataPath] = '[skipped]'; + } + } + } + }); + } @override late final List cards = [ @@ -82,7 +99,23 @@ class XmpGDeviceNamespace extends XmpNamespace { XmpCardData(RegExp(r'Camera:ImagingModel/(.*)')), ], ), - XmpCardData(RegExp(nsPrefix + r'Container/Container:Directory\[(\d+)\]/(.*)')), + XmpCardData( + RegExp(nsPrefix + r'Container/Container:Directory\[(\d+)\]/(.*)'), + spanBuilders: (index, struct) { + if (struct.containsKey('Item:Data') && struct.containsKey('Item:DataURI')) { + final dataUriProp = struct['Item:DataURI']; + if (dataUriProp != null) { + return { + 'Data': InfoRowGroup.linkSpanBuilder( + linkText: (context) => context.l10n.viewerInfoOpenLinkText, + onTap: (context) => OpenEmbeddedDataNotification.googleDevice(dataUri: dataUriProp.value).dispatch(context), + ), + }; + } + } + return {}; + }, + ), XmpCardData(RegExp(nsPrefix + r'Profiles\[(\d+)\]/(.*)')), ]; }