#443 google camera portrait mode item extraction

This commit is contained in:
Thibault Deckers 2023-01-13 12:32:30 +01:00
parent ebc147771c
commit 0e3cf257bd
15 changed files with 263 additions and 44 deletions

View file

@ -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<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName")
val dataUri = call.argument<String>("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<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }

View file

@ -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]"
}
}

View file

@ -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<GoogleDeviceContainerItem> = ArrayList()
private val offsets: MutableList<Int> = 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) {}

View file

@ -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()
}

View file

@ -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()
}
}
}

View file

@ -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
}

View file

@ -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\"",
)
})
}

View file

@ -20,7 +20,7 @@ fun <E> MutableList<E>.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

View file

@ -6,6 +6,8 @@ import 'package:flutter/services.dart';
abstract class EmbeddedDataService {
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
Future<Map> extractGoogleDeviceItem(AvesEntry entry, String dataUri);
Future<Map> extractMotionPhotoImage(AvesEntry entry);
Future<Map> extractMotionPhotoVideo(AvesEntry entry);
@ -33,6 +35,23 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
return [];
}
@override
Future<Map> extractGoogleDeviceItem(AvesEntry entry, String dataUri) async {
try {
final result = await _platform.invokeMethod('extractGoogleDeviceItem', <String, dynamic>{
'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<Map> extractMotionPhotoImage(AvesEntry entry) async {
try {

View file

@ -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',

View file

@ -35,6 +35,9 @@ class EmbeddedDataOpener extends StatelessWidget with FeedbackMixin {
Future<void> _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;

View file

@ -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<dynamic>? 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}';
}

View file

@ -91,7 +91,7 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
@override
Widget build(BuildContext context) {
if (keyValues.isEmpty) return const SizedBox.shrink();
if (keyValues.isEmpty) return const SizedBox();
final _keyStyle = InfoRowGroup.keyStyle(context);

View file

@ -58,7 +58,7 @@ class _XmpCardState extends State<XmpCard> {
@override
void didUpdateWidget(covariant XmpCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (_indexNotifier.value >= indexedStructCount) {
if (isIndexed && _indexNotifier.value >= indexedStructCount) {
_indexNotifier.value = indexedStructCount - 1;
}
}

View file

@ -70,7 +70,24 @@ class XmpGDepthNamespace extends XmpGoogleNamespace {
}
class XmpGDeviceNamespace extends XmpNamespace {
XmpGDeviceNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.gDevice, nsPrefix, rawProps);
XmpGDeviceNamespace(String nsPrefix, Map<String, String> 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<XmpCardData> 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+)\]/(.*)')),
];
}